diff options
| -rw-r--r-- | building_guide/tick_order.md | 141 | ||||
| -rw-r--r-- | cmd/thoi/main.go | 29 | ||||
| -rw-r--r-- | internal/game/tick.go | 3 | ||||
| -rw-r--r-- | internal/game/tick_systems.go | 60 | ||||
| -rw-r--r-- | internal/game/tick_systems_test.go | 147 | ||||
| -rw-r--r-- | internal/net/server.go | 19 | ||||
| -rw-r--r-- | internal/world/mob.go | 18 |
7 files changed, 394 insertions, 23 deletions
diff --git a/building_guide/tick_order.md b/building_guide/tick_order.md new file mode 100644 index 0000000..2d22ac9 --- /dev/null +++ b/building_guide/tick_order.md @@ -0,0 +1,141 @@ +## Per-Tick System Order + +The server runs on a fixed game tick (600ms by default; configurable via +`tick_length` in `config.yaml`). Each tick, every online player's pending +commands resolve and a fixed sequence of systems advance world state. The +**canonical order** in which these per-tick systems fire is a load-bearing +contract: combat resolution, deplete/restock timers, regen, respawns, and +sequence-driven triggers all depend on it. Reordering it is a breaking change. + +### Source of truth + +The authoritative list lives in code, not in this doc: + +- `internal/game/tick_systems.go` — the `tickSystemOrder` slice and + `(*Game).RunTickSystems()`, the method that fires them in order. +- `cmd/thoi/main.go` — the master per-tick subscriber, registered with the + engine as **subscription ID 1**, whose body is `g.RunTickSystems()`. The + engine (`internal/engine/tick.go` `processTick`) sorts subscribers by + registration ID, so the master subscriber fires first each tick, before + any dynamically-registered combat / aggro / respawn callbacks (which get + higher IDs as players act). +- `internal/game/tick_systems_test.go` — `TestTickSystemOrder` fails if the + table drifts from the list below. `TestRunTickSystemsEmptyGameIsSafe` + guards the "safe to tick with no players connected" contract every system + must uphold (see "Per-system contracts" below). + +If you change the order, add a system, or remove one: update `tickSystemOrder` +in `tick_systems.go`, the `expectedTickSystemOrder` list in +`tick_systems_test.go`, and the table in this file. All three are intentional +duplicates so any one of them drifting fails the test loudly. + +### Canonical sequence + +| # | System | What it does | +| --- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 1 | `ProcessQueuedCommands` | Drains each player's free-queued commands and the globally timestamp-sorted active queue; advances walk sequences. | +| 2 | `MoveTick` | Counts down `p.MoveTicks`; on reach-zero, completes a pending run/walk and re-prompts. | +| 3 | `SequenceTick` | Advances every active trigger `sequence` (per-player and global) one step. | +| 4 | `TransientMobTick` | Despawns owned / `DespawnOnLeave` mobs whose owner has left their room. | +| 5 | `World.Tick` | World-internal: ground-item respawn/despawn/reserve timers and shared-object deplete timers. | +| 6 | `MobStore.Tick` | Mob shop restock and mob HP regenerate. Order: by `InstanceID` so processing is stable across ticks. | +| 7 | `RegenTick` | Player HP regeneration, accelerated by tech HP-regen multipliers. | +| 8 | `EnergyRegenTick` | Run-energy regeneration for every online player. | +| 9 | `DisconnectTick` | Counts down pending disconnects and hard-removes finished sessions (skipping players still in combat). | +| 10 | `WanderTick` | Mob wandering (exit-based) and object wandering (fishing spots), with enter/leave messages to observers. | +| 11 | `SharedDepletionTick` | Ticks shared-object deplete timers up (in use) or down (recovering). | +| 12 | `FireTick` | Decays fire objects; on burn-out, spawns the removal item and cancels stokers. | +| 13 | `AdvanceActions` | Advances every active player action by one tick; fires completion handlers per action type. | +| 14 | `TechTick` | Drains battery for active tech, applies equipment / PreserveDrain reductions, and deactivates all on empty. | +| 15 | `ConsumeTick` | Counts down `ConsumeCooldown` and `HomeTransportCooldown`. | +| 16 | `BroadcastRespawns` | Flushes respawned world objects and broadcasts their `respawn_broadcast` lines to players in those rooms. | +| 17 | `VisualTick` | Optional per-player "visual tick" line/counter for AFK feedback. | +| 18 | `SneakTick` | Advances guard watch timers and sneaking players' tick counters. | +| 19 | `FarmTick` | Crops grow, drain water, and yield on harvest (fires every `FarmTickInterval` ticks). | +| 20 | `BuffTick` | Potion buff duration countdown; expires and removes effects. | +| 21 | `SafespotTick` | Safespot unsafe-chance and decay-chance rolls per player. | +| 22 | `HazardTick` | Room hazard damage to non-GodMode players. | + +### Determinism contract + +Three layers of ordering decide what happens on a given tick, in priority: + +1. **Subscriber order** — deterministic. `engine.processTick` sorts + subscribers by registration ID. The master subscriber (ID 1, set in + `cmd/thoi/main.go`) always fires first; every system in + `RunTickSystems` then runs in the table order above. Dynamic combat / + aggro / respawn subscribers registered later get higher IDs and fire + after the canonical 22 systems. This is asserted by + `internal/engine/tick_test.go::TestProcessTickFiresInSubscriptionOrder`. + +2. **Per-system iteration order** — deterministic, as of the deterministic + per-tick subscriber work. Systems that iterate over players or mobs use + stable ordering: + - `Hub.AllSessions()` and `Hub.PlayersInRoom` (`internal/net/server.go`) + sort sessions by `Session.seq`, a monotonic counter assigned in + `Hub.Add` in connection order. + - `MobStore.AllInstances()`, `MobStore.MobsInRoom()`, and `MobStore.Tick()` + (`internal/world/mob.go`) sort by `MobInstance.InstanceID`. + + This means, for example, that cosmetic "X enters." / "Y leaves." + interleavings in `WanderTick`, shop-restock order in `MobStore.Tick`, + and per-player processing order in `RegenTick` / `HazardTick` / + `SneakTick` etc. are reproducible across runs. + +3. **Random-number generation** — **not deterministic across server + restarts.** The `math/rand` top-level functions used across the engine + (`rand.Float64`, `rand.Intn`, `rand.Int`, `rand.Shuffle`) are + auto-seeded at program startup (Go 1.20+; calling `rand.Seed` is + deprecated and is never done here). So combat hit rolls, drop rolls, + wandering exit picks, and minigame randomness differ between server + runs. Two ticks from the same seeded run are reproducible *relative* to + each other (the global source is deterministic), but a fresh server + start produces a different stream. + + Only `rollAggroRand` (`internal/game/sys_combat.go`) is replaceable for + tests; the rest are global-function calls. Reproducible full-tick + traces (e.g. for record/replay debugging) would require threading a + seeded `*math/rand.Rand`/`math/rand/v2.ChaCha8` through the engine; not + currently done. + +### Per-system contracts (the empty-game invariant) + +Every system in the table must be safe to invoke on a `*Game` that has its +stores initialized (`World`, `MobStore`, `Hub`, `queue`, etc. via +`game.New` + `SetHub`) but **no players connected**. Concretely: + +- Systems that touch the hub: guard with `if g.Hub == nil { return }` at the + top, *or* guard each `Hub.*` call with `if g.Hub != nil`. Either is fine. + (Note: `Ticks.Start` runs *before* `SetHub` in `main.go`, so the master + subscriber can fire with `Hub == nil` during startup — guards are not + optional.) +- Systems that iterate over maps/slices held on `*Game` (`sequences`, + `restTimers`, `guardWatchTimers`, `hackingStates`, …) must tolerate empty + / freshly-initialized maps. `game.New` initializes all of them; tests that + build a stub `*Game` must too (see `newRunTickSystemsTestGame`). + +Violating either of these is caught by +`TestRunTickSystemsEmptyGameIsSafe` in `internal/game/tick_systems_test.go`. +If you add a new system that needs a new field initialized, update that +helper as well. + +### Notes for builders + +When writing content that depends on per-tick ordering (a trigger that +should fire *after* regen, a sequence whose step messages should appear +*before* the move completes, etc.), consult the table above and remember: + +- Trigger `sequences` advance in step 3, **before** movement completes in + step 2 has any knock-on effects like enter-room triggers — wait, step 2 + (MoveTick) actually runs *before* step 3 (SequenceTick) because the table + is fired top-to-bottom. Double-check the exact step your event lives in. +- `on_enter` / `on_exit` room triggers fire from command execution, not + from a per-tick system; they run inside `ProcessQueuedCommands` (step 1) + or `MoveTick` (step 2) when the move actually completes. +- Shop restock (step 6) happens *before* `RegenTick` (step 7) and *before* + `AdvanceActions` (step 13); if a gathering action completes this tick + it does so in step 13, by which point shop state for this tick has + already advanced. + +When in doubt, the test in `tick_systems_test.go` is the executable +specification.
\ No newline at end of file diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go index 254db69..27b32f4 100644 --- a/cmd/thoi/main.go +++ b/cmd/thoi/main.go @@ -45,29 +45,14 @@ func main() { g.Ticks.Start(cfg.TickLength) defer g.Ticks.Stop() + // The master per-tick subscriber. Registered as engine subscription ID 1, + // the engine fires it before any dynamically-registered combat/aggro/respawn + // callbacks (engine.processTick sorts subscribers by registration ID). Its + // body — the canonical per-tick system sequence — is enforced and tested in + // internal/game/tick_systems.go and documented in + // building_guide/tick_order.md. Reordering it is a breaking change. g.Ticks.Subscribe(1, func() bool { - g.ProcessQueuedCommands() - g.MoveTick() - g.SequenceTick() - g.TransientMobTick() - g.World.Tick() - g.MobStore.Tick() - g.RegenTick() - g.EnergyRegenTick() - g.DisconnectTick() - g.WanderTick() - g.SharedDepletionTick() - g.FireTick() - g.AdvanceActions() - g.TechTick() - g.ConsumeTick() - g.BroadcastRespawns() - g.VisualTick() - g.SneakTick() - g.FarmTick() - g.BuffTick() - g.SafespotTick() - g.HazardTick() + g.RunTickSystems() return true }) diff --git a/internal/game/tick.go b/internal/game/tick.go index d765983..8910137 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -161,6 +161,9 @@ func (g *Game) WanderTick() { } func (g *Game) SharedDepletionTick() { + if g.Hub == nil { + return + } activeKeys := make(map[string]bool) for _, sess := range g.Hub.AllSessions() { p := sess.Player diff --git a/internal/game/tick_systems.go b/internal/game/tick_systems.go new file mode 100644 index 0000000..4d8d4b6 --- /dev/null +++ b/internal/game/tick_systems.go @@ -0,0 +1,60 @@ +package game + +// tickSystem is one step of the canonical per-tick system sequence. Name is +// the human-readable identifier also encoded in building_guide/tick_order.md; +// Run invokes the underlying system. The slice tickSystemOrder is the single +// executable source of truth for the order in which per-tick systems fire. +// +// The order is what callers like the master subscriber in cmd/thoi/main.go +// (registered as engine subscription ID 1, which the engine fires before all +// dynamically-registered combat/aggro/respawn callbacks per processTick's +// registration-ID sort) depend on. Reordering this slice is a breaking change +// to game behavior; keep building_guide/tick_order.md in sync. +type tickSystem struct { + Name string + Run func(*Game) +} + +// TickSystemOrder returns the canonical per-tick system sequence. It is a +// copy of the internal slice so callers cannot mutate the canonical order. +func TickSystemOrder() []struct{ Name string } { + out := make([]struct{ Name string }, len(tickSystemOrder)) + for i, s := range tickSystemOrder { + out[i] = struct{ Name string }{s.Name} + } + return out +} + +var tickSystemOrder = []tickSystem{ + {"ProcessQueuedCommands", (*Game).ProcessQueuedCommands}, + {"MoveTick", (*Game).MoveTick}, + {"SequenceTick", (*Game).SequenceTick}, + {"TransientMobTick", (*Game).TransientMobTick}, + {"World.Tick", func(g *Game) { g.World.Tick() }}, + {"MobStore.Tick", func(g *Game) { g.MobStore.Tick() }}, + {"RegenTick", (*Game).RegenTick}, + {"EnergyRegenTick", (*Game).EnergyRegenTick}, + {"DisconnectTick", (*Game).DisconnectTick}, + {"WanderTick", (*Game).WanderTick}, + {"SharedDepletionTick", (*Game).SharedDepletionTick}, + {"FireTick", (*Game).FireTick}, + {"AdvanceActions", (*Game).AdvanceActions}, + {"TechTick", (*Game).TechTick}, + {"ConsumeTick", (*Game).ConsumeTick}, + {"BroadcastRespawns", (*Game).BroadcastRespawns}, + {"VisualTick", (*Game).VisualTick}, + {"SneakTick", (*Game).SneakTick}, + {"FarmTick", (*Game).FarmTick}, + {"BuffTick", (*Game).BuffTick}, + {"SafespotTick", (*Game).SafespotTick}, + {"HazardTick", (*Game).HazardTick}, +} + +// RunTickSystems fires every canonical per-tick system in registration order. +// It is the body of the master per-tick subscriber in cmd/thoi/main.go and the +// single place that encodes per-tick system execution order. +func (g *Game) RunTickSystems() { + for _, s := range tickSystemOrder { + s.Run(g) + } +}
\ No newline at end of file diff --git a/internal/game/tick_systems_test.go b/internal/game/tick_systems_test.go new file mode 100644 index 0000000..6b44e91 --- /dev/null +++ b/internal/game/tick_systems_test.go @@ -0,0 +1,147 @@ +package game + +import ( + "reflect" + "runtime" + "strings" + "testing" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/game/hacking" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/world" +) + +// expectedTickSystemOrder is the canonical per-tick system sequence. It MUST +// stay in sync with tickSystemOrder in tick_systems.go and with +// building_guide/tick_order.md. Updating one without the others is a bug. +// +// The list is intentionally spelled out here (rather than derived from +// tickSystemOrder) so the test fails loudly when someone reorders, adds, or +// removes a system in tickSystemOrder without also updating the documented +// contract. +var expectedTickSystemOrder = []string{ + "ProcessQueuedCommands", + "MoveTick", + "SequenceTick", + "TransientMobTick", + "World.Tick", + "MobStore.Tick", + "RegenTick", + "EnergyRegenTick", + "DisconnectTick", + "WanderTick", + "SharedDepletionTick", + "FireTick", + "AdvanceActions", + "TechTick", + "ConsumeTick", + "BroadcastRespawns", + "VisualTick", + "SneakTick", + "FarmTick", + "BuffTick", + "SafespotTick", + "HazardTick", +} + +// TestTickSystemOrder asserts the canonical per-tick system sequence matches +// the documented order exactly. This is the regression guard referenced in +// building_guide/tick_order.md and in the master subscriber doc comment in +// cmd/thoi/main.go. +func TestTickSystemOrder(t *testing.T) { + got := TickSystemOrder() + if len(got) != len(expectedTickSystemOrder) { + t.Fatalf("tick system count changed: got %d, want %d (update expectedTickSystemOrder and building_guide/tick_order.md)", + len(got), len(expectedTickSystemOrder)) + } + for i, want := range expectedTickSystemOrder { + if got[i].Name != want { + t.Errorf("tick system %d: got %q, want %q (reordered? update building_guide/tick_order.md)", + i, got[i].Name, want) + } + } +} + +// TestTickSystemTableRunsValidMethods sanity-checks that every Run entry in +// tickSystemOrder is a non-nil callable that resolves to one of the *Game +// tick methods (or one of the two wrapped delegations World.Tick / +// MobStore.Tick). This guards against typos like swapping two method +// pointers or leaving a nil entry. It deliberately does not invoke them, +// since RunTickSystems itself is exercised by every game-package tick test. +func TestTickSystemTableRunsValidMethods(t *testing.T) { + if len(tickSystemOrder) == 0 { + t.Fatal("tickSystemOrder is empty — RunTickSystems would do nothing on each tick") + } + for i, s := range tickSystemOrder { + if s.Name == "" { + t.Errorf("tick system %d has empty Name", i) + } + if s.Run == nil { + t.Errorf("tick system %d (%q) has nil Run", i, s.Name) + } + if s.Name != "World.Tick" && s.Name != "MobStore.Tick" { + // Wrapped closures: too fragile to introspect by reflect name; skip. + pc := reflect.ValueOf(s.Run).Pointer() + fn := runtime.FuncForPC(pc) + if fn == nil { + t.Errorf("tick system %d (%q): Run did not resolve to a *Game method", i, s.Name) + continue + } + full := fn.Name() + // full is like "thehouseoficarus/internal/game.(*Game).MoveTick" + if !strings.Contains(full, "(*Game).") { + t.Errorf("tick system %d (%q): Run did not resolve to a *Game method (got %q)", + i, s.Name, full) + continue + } + base := full[strings.LastIndex(full, ".")+1:] + if base != s.Name { + t.Errorf("tick system %d: Name=%q but Run points at %q", i, s.Name, base) + } + } + } +} + +// TestRunTickSystemsEmptyGameIsSafe asserts RunTickSystems is safe to fire +// with a fully-initialized Game that has no players connected. This matches +// the production invariant: every per-tick *Game system early-returns when +// g.Hub == nil (or, after SetHub, iterates zero sessions), and the +// World/MobStore wrappers operate on their own stores. A future tick system +// that touches g.Hub without a nil guard, or a store we forget to +// initialize, would panic here and crash the live engine goroutine in +// production. This test is the regression guard for that contract. +func TestRunTickSystemsEmptyGameIsSafe(t *testing.T) { + defer func() { + if r := recover(); r != nil { + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + t.Fatalf("RunTickSystems panics on empty game: %v\n%s", r, string(buf[:n])) + } + }() + g := newRunTickSystemsTestGame() + g.RunTickSystems() +} + +// newRunTickSystemsTestGame builds a *Game with the stores RunTickSystems +// reaches (World, MobStore) initialized and a fresh Hub with no sessions, +// matching the production state right after game.New + SetHub but before any +// client connects. +func newRunTickSystemsTestGame() *Game { + return &Game{ + Deps: Deps{ + World: world.New(""), + MobStore: world.NewMobStore(""), + }, + Hub: net.NewHub(), + GlobalFlags: NewGlobalFlagStore(), + Combat: combat.NewTracker(), + flagIndex: newFlagTriggerIndex(), + queue: NewCommandQueue(), + safespot: NewSafespotManager(), + restTimers: map[string]uint64{}, + guardWatchTimers: map[string]int{}, + hackingStates: map[string]*hacking.Session{}, + sequences: map[string]*sequence{}, + } +}
\ No newline at end of file diff --git a/internal/net/server.go b/internal/net/server.go index 11339d7..9da90ea 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -11,7 +11,9 @@ import ( "math/big" "net" "net/http" + "sort" "sync" + "sync/atomic" "time" "thehouseoficarus/internal/color" @@ -70,8 +72,22 @@ type Session struct { PendingUndigRoom int promptVisible bool writeMu sync.Mutex + + // seq is a monotonically increasing sequence number assigned when the + // session is added to a Hub. Hub.AllSessions and Hub.PlayersInRoom sort + // sessions by seq so per-tick consumers iterate players in a stable, + // registration order instead of Go's randomized map iteration order. + // Sessions constructed without being added (e.g. in tests) keep seq=0 + // and tie-break by map-append order, which is also deterministic. + seq uint64 } +// sessionSeq is the source of Session.seq values. It is incremented under the +// adding Hub's mutex, so no extra atomicity is needed for correctness; atomic +// access keeps the package-level default counter safe if Hub.Add is ever +// called concurrently across hubs (it isn't today, but this is defensive). +var sessionSeq atomic.Uint64 + type Server struct { config *config.Config telnetLn net.Listener @@ -106,6 +122,7 @@ func (h *Hub) OnRemove(cb func(*Session)) { func (h *Hub) Add(s *Session) { h.mu.Lock() defer h.mu.Unlock() + s.seq = sessionSeq.Add(1) h.sessions[s] = true } @@ -165,6 +182,7 @@ func (h *Hub) AllSessions() []*Session { for s := range h.sessions { out = append(out, s) } + sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq }) return out } @@ -177,6 +195,7 @@ func (h *Hub) PlayersInRoom(roomID int) []*Session { out = append(out, s) } } + sort.SliceStable(out, func(i, j int) bool { return out[i].seq < out[j].seq }) return out } diff --git a/internal/world/mob.go b/internal/world/mob.go index fa0cdbf..f33b2f9 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -5,6 +5,7 @@ import ( "math/rand" "os" "path/filepath" + "sort" "strings" "sync" @@ -476,6 +477,7 @@ func (s *MobStore) AllInstances() []*MobInstance { for _, inst := range s.instances { out = append(out, inst) } + sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID }) return out } @@ -499,6 +501,20 @@ func (s *MobStore) MobsInRoom(roomID int) []*MobInstance { out = append(out, inst) } } + sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID }) + return out +} + +// sortedInstancesLocked returns every mob instance sorted by InstanceID. It +// must be called with s.mu held; callers iterate the returned slice instead of +// ranging s.instances directly so per-tick processing order is stable across +// Go's randomized map iteration. +func (s *MobStore) sortedInstancesLocked() []*MobInstance { + out := make([]*MobInstance, 0, len(s.instances)) + for _, inst := range s.instances { + out = append(out, inst) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID }) return out } @@ -506,7 +522,7 @@ func (s *MobStore) Tick() { s.mu.Lock() defer s.mu.Unlock() - for _, inst := range s.instances { + for _, inst := range s.sortedInstancesLocked() { if inst.Shop != nil { s.tickShopLocked(inst) } |
