diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/account_txn.go | 7 | ||||
| -rw-r--r-- | internal/game/act_talk.go | 4 | ||||
| -rw-r--r-- | internal/game/casino_house_edge.go | 16 | ||||
| -rw-r--r-- | internal/game/correct_audit_fixes_test.go | 94 | ||||
| -rw-r--r-- | internal/game/exit_migrate.go | 13 | ||||
| -rw-r--r-- | internal/game/harness_test.go | 33 |
6 files changed, 142 insertions, 25 deletions
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go index a1a5cf0..77a4219 100644 --- a/internal/game/account_txn.go +++ b/internal/game/account_txn.go @@ -482,8 +482,11 @@ func equalsFold(a, b string) bool { return true } -// recoverJournals rolls back leftover lifecycle journals at startup and -// surfaces unrecoverable ones. +// recoverJournals rolls back leftover lifecycle journals and surfaces +// unrecoverable ones. It scans every journal in the directory, so it must run +// only where no other transaction can be in flight: at startup, or on the game +// owner goroutine (which serializes every transaction). The mid-game caller in +// video_poker_banks.go is on the owner. func (g *Game) recoverJournals() { if err := persistence.Recover(g.journalDir()); err != nil { fmt.Fprintf(os.Stderr, "lifecycle journal recovery failed: %v\n", err) diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go index fa93435..8f47bf5 100644 --- a/internal/game/act_talk.go +++ b/internal/game/act_talk.go @@ -139,6 +139,10 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) { } } + // A terminal node (no options, no goto) ends the conversation; clear the + // action or it lingers as a stale talk action (status shows "talking", + // isBusy suppresses the prompt, and advanceTalk no-ops every tick). + g.cancelAction(p) sess.SetState(net.StateGame) g.writePrompt(sess) } diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go index de0036b..ff3ce59 100644 --- a/internal/game/casino_house_edge.go +++ b/internal/game/casino_house_edge.go @@ -158,19 +158,9 @@ func (g *Game) showCasinoListTable(sess *net.Session, betHeader string, rows []c } } budget := max(len(headers), width-3*len(headers)-1) - for { - sum, widest := 0, 0 - for i, w := range widths { - sum += w - if w > widths[widest] { - widest = i - } - } - if sum <= budget || widths[widest] <= 1 { - break - } - widths[widest]-- - } + // Size the pre-wrapped cells with the same shrink algorithm Table.Render + // uses, instead of a second copy that can disagree on which column yields. + ui.FitWidths(widths, budget) table := ui.Table{} // Headers wrap too, so narrow terminals never lose a column label. all := append([][]string{headers}, cells...) diff --git a/internal/game/correct_audit_fixes_test.go b/internal/game/correct_audit_fixes_test.go index c32b1e5..03facc0 100644 --- a/internal/game/correct_audit_fixes_test.go +++ b/internal/game/correct_audit_fixes_test.go @@ -166,6 +166,100 @@ func TestGatherYieldsDrop(t *testing.T) { }) } +// End-to-end burn: the missing coverage the audit called out. Start a fire +// with a tinderbox and tick until it catches. +func TestBurnCreatesFire(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + writeSeedFile(t, filepath.Join(h.dir, "items", "logs.yaml"), + []byte("id: logs\nname: logs\nburn_ticks: 100\nfire_level: 1\nfire_xp: 10\n")) + writeSeedFile(t, filepath.Join(h.dir, "items", "tinderbox.yaml"), + []byte("id: tinderbox\nname: tinderbox\ntool:\n type: fire\n speed: 1\n")) + sess := &net.Session{Conn: newChanConn(), Player: p} + sess.SetState(net.StateGame) + h.g.RunOnOwner(func() { + h.g.ItemStore.Reload(h.dir) + p.SetInvSlot(0, &player.InventorySlot{ItemID: "logs", Quantity: 5}) + p.SetInvSlot(1, &player.InventorySlot{ItemID: "tinderbox", Quantity: 1}) + h.g.startBurn(sess, p, "logs", false) + if p.Action == nil || p.Action.Type != behavior.TypeBurn { + t.Fatalf("burn action did not start: %s", sess.Conn.(*chanConn).Bytes()) + } + // Each attempt is two calls (phase 1 then the roll); 200 calls is ~100 + // attempts at >=5% success, so failure is effectively impossible. + for i := 0; i < 200 && p.Action != nil; i++ { + h.g.advanceBurn(sess, p) + } + if p.Action != nil { + t.Fatal("burn never completed") + } + if !h.g.hasFireObject(1) { + t.Fatalf("no fire created (output: %s)", sess.Conn.(*chanConn).Bytes()) + } + }) +} + +// Regression: a terminal talk node (messages, no options, no goto) must clear +// the talk action, or it lingers as a stale action that suppresses the prompt +// and no-ops every tick. +func TestTerminalTalkNodeClearsAction(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + writeSeedFile(t, filepath.Join(h.dir, "objects", "statue.yaml"), + []byte("id: statue\nname: statue\ntalk:\n nodes:\n start:\n messages: [The statue says nothing more.]\n")) + sess := &net.Session{Conn: newChanConn(), Player: p} + sess.SetState(net.StateGame) + h.g.RunOnOwner(func() { + h.g.ObjectStore.Reload(h.dir) + obj, err := h.g.ObjectStore.Load("statue") + if err != nil { + t.Fatal(err) + } + h.g.startTalk(sess, p, obj) + if p.Action != nil { + t.Fatalf("terminal talk node left a stale action: %+v", p.Action) + } + if sess.State() != net.StateGame { + t.Fatalf("state = %v, want StateGame", sess.State()) + } + }) +} + +// End-to-end object steal: the missing coverage the audit called out. +func TestObjectStealYieldsLoot(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + writeSeedFile(t, filepath.Join(h.dir, "objects", "market_stall.yaml"), + []byte("id: market_stall\nname: market stall\nsteal:\n level: 1\n xp: 10\n speed: 1\n drops:\n - item_id: stolen_coins\n weight: 100\n")) + writeSeedFile(t, filepath.Join(h.dir, "items", "stolen_coins.yaml"), + []byte("id: stolen_coins\nname: stolen coins\nstackable: true\n")) + sess := &net.Session{Conn: newChanConn(), Player: p} + sess.SetState(net.StateGame) + h.g.RunOnOwner(func() { + h.g.ItemStore.Reload(h.dir) + h.g.ObjectStore.Reload(h.dir) + h.g.World.EnsureObjectStates(1, []string{"market_stall"}) + obj, err := h.g.ObjectStore.Load("market_stall") + if err != nil { + t.Fatal(err) + } + h.g.startSteal(sess, p, "object", nil, obj) + if p.Action == nil || p.Action.Type != behavior.TypeSteal { + t.Fatalf("steal action did not start: %s", sess.Conn.(*chanConn).Bytes()) + } + // ~50% success per attempt; 200 attempts makes failure negligible. + for i := 0; i < 200 && p.CountItem("stolen_coins") == 0; i++ { + h.g.advanceSteal(sess, p) + } + if got := p.CountItem("stolen_coins"); got == 0 { + t.Fatalf("steal yielded no loot (output: %s)", sess.Conn.(*chanConn).Bytes()) + } + }) +} + func TestPlayerDataFileNameCanonicalizesPrefixes(t *testing.T) { for _, in := range []string{ "players/characters/alice.yaml", diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index f6df05d..4edab95 100644 --- a/internal/game/exit_migrate.go +++ b/internal/game/exit_migrate.go @@ -6,6 +6,7 @@ import ( "strings" "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" ) // rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per @@ -21,10 +22,10 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool { return false } changed := false - if remapScalar(idMap, &p.RoomID) { + if world.RemapInt(idMap, &p.RoomID) { changed = true } - if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && remapScalar(idMap, &p.PendingSequence.RoomID) { + if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && world.RemapInt(idMap, &p.PendingSequence.RoomID) { changed = true } if len(p.Flags) > 0 && rewriteDiscoveredExitFlags(p.Flags, idMap) { @@ -41,14 +42,6 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool { return changed } -func remapScalar(idMap map[int]int, n *int) bool { - if newID, ok := idMap[*n]; ok && newID != *n { - *n = newID - return true - } - return false -} - // rewriteDiscoveredExitFlags rewrites hidden_exit_<roomID>_<dir> flag keys, // remapping the embedded roomID per idMap. Flag values are preserved. func rewriteDiscoveredExitFlags(flags map[string]any, idMap map[int]int) bool { diff --git a/internal/game/harness_test.go b/internal/game/harness_test.go index 3bdce0f..c5377b4 100644 --- a/internal/game/harness_test.go +++ b/internal/game/harness_test.go @@ -131,6 +131,39 @@ func newHarness(t *testing.T) *harness { return h } +// newHarnessWithTicks is newHarness with the production master tick +// subscription registered, so tick-driven systems (disconnect countdown, +// action advance, casino tick) run exactly as they do in production. Most +// harness tests drive the systems directly and do not need this. +func newHarnessWithTicks(t *testing.T) *harness { + t.Helper() + h := newHarness(t) + h.g.SubscribeTicks(1, func() bool { + h.g.RunTickSystems() + return true + }) + return h +} + +// Regression: the harness must be able to run the real master tick sequence, +// which is the only way the disconnect countdown (Hub.Remove -> DisconnectTick +// -> onRemove) is exercised end-to-end. +func TestHarnessWithTicksCompletesDisconnect(t *testing.T) { + h := newHarnessWithTicks(t) + h.seedAccount("alice") + h.seedCharacter("alice", "alice") + sess, _ := h.connect("alice") + h.hub.Remove(sess) + h.barrier() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && len(h.hub.AllSessions()) > 0 { + time.Sleep(5 * time.Millisecond) + } + if got := len(h.hub.AllSessions()); got != 0 { + t.Fatalf("session not hard-removed after the countdown: %d", got) + } +} + // seedWorld writes the minimal on-disk content a Game needs: three rooms. func seedWorld(t *testing.T, dir string) { t.Helper() |
