feat: westworldwithmessaging. add entitymanager, telegram, messagehandlers, delayedmessages, expand baseentity

This commit is contained in:
jonas committed 2026-09-03 21:57:52 +02:00
1 parent 0e7482e04c
commit 129dd5d8b8
18 files changed
+505 -19

No files matched your search

@@ -0,0 +1,40 @@
package westworldwithmessaging
import (
"sync"
"time"
)
// clockSingleton is our own stand-in for whatever timer/clock utility
// class the book uses elsewhere (it only shows up here as
// "Clock->GetCurrentTime()" - the book hasn't shown us that class's
// actual source, likely a separate timer file). All DispatchMessage
// and DispatchDelayedMessages actually need is a steadily increasing
// number of seconds, so this just measures time elapsed since the
// clock was first used.
type clockSingleton struct {
start time.Time
}
var (
clockInstance *clockSingleton
clockOnce sync.Once
)
func newClock() *clockSingleton {
clockOnce.Do(func() {
clockInstance = &clockSingleton{start: time.Now()}
})
return clockInstance
}
// Clock is a shorthand for the clock singleton, same "#define"-style
// convenience pattern as EntityMgr and Dispatch.
var Clock = newClock()
// GetCurrentTime returns the number of seconds elapsed since the
// clock started, matching the book's
// "double CurrentTime = Clock->GetCurrentTime();".
func (c *clockSingleton) GetCurrentTime() float64 {
return time.Since(c.start).Seconds()
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
// Elsa is the miner's wife, introduced in this section of the book.
// She's built the exact same way Miner is: embed *BaseGameEntity for
@@ -28,6 +28,10 @@ func NewElsa(id int) *Elsa {
e.stateMachine.SetCurrentState(DoHousework())
e.stateMachine.SetGlobalState(WifesGlobalState())
// Register with the entity manager automatically, same reasoning
// as NewMiner - one less manual step for every caller to remember.
EntityMgr.RegisterEntity(e)
return e
}
@@ -47,3 +51,9 @@ func (e *Elsa) Update() {
func (e *Elsa) ChangeState(newState State[Elsa]) {
e.stateMachine.ChangeState(newState)
}
// HandleMessage forwards to the state machine, same as
// Miner.HandleMessage.
func (e *Elsa) HandleMessage(msg Telegram) bool {
return e.stateMachine.HandleMessage(msg)
}
@@ -1,15 +1,21 @@
package westworldwithwoman
package westworldwithmessaging
// Entity is what every game object in the world must be able to do.
// The C++ code used a pure virtual function (Update()=0) to force
// every subclass to write its own Update. Go has no subclasses, so
// instead we say: "anything with an Update() and an ID() method
// counts as an Entity." A type only satisfies this interface once
// it has its own Update method - just like the C++ version couldn't
// be used until Update was implemented.
// The C++ code used pure virtual functions (Update()=0, and now
// HandleMessage()=0) to force every subclass to write its own
// versions. Go has no subclasses, so instead we say: "anything with
// Update(), ID() and HandleMessage() methods counts as an Entity." A
// type only satisfies this interface once it has all three - just
// like the C++ version couldn't be used until both were implemented.
type Entity interface {
Update()
ID() int
// HandleMessage lets an entity respond to a Telegram sent to it
// by the message dispatcher. It returns true if the message was
// actually handled, false otherwise - callers use that to decide
// whether to try routing the message somewhere else instead.
HandleMessage(msg Telegram) bool
}
// nextValidID plays the role of the C++ "static int m_iNextValidID".
@@ -0,0 +1,70 @@
package westworldwithmessaging
import "sync"
// entityManager is the Go version of the singleton class
// EntityManager. Same singleton pattern used throughout this
// package: an unexported type, one shared instance, reached only
// through the EntityManager() function below.
//
// The C++ version stores "BaseGameEntity*" pointers in its map. In
// C++, a BaseGameEntity* pointing at an actual Miner still calls
// Miner's real Update() (that's what "virtual" functions do - even
// through a base-class pointer, the real subclass's version runs).
// Go doesn't have virtual functions or base-class pointers, but our
// Entity interface (from entity.go: Update() and ID()) gives us the
// exact same behavior - a variable of type Entity holding a *Miner
// still calls *Miner's own Update(). So Entity is what we store here
// instead of *BaseGameEntity, to get the same real-subclass-runs
// behavior C++ gets from virtual dispatch.
type entityManager struct {
// entityMap cross-references entities by their ID number, same
// as the book's std::map<int, BaseGameEntity*> m_EntityMap. (The
// book's own comment above that field actually describes a
// std::vector instead - a leftover mismatch in the book's text
// itself. We're matching the real field, the map.)
entityMap map[int]Entity
}
var (
entityManagerInstance *entityManager
entityManagerOnce sync.Once
)
// EntityManager is the Go stand-in for "EntityManager::Instance()".
func EntityManager() *entityManager {
entityManagerOnce.Do(func() {
entityManagerInstance = &entityManager{
entityMap: make(map[int]Entity),
}
})
return entityManagerInstance
}
// EntityMgr is a shorthand for EntityManager(), matching the book's
// "#define EntityMgr EntityManager::Instance()" macro. Go has no
// preprocessor and no macros, so instead this is just a plain
// package-level variable, set up once when the package is loaded -
// EntityMgr.RegisterEntity(...) reads the same as the book's
// EntityMgr->RegisterEntity(...), just with a dot instead of an
// arrow (Go doesn't distinguish "." for values from "->" for
// pointers the way C++ does - it's always ".").
var EntityMgr = EntityManager()
// RegisterEntity stores an entity so it can be looked up later by
// its ID.
func (m *entityManager) RegisterEntity(newEntity Entity) {
m.entityMap[newEntity.ID()] = newEntity
}
// GetEntityFromID returns the entity with the given ID, or nil if
// none is registered under that ID (same as the C++ version
// implicitly returning nullptr for a lookup miss).
func (m *entityManager) GetEntityFromID(id int) Entity {
return m.entityMap[id]
}
// RemoveEntity removes an entity from the registry.
func (m *entityManager) RemoveEntity(entity Entity) {
delete(m.entityMap, entity.ID())
}
@@ -0,0 +1,158 @@
package westworldwithmessaging
import (
"sort"
"sync"
)
// messageDispatcher is the Go version of the singleton class
// MessageDispatcher. Same singleton pattern as everything else in
// this package: unexported type, one shared instance, reached only
// through the MessageDispatcher() function below.
type messageDispatcher struct {
// priorityQueue holds telegrams that haven't gone out yet because
// they were sent with a delay, mirroring the book's
// "std::set<Telegram> PriorityQ". A std::set keeps its contents
// sorted (here, by DispatchTime) and automatically drops
// duplicates. Go has no built-in sorted-set container, so we
// reproduce both properties by hand: insertIntoPriorityQueue
// (below) always keeps this slice sorted by DispatchTime, and
// skips inserting anything that looks like a near-duplicate of
// something already queued (see telegramsMatch in telegram.go).
// index 0 is always the next telegram due to go out.
priorityQueue []Telegram
}
var (
messageDispatcherInstance *messageDispatcher
messageDispatcherOnce sync.Once
)
// MessageDispatcher is the Go stand-in for
// "MessageDispatcher::Instance()".
func MessageDispatcher() *messageDispatcher {
messageDispatcherOnce.Do(func() {
messageDispatcherInstance = &messageDispatcher{}
})
return messageDispatcherInstance
}
// Dispatch is a shorthand for MessageDispatcher(), matching the
// book's "#define Dispatch MessageDispatcher::Instance()" macro -
// same trick as EntityMgr earlier: Go has no preprocessor, so this
// is a plain package-level variable instead, set up once when the
// package loads. Dispatch.DispatchMessage(...) reads the same as the
// book's Dispatch->DispatchMessage(...).
var Dispatch = MessageDispatcher()
// discharge is the Go version of the private C++ method Discharge.
// It's what actually hands a telegram to its receiving entity - both
// DispatchMessage (for immediate messages) and
// DispatchDelayedMessages (once a delayed one comes due) call this
// to do the real delivery. The book never pasted this method's body
// directly, but we now have everything it needs: Entity.HandleMessage
// (added this round), which is exactly "the message handling method
// of the receiving entity" the book described back when DispatchMessage
// was introduced.
//
// One thing we added that the book's description didn't mention: a
// nil check. GetEntityFromID returns nil for an unregistered ID, and
// calling a method on a nil Entity would panic - the book's C++
// equivalent (calling through a null BaseGameEntity*) would crash
// the same way, so this guard is a real improvement, not just a
// style choice.
func (d *messageDispatcher) discharge(receiver Entity, msg Telegram) {
if receiver == nil {
return
}
receiver.HandleMessage(msg)
}
// insertIntoPriorityQueue is our Go stand-in for
// "PriorityQ.insert(telegram)". A C++ std::set does two things on
// insert: it keeps everything sorted, and it silently refuses to add
// something that's already there (per whatever == means for the
// contained type). We reproduce both by hand here: skip the insert
// entirely if an equivalent telegram (telegramsMatch, see
// telegram.go) is already queued, otherwise insert it at the correct
// sorted position by DispatchTime.
func (d *messageDispatcher) insertIntoPriorityQueue(t Telegram) {
for _, existing := range d.priorityQueue {
if telegramsMatch(existing, t) {
return
}
}
i := sort.Search(len(d.priorityQueue), func(i int) bool {
return d.priorityQueue[i].DispatchTime >= t.DispatchTime
})
d.priorityQueue = append(d.priorityQueue, Telegram{})
copy(d.priorityQueue[i+1:], d.priorityQueue[i:])
d.priorityQueue[i] = t
}
// DispatchMessage sends a message to another entity, either right
// now (delay <= 0) or at some point in the future (delay > 0, added
// to the current time to get the telegram's DispatchTime).
func (d *messageDispatcher) DispatchMessage(delay float64, sender int, receiver int, msg int, extraInfo any) {
// get a pointer to the receiver of the message
pReceiver := EntityMgr.GetEntityFromID(receiver)
// create the telegram
telegram := Telegram{
Sender: sender,
Receiver: receiver,
Msg: msg,
DispatchTime: 0,
ExtraInfo: extraInfo,
}
// if there is no delay, route the telegram immediately
if delay <= 0.0 {
// send the telegram to the recipient
d.discharge(pReceiver, telegram)
} else {
// calculate the time when the telegram should be dispatched
currentTime := Clock.GetCurrentTime()
telegram.DispatchTime = currentTime + delay
// and put it in the queue
d.insertIntoPriorityQueue(telegram)
}
}
// DispatchDelayedMessages is meant to be called once per pass through
// the main game loop: it checks the front of priorityQueue and
// discharges any telegram whose DispatchTime has arrived.
//
// One difference from the book's version worth flagging: the C++
// code checks PriorityQ.begin()->DispatchTime without first checking
// whether PriorityQ is empty - calling begin() on an empty std::set
// and dereferencing it is undefined behavior in C++. Indexing an
// empty Go slice would panic the same way, so we added the
// len(d.priorityQueue) > 0 check ourselves to actually be safe,
// rather than translate that bug faithfully.
func (d *messageDispatcher) DispatchDelayedMessages() {
// first get current time
currentTime := Clock.GetCurrentTime()
// now peek at the queue to see if any telegrams need dispatching.
// remove all telegrams from the front of the queue that have gone
// past their sell-by date
for len(d.priorityQueue) > 0 &&
d.priorityQueue[0].DispatchTime < currentTime &&
d.priorityQueue[0].DispatchTime > 0 {
// read the telegram from the front of the queue
telegram := d.priorityQueue[0]
// find the recipient
pReceiver := EntityMgr.GetEntityFromID(telegram.Receiver)
// send the telegram to the recipient
d.discharge(pReceiver, telegram)
// and remove it from the queue
d.priorityQueue = d.priorityQueue[1:]
}
}
@@ -0,0 +1,24 @@
package westworldwithmessaging
// MessageType is the Go version of the C++ enum:
//
// enum message_type
// {
// Msg_HiHoneyImHome,
// Msg_StewReady
// };
//
// from the book's "MessageTypes.h". Same trick used for locationType
// back in the westworld package: a named int type plus a block of
// auto-numbered constants.
type MessageType int
// MsgHiHoneyImHome and MsgStewReady are the two message types the
// book has shown us so far - more get added here as the book
// introduces them. (Go naming convention drops underscores from
// constant names, hence "MsgHiHoneyImHome" instead of the C++
// "Msg_HiHoneyImHome".)
const (
MsgHiHoneyImHome MessageType = iota
MsgStewReady
)
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
// State is the Go version of the C++ class template:
//
@@ -39,6 +39,14 @@ type State[T any] interface {
// Exit runs once, the moment the entity leaves this state.
Exit(entity *T)
// OnMessage runs when the entity receives a Telegram while this
// state is active (current or global). It returns true if this
// state actually dealt with the message, false if it doesn't
// care about that kind of message - StateMachine.HandleMessage
// uses that return value to decide whether to also try routing
// the message to the global state.
OnMessage(entity *T, msg Telegram) bool
}
// locationType is a temporary stand-in for the C++ enum
@@ -116,6 +124,13 @@ func NewMiner(id int) *Miner {
m.stateMachine.SetCurrentState(GoHomeAndSleepTilRested())
m.stateMachine.SetGlobalState(MinerGlobalState())
// The book registers an entity as a separate step at the call
// site ("Miner* Bob = new Miner(...); EntityMgr->RegisterEntity(Bob);").
// We're folding that into the constructor instead, so every miner
// is automatically registered and reachable by ID the moment it
// exists - nobody can forget the second step.
EntityMgr.RegisterEntity(m)
return m
}
@@ -246,3 +261,11 @@ func (m *Miner) Update() {
func (m *Miner) ChangeState(newState State[Miner]) {
m.stateMachine.ChangeState(newState)
}
// HandleMessage is the Go version of Miner::HandleMessage - it does
// nothing itself, just routes the telegram straight to the state
// machine, which knows how to try the current state first and the
// global state second.
func (m *Miner) HandleMessage(msg Telegram) bool {
return m.stateMachine.HandleMessage(msg)
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import "fmt"
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import (
"fmt"
@@ -46,3 +46,12 @@ func (s *doHousework) Execute(e *Elsa) {
// Exit does nothing, for the same reason as Enter.
func (s *doHousework) Exit(e *Elsa) {}
// OnMessage is required now that State[T] includes it, so that every
// state satisfies the interface. This state doesn't do anything
// special with messages yet - false means "I didn't handle it,"
// which lets StateMachine.HandleMessage fall through and try the
// global state instead.
func (s *doHousework) OnMessage(e *Elsa, msg Telegram) bool {
return false
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import (
"fmt"
@@ -80,3 +80,10 @@ func (s *enterMineAndDigForNugget) Execute(m *Miner) {
func (s *enterMineAndDigForNugget) Exit(m *Miner) {
fmt.Printf("\n%s: Ah'm leavin' the gold mine with mah pockets full o' sweet gold", GetNameOfEntity(m.ID()))
}
// OnMessage is required now that State[T] includes it. This state
// doesn't handle any messages yet - false ("not handled") is the
// correct default until the book shows otherwise.
func (s *enterMineAndDigForNugget) OnMessage(m *Miner, msg Telegram) bool {
return false
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import (
"fmt"
@@ -57,3 +57,12 @@ func (s *goHomeAndSleepTilRested) Execute(m *Miner) {
func (s *goHomeAndSleepTilRested) Exit(m *Miner) {
fmt.Printf("\n%s: What a great sleep! Time to find more gold", GetNameOfEntity(m.ID()))
}
// OnMessage is required now that State[T] includes it. This state
// 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
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
// StateMachine is the Go version of the C++ class template:
//
@@ -129,3 +129,25 @@ func (sm *StateMachine[T]) PreviousState() State[T] {
func (sm *StateMachine[T]) IsInState(st State[T]) bool {
return sm.currentState == st
}
// HandleMessage is the Go version of StateMachine::HandleMessage. A
// telegram is offered to the current state first; if that state
// doesn't have code in place to deal with this particular message
// (OnMessage returns false), it's offered to the global state
// instead. The C++ version was marked const - same as Update, Go has
// no equivalent keyword and doesn't need one.
func (sm *StateMachine[T]) HandleMessage(msg Telegram) bool {
// first see if the current state is valid and that it can handle
// the message
if sm.currentState != nil && sm.currentState.OnMessage(sm.owner, msg) {
return true
}
// if not, and if a global state has been implemented, send
// the message to the global state
if sm.globalState != nil && sm.globalState.OnMessage(sm.owner, msg) {
return true
}
return false
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import "sync"
@@ -41,3 +41,11 @@ func (s *minerGlobalState) Execute(m *Miner) {
func (s *minerGlobalState) Exit(m *Miner) {
// TODO: fill in once the book shows this method's body.
}
// OnMessage is required now that State[T] includes it. This state
// doesn't handle any messages yet, so false ("not handled") is the
// correct default until the book shows otherwise.
func (s *minerGlobalState) OnMessage(m *Miner, msg Telegram) bool {
// TODO: fill in once the book shows this method's body.
return false
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import (
"fmt"
@@ -51,3 +51,10 @@ func (s *quenchThirst) Execute(m *Miner) {
func (s *quenchThirst) Exit(m *Miner) {
fmt.Printf("\n%s: Leavin' the saloon, feelin' good", GetNameOfEntity(m.ID()))
}
// OnMessage is required now that State[T] includes it. This state
// doesn't handle any messages yet - false ("not handled") is the
// correct default until the book shows otherwise.
func (s *quenchThirst) OnMessage(m *Miner, msg Telegram) bool {
return false
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import (
"fmt"
@@ -59,3 +59,10 @@ func (s *visitBankAndDepositGold) Execute(m *Miner) {
func (s *visitBankAndDepositGold) Exit(m *Miner) {
fmt.Printf("\n%s: Leavin' the bank", GetNameOfEntity(m.ID()))
}
// OnMessage is required now that State[T] includes it. This state
// doesn't handle any messages yet - false ("not handled") is the
// correct default until the book shows otherwise.
func (s *visitBankAndDepositGold) OnMessage(m *Miner, msg Telegram) bool {
return false
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import (
"fmt"
@@ -45,3 +45,10 @@ func (s *visitBathroom) Execute(e *Elsa) {
func (s *visitBathroom) Exit(e *Elsa) {
fmt.Printf("\n%s: Leavin' the john", GetNameOfEntity(e.ID()))
}
// OnMessage is required now that State[T] includes it. This state
// doesn't handle any messages yet - false ("not handled") is the
// correct default until the book shows otherwise.
func (s *visitBathroom) OnMessage(e *Elsa, msg Telegram) bool {
return false
}
@@ -1,4 +1,4 @@
package westworldwithwoman
package westworldwithmessaging
import (
"math/rand"
@@ -48,3 +48,14 @@ func (s *wifesGlobalState) Execute(e *Elsa) {
e.ChangeState(VisitBathroom())
}
}
// OnMessage is required now that State[T] includes it. This state
// doesn't handle any messages yet - false ("not handled") is the
// correct default until the book shows otherwise. (Note: our Elsa
// only has DoHousework and VisitBathroom, not a cooking state, so
// the book's stew-related messages between Miner and Elsa may not
// map cleanly onto this simplified version - worth revisiting if
// that comes up.)
func (s *wifesGlobalState) OnMessage(e *Elsa, msg Telegram) bool {
return false
}
@@ -0,0 +1,68 @@
package westworldwithmessaging
import "math"
// Telegram is the Go version of the C++ struct:
//
// struct Telegram
// {
// int Sender;
// int Receiver;
// int Msg;
// double DispatchTime;
// void* ExtraInfo;
// };
//
// It's one message passed between entities through the dispatcher /
// entity manager we'll build next: who sent it, who it's for, what
// it says, when it should actually go out, and any extra data riding
// along with it. The book's "/* CONSTRUCTORS OMITTED */" means we
// haven't been shown how one gets built yet, so this is just the
// plain data shape for now, no constructor.
type Telegram struct {
// Sender is the ID of the entity that sent this telegram.
Sender int
// Receiver is the ID of the entity meant to receive this telegram.
Receiver int
// Msg is the message itself. The book keeps this as a plain int
// rather than the MessageType enum, presumably so the dispatcher
// can stay generic and doesn't need to know about every possible
// message type - we're matching that choice exactly rather than
// tightening it to MessageType ourselves.
Msg int
// DispatchTime supports delayed messages: instead of going out
// immediately, a telegram can be stamped with the time it should
// actually be delivered. C++'s "double" becomes Go's float64 -
// Go has no bare "double" keyword, float64 is its 64-bit
// floating-point type.
DispatchTime float64
// ExtraInfo carries any additional data that should travel with
// the message. C++'s "void*" (a pointer with no fixed type -
// "could point to anything") becomes Go's "any" (an alias for
// interface{} - "could hold a value of any type"). Whoever reads
// ExtraInfo later type-asserts it back to something concrete.
ExtraInfo any
}
// smallestDelay is our stand-in for the book's "a quarter of a
// second" tolerance - it doesn't give us an exact named constant for
// this, just that written description.
const smallestDelay = 0.25
// telegramsMatch is our own Go version of the behavior the book
// describes for Telegram.h's overloaded == operator (we haven't been
// shown that file's actual code, only this description): two
// telegrams count as the same message if they're headed the same
// direction with the same content, and their dispatch times are less
// than smallestDelay apart. This is what keeps a burst of near-
// identical messages from flooding an entity's queue.
func telegramsMatch(a, b Telegram) bool {
return a.Sender == b.Sender &&
a.Receiver == b.Receiver &&
a.Msg == b.Msg &&
math.Abs(a.DispatchTime-b.DispatchTime) < smallestDelay
}