feat: finalize with step 5

This commit is contained in:
jonas committed 2026-09-03 23:39:25 +02:00
1 parent 129dd5d8b8
commit aab128b55c
10 files changed
+332 -37

No files matched your search

@@ -0,0 +1,37 @@
// Command westworldwithmessaging runs the full messaging demo: Miner
// Bob and Elsa tick forward together, and each iteration also flushes
// any delayed telegrams that have come due (Dispatch.DispatchDelayedMessages).
//
// Our Clock measures real wall-clock time, same as the book's, and
// Elsa's CookStew reminder uses a real 1.5 second delay - so this
// loop sleeps a little each tick and runs for several seconds of
// real time, giving that delayed message an actual chance to fire
// before the program ends. A tight, sleep-free loop would finish in
// microseconds and the stew would never get made.
package main
import (
"fmt"
"time"
"git.jjstudio.me/jonas/Programming-Game-AI-by-Example-Go/chapter-2-state-driven-agent-design/westworldwithmessaging"
)
func main() {
bob := westworldwithmessaging.NewMiner(1)
elsa := westworldwithmessaging.NewElsa(2)
const (
numUpdateSteps = 40
tickDelay = 100 * time.Millisecond
)
for range numUpdateSteps {
westworldwithmessaging.Dispatch.DispatchDelayedMessages()
bob.Update()
elsa.Update()
time.Sleep(tickDelay)
}
fmt.Println()
}
@@ -10,6 +10,11 @@ type Elsa struct {
*BaseGameEntity *BaseGameEntity
stateMachine *StateMachine[Elsa] stateMachine *StateMachine[Elsa]
// cooking tracks whether there's already a stew in the oven, so
// CookStew.Enter doesn't put a second one in or send a second
// reminder message to herself.
cooking bool
} }
// NewElsa sets up Elsa's state machine: DoHousework is her ordinary, // NewElsa sets up Elsa's state machine: DoHousework is her ordinary,
@@ -40,6 +45,16 @@ func (e *Elsa) FSM() *StateMachine[Elsa] {
return e.stateMachine return e.stateMachine
} }
// Cooking reports whether there's already a stew in the oven.
func (e *Elsa) Cooking() bool {
return e.cooking
}
// SetCooking updates whether there's a stew in the oven.
func (e *Elsa) SetCooking(v bool) {
e.cooking = v
}
// Update satisfies the Entity interface (alongside the ID() method // Update satisfies the Entity interface (alongside the ID() method
// borrowed from BaseGameEntity) and just delegates to the state // borrowed from BaseGameEntity) and just delegates to the state
// machine, same shape as Miner.Update. // machine, same shape as Miner.Update.
@@ -1,10 +1,21 @@
package westworldwithmessaging package westworldwithmessaging
import ( import (
"fmt"
"sort" "sort"
"sync" "sync"
) )
// SendMsgImmediately and NoAdditionalInfo are the Go versions of the
// book's "SEND_MSG_IMMEDIATELY" and "NO_ADDITIONAL_INFO" - both
// defined as 0 in the book's MessageDispatcher.h purely so call
// sites read clearly instead of showing a bare 0/NULL. NoAdditionalInfo
// is nil rather than 0, since Go's nil is what an empty "any" (our
// ExtraInfo field's type) actually looks like.
const SendMsgImmediately = 0.0
var NoAdditionalInfo any = nil
// messageDispatcher is the Go version of the singleton class // messageDispatcher is the Go version of the singleton class
// MessageDispatcher. Same singleton pattern as everything else in // MessageDispatcher. Same singleton pattern as everything else in
// this package: unexported type, one shared instance, reached only // this package: unexported type, one shared instance, reached only
@@ -109,6 +120,9 @@ func (d *messageDispatcher) DispatchMessage(delay float64, sender int, receiver
// if there is no delay, route the telegram immediately // if there is no delay, route the telegram immediately
if delay <= 0.0 { if delay <= 0.0 {
fmt.Printf("\nInstant telegram dispatched at time: %.5f by %s for %s. Msg is %s",
Clock.GetCurrentTime(), GetNameOfEntity(sender), GetNameOfEntity(receiver), GetNameOfMessage(MessageType(msg)))
// send the telegram to the recipient // send the telegram to the recipient
d.discharge(pReceiver, telegram) d.discharge(pReceiver, telegram)
} else { } else {
@@ -118,6 +132,9 @@ func (d *messageDispatcher) DispatchMessage(delay float64, sender int, receiver
// and put it in the queue // and put it in the queue
d.insertIntoPriorityQueue(telegram) d.insertIntoPriorityQueue(telegram)
fmt.Printf("\nDelayed telegram from %s recorded at time %.5f for %s. Msg is %s",
GetNameOfEntity(sender), currentTime, GetNameOfEntity(receiver), GetNameOfMessage(MessageType(msg)))
} }
} }
@@ -146,6 +163,9 @@ func (d *messageDispatcher) DispatchDelayedMessages() {
// read the telegram from the front of the queue // read the telegram from the front of the queue
telegram := d.priorityQueue[0] telegram := d.priorityQueue[0]
fmt.Printf("\nQueued telegram ready for dispatch: Sent to %s. Msg is %s",
GetNameOfEntity(telegram.Receiver), GetNameOfMessage(MessageType(telegram.Msg)))
// find the recipient // find the recipient
pReceiver := EntityMgr.GetEntityFromID(telegram.Receiver) pReceiver := EntityMgr.GetEntityFromID(telegram.Receiver)
@@ -1,5 +1,7 @@
package westworldwithmessaging package westworldwithmessaging
import "fmt"
// MessageType is the Go version of the C++ enum: // MessageType is the Go version of the C++ enum:
// //
// enum message_type // enum message_type
@@ -22,3 +24,24 @@ const (
MsgHiHoneyImHome MessageType = iota MsgHiHoneyImHome MessageType = iota
MsgStewReady MsgStewReady
) )
// messageNames is our own stand-in for however the book actually
// renders a message type in its dispatcher log lines (the sample
// output shows names like "HiHoneyImHome" and "StewReady" - the enum
// constant with the "Msg_" prefix dropped). We haven't been shown
// the book's real helper for this, so this recreates exactly that
// naming.
var messageNames = map[MessageType]string{
MsgHiHoneyImHome: "HiHoneyImHome",
MsgStewReady: "StewReady",
}
// GetNameOfMessage looks up a friendly name for a message type, for
// use in log output. Anything not in messageNames falls back to
// printing its raw number.
func GetNameOfMessage(msg MessageType) string {
if name, ok := messageNames[msg]; ok {
return name
}
return fmt.Sprintf("Msg %d", int(msg))
}
@@ -2,13 +2,23 @@ package westworldwithmessaging
import "fmt" import "fmt"
// entMinerBob and entElsa are our own stand-ins for the book's
// enumerated entity IDs (referenced as "ent_Miner_Bob" and "ent_Elsa"
// - presumably from an enum in a header we haven't been shown). Since
// this demo only ever has these two fixed characters, plain named
// constants do the same job: giving call sites a readable name
// instead of a bare number.
const (
entMinerBob = 1
entElsa = 2
)
// entityNames is a temporary stand-in for the book's real name // entityNames is a temporary stand-in for the book's real name
// lookup table. We still haven't been shown that table, so this is // lookup table. We still haven't been shown that table, so this is
// just a small map we're filling in by hand as we meet entities - // just a small map we're filling in by hand as we meet entities.
// the miner (ID 1) and now his wife Elsa (ID 2).
var entityNames = map[int]string{ var entityNames = map[int]string{
1: "Miner Bob", entMinerBob: "Miner Bob",
2: "Elsa", entElsa: "Elsa",
} }
// GetNameOfEntity looks up a friendly name for a given entity ID. // GetNameOfEntity looks up a friendly name for a given entity ID.
@@ -0,0 +1,90 @@
package westworldwithmessaging
import (
"fmt"
"sync"
)
// cookStew is the Go version of the (not yet pasted) singleton class
// CookStew from the book. Same pattern as every other state:
// unexported type, one shared instance, reached only through the
// function below.
type cookStew struct{}
var (
cookStewInstance *cookStew
cookStewOnce sync.Once
)
// CookStew is the Go stand-in for "CookStew::Instance()".
func CookStew() *cookStew {
cookStewOnce.Do(func() {
cookStewInstance = &cookStew{}
})
return cookStewInstance
}
var _ State[Elsa] = (*cookStew)(nil)
// Execute and Exit weren't pasted to us directly either, but a
// sample run of the book's program makes their behavior clear
// enough to fill in: Execute prints "Fussin' over food" once per
// tick while the stew is in the oven (waiting for the delayed
// Msg_StewReady reminder to arrive), and Exit announces taking the
// stew out and putting it on the table, right before switching back
// to DoHousework.
// Enter puts the stew in the oven (if one isn't already cooking) and
// sends a delayed message to herself, reminding her to take it out
// once it's ready.
func (s *cookStew) Enter(e *Elsa) {
if !e.Cooking() {
fmt.Printf("\n%s: Puttin' the stew in the oven", GetNameOfEntity(e.ID()))
// send a delayed message to myself so that I know when to take
// the stew out of the oven
Dispatch.DispatchMessage(
1.5, // time delay
e.ID(), // sender ID
e.ID(), // receiver ID
int(MsgStewReady), // the message
NoAdditionalInfo, // no extra info attached
)
e.SetCooking(true)
}
}
func (s *cookStew) Execute(e *Elsa) {
fmt.Printf("\n%s: Fussin' over food", GetNameOfEntity(e.ID()))
}
func (s *cookStew) Exit(e *Elsa) {
fmt.Printf("\n%s: Puttin' the stew on the table", GetNameOfEntity(e.ID()))
}
// OnMessage handles the delayed reminder Enter sent to herself: once
// the stew's ready, take it out, let Bob know dinner's on the table,
// and go back to the ordinary housework state.
func (s *cookStew) OnMessage(e *Elsa, msg Telegram) bool {
switch MessageType(msg.Msg) {
case MsgStewReady:
fmt.Printf("\nMessage received by %s at time: %.5f", GetNameOfEntity(e.ID()), Clock.GetCurrentTime())
fmt.Printf("\n%s: Stew ready! Let's eat", GetNameOfEntity(e.ID()))
// let hubby know the stew is ready
Dispatch.DispatchMessage(
SendMsgImmediately,
e.ID(),
entMinerBob,
int(MsgStewReady),
NoAdditionalInfo,
)
e.SetCooking(false)
e.FSM().ChangeState(DoHousework())
return true
}
return false
}
@@ -26,25 +26,33 @@ func DoHousework() *doHousework {
var _ State[Elsa] = (*doHousework)(nil) var _ State[Elsa] = (*doHousework)(nil)
// chores is our own small stand-in for the book's actual chore list // chores is our own small stand-in for the book's actual chore list
// - it only ever showed two examples in its sample output ("Moppin' // - its sample output shows three examples ("Moppin' the floor",
// the floor", "Makin' the bed"), so that's exactly what's reused // "Makin' the bed", "Washin' the dishes"), so that's exactly what's
// here. One gets picked at random each tick. // reused here. One gets picked at random each tick.
var chores = []string{ var chores = []string{
"Moppin' the floor", "Moppin' the floor",
"Makin' the bed", "Makin' the bed",
"Washin' the dishes",
} }
// Enter does nothing - the book's sample output never shows a // Enter announces that Elsa is back to her ordinary routine. A
// message when Elsa starts doing housework, only while she's doing // sample run of the book's program shows this line printed right
// it (Execute) and when she leaves the bathroom back into it. // after she finishes cooking and switches back to this state - an
func (s *doHousework) Enter(e *Elsa) {} // earlier, more abridged sample output we saw didn't show this line
// after a bathroom-break revert, but that sample was explicitly
// described as just "a sample," not a complete transcript, so this
// message firing every time Enter runs (bathroom revert included)
// isn't a contradiction.
func (s *doHousework) Enter(e *Elsa) {
fmt.Printf("\n%s: Time to do some more housework!", GetNameOfEntity(e.ID()))
}
// Execute announces one randomly chosen chore per tick. // Execute announces one randomly chosen chore per tick.
func (s *doHousework) Execute(e *Elsa) { func (s *doHousework) Execute(e *Elsa) {
fmt.Printf("\n%s: %s", GetNameOfEntity(e.ID()), chores[rand.Intn(len(chores))]) fmt.Printf("\n%s: %s", GetNameOfEntity(e.ID()), chores[rand.Intn(len(chores))])
} }
// Exit does nothing, for the same reason as Enter. // Exit does nothing - no sample output ever shows a message here.
func (s *doHousework) Exit(e *Elsa) {} func (s *doHousework) Exit(e *Elsa) {}
// OnMessage is required now that State[T] includes it, so that every // OnMessage is required now that State[T] includes it, so that every
@@ -0,0 +1,62 @@
package westworldwithmessaging
import (
"fmt"
"sync"
)
// eatStew is the Go version of the (not yet pasted) singleton class
// EatStew from the book. Same pattern as every other state:
// unexported type, one shared instance, reached only through the
// function below.
type eatStew struct{}
var (
eatStewInstance *eatStew
eatStewOnce sync.Once
)
// EatStew is the Go stand-in for "EatStew::Instance()".
func EatStew() *eatStew {
eatStewOnce.Do(func() {
eatStewInstance = &eatStew{}
})
return eatStewInstance
}
var _ State[Miner] = (*eatStew)(nil)
// The book never pasted this state's actual source - it only ever
// referenced EatStew::Instance() as a switch target. This whole
// implementation is reverse-engineered from a sample run of the
// finished program, which shows this exact sequence: Bob enters
// (one line), eats (one line, then immediately heads back to
// whatever he was doing - a single-tick state, same shape as
// QuenchThirst), and leaving prints one more line before he's back
// in GoHomeAndSleepTilRested.
// Enter announces that dinner smells good.
func (s *eatStew) Enter(m *Miner) {
fmt.Printf("\n%s: Smells reaaal goood, Elsa!", GetNameOfEntity(m.ID()))
}
// Execute is the entire meal: announce it, then head back to
// whatever state he was in before dinner interrupted him. The sample
// output never shows more than one "Tastes real good too!" line, so
// - like QuenchThirst - this doesn't loop across multiple ticks.
func (s *eatStew) Execute(m *Miner) {
fmt.Printf("\n%s: Tastes real good too!", GetNameOfEntity(m.ID()))
m.ChangeState(GoHomeAndSleepTilRested())
}
// Exit thanks Elsa for the meal on the way out.
func (s *eatStew) Exit(m *Miner) {
fmt.Printf("\n%s: Thank ya li'l lady. Ah better get back to whatever ah wuz doin'", GetNameOfEntity(m.ID()))
}
// OnMessage is required now that State[T] includes it. The sample
// output doesn't show Bob reacting to any message while eating, so
// false ("not handled") is the correct default.
func (s *eatStew) OnMessage(m *Miner, msg Telegram) bool {
return false
}
@@ -28,41 +28,64 @@ func GoHomeAndSleepTilRested() *goHomeAndSleepTilRested {
var _ State[Miner] = (*goHomeAndSleepTilRested)(nil) var _ State[Miner] = (*goHomeAndSleepTilRested)(nil)
// The book hasn't shown us this state's real code, so this is our // Enter sends the miner home, if he isn't there already, and lets
// own implementation based purely on its written description: "the // Elsa know he's arrived by dispatching a message to her.
// miner will return to his shack and sleep until his fatigue level
// drops below an acceptable level. He will then change state to
// EnterMineAndDigForNugget."
// Enter sends the miner home, if he isn't there already.
func (s *goHomeAndSleepTilRested) Enter(m *Miner) { func (s *goHomeAndSleepTilRested) Enter(m *Miner) {
if m.Location() != shack { if m.Location() != shack {
fmt.Printf("\n%s: Headin' on home", GetNameOfEntity(m.ID())) fmt.Printf("\n%s: Walkin' home", GetNameOfEntity(m.ID()))
m.ChangeLocation(shack) m.ChangeLocation(shack)
// let the wife know I'm home
Dispatch.DispatchMessage(
SendMsgImmediately, // time delay
m.ID(), // ID of sender
entElsa, // ID/name of recipient
int(MsgHiHoneyImHome), // the message
NoAdditionalInfo, // no extra info attached
)
} }
} }
// Execute sleeps off a little fatigue each tick, and once the miner // Execute sleeps off a little fatigue each tick, and once the miner
// is rested enough, sends him back to work. // is rested enough, announces it and sends him back to work. A
// sample run of the book's program shows this exact message
// ("All mah fatigue has drained away...") printed right before the
// switch to digging - and no message at all when this state is
// instead exited early via a Msg_StewReady interruption (see
// OnMessage below) - which is why this line lives here in Execute's
// else-branch rather than in Exit: Exit runs either way, but this
// text is specific to waking up rested, not to leaving for any
// reason.
func (s *goHomeAndSleepTilRested) Execute(m *Miner) { func (s *goHomeAndSleepTilRested) Execute(m *Miner) {
if m.Fatigued() { if m.Fatigued() {
fmt.Printf("\n%s: ZZZZ... ", GetNameOfEntity(m.ID())) fmt.Printf("\n%s: ZZZZ... ", GetNameOfEntity(m.ID()))
m.DecreaseFatigue() m.DecreaseFatigue()
} else { } else {
fmt.Printf("\n%s: All mah fatigue has drained away. Time to find more gold!", GetNameOfEntity(m.ID()))
m.ChangeState(EnterMineAndDigForNugget()) m.ChangeState(EnterMineAndDigForNugget())
} }
} }
// Exit announces that the miner is up and about again. // Exit does nothing - a sample run of the book's program shows no
func (s *goHomeAndSleepTilRested) Exit(m *Miner) { // message printed here, whether the miner leaves because he's
fmt.Printf("\n%s: What a great sleep! Time to find more gold", GetNameOfEntity(m.ID())) // rested or because dinner's ready.
func (s *goHomeAndSleepTilRested) Exit(m *Miner) {}
// OnMessage is what makes Bob only respond to Elsa's dinner call
// while he's actually home to hear it: this state only exists while
// he's in the shack, so putting the Msg_StewReady handling here
// (rather than in MinerGlobalState) is exactly how the book
// guarantees "he will only respond to this message if he is in the
// GoHomeAndSleepTilRested state."
func (s *goHomeAndSleepTilRested) OnMessage(m *Miner, msg Telegram) bool {
switch MessageType(msg.Msg) {
case MsgStewReady:
fmt.Printf("\nMessage handled by %s at time: %.5f", GetNameOfEntity(m.ID()), Clock.GetCurrentTime())
fmt.Printf("\n%s: Okay hun, ahm a-comin'!", GetNameOfEntity(m.ID()))
m.ChangeState(EatStew())
return true
} }
// OnMessage is required now that State[T] includes it. This state // send message to global message handler
// doesn't handle any messages yet (the book hasn't shown us that a
// sleeping miner reacts to anything, like a "stew's ready" message,
// though that seems like a likely candidate later), so false ("not
// handled") is the correct default for now.
func (s *goHomeAndSleepTilRested) OnMessage(m *Miner, msg Telegram) bool {
return false return false
} }
@@ -1,6 +1,7 @@
package westworldwithmessaging package westworldwithmessaging
import ( import (
"fmt"
"math/rand" "math/rand"
"sync" "sync"
) )
@@ -49,13 +50,19 @@ func (s *wifesGlobalState) Execute(e *Elsa) {
} }
} }
// OnMessage is required now that State[T] includes it. This state // OnMessage handles messages that should get through no matter what
// doesn't handle any messages yet - false ("not handled") is the // Elsa is currently doing - the book puts Msg_HiHoneyImHome here
// correct default until the book shows otherwise. (Note: our Elsa // specifically because she should react to it "when in any state,"
// only has DoHousework and VisitBathroom, not a cooking state, so // same reasoning as why the bathroom-call check above lives in the
// the book's stew-related messages between Miner and Elsa may not // global state rather than in DoHousework.
// map cleanly onto this simplified version - worth revisiting if
// that comes up.)
func (s *wifesGlobalState) OnMessage(e *Elsa, msg Telegram) bool { func (s *wifesGlobalState) OnMessage(e *Elsa, msg Telegram) bool {
switch MessageType(msg.Msg) {
case MsgHiHoneyImHome:
fmt.Printf("\nMessage handled by %s at time: %.5f", GetNameOfEntity(e.ID()), Clock.GetCurrentTime())
fmt.Printf("\n%s: Hi honey. Let me make you some of mah fine country stew", GetNameOfEntity(e.ID()))
e.FSM().ChangeState(CookStew())
return true
}
return false return false
} }