From c054b27f628f23fecc505b9e09e78721738f3c4e Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 16 Sep 2026 13:52:58 -0400 Subject: fix: shutdown admission, recovery identity/order, rollback journal retention, pickup merge --- internal/game/hacking/liars_dice.go | 10 +++-- internal/game/hacking/liars_dice_test.go | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 internal/game/hacking/liars_dice_test.go (limited to 'internal/game/hacking') diff --git a/internal/game/hacking/liars_dice.go b/internal/game/hacking/liars_dice.go index 1dd27b6..8e21b94 100644 --- a/internal/game/hacking/liars_dice.go +++ b/internal/game/hacking/liars_dice.go @@ -175,7 +175,7 @@ func (l *LiarsDiceGame) doCall(player bool) (string, bool, bool) { } } - return l.checkGameOver(sb) + return l.checkGameOver(&sb) } func (l *LiarsDiceGame) doExact() (string, bool, bool) { @@ -202,10 +202,14 @@ func (l *LiarsDiceGame) doExact() (string, bool, bool) { l.playerLost++ } - return l.checkGameOver(sb) + return l.checkGameOver(&sb) } -func (l *LiarsDiceGame) checkGameOver(sb strings.Builder) (string, bool, bool) { +// checkGameOver resolves a completed challenge/exact exchange. The builder is +// passed BY POINTER: it already holds the resolution text, and copying a +// non-zero strings.Builder by value panics — a panic here runs on the game +// owner goroutine, which has no recovery, so it would terminate the server. +func (l *LiarsDiceGame) checkGameOver(sb *strings.Builder) (string, bool, bool) { if len(l.playerDice) == 0 { sb.WriteString("\nYou've lost all your fragments. Connection terminated.") l.gameOver = true diff --git a/internal/game/hacking/liars_dice_test.go b/internal/game/hacking/liars_dice_test.go new file mode 100644 index 0000000..59ebb92 --- /dev/null +++ b/internal/game/hacking/liars_dice_test.go @@ -0,0 +1,68 @@ +package hacking + +import ( + "strings" + "testing" +) + +// Regression: round resolution (challenge and exact calls) passed an +// already-used strings.Builder BY VALUE into checkGameOver, panicking with +// "illegal use of non-zero Builder copied by value" on ordinary input — a +// forced challenge resolves on the very first bid. A panic here runs on the +// game owner goroutine, which has no recovery, so it terminated the server. +// See building_guide/concurrency.md for the owner's no-recovery guarantee. + +func TestLiarsDiceForcedChallengeDoesNotPanic(t *testing.T) { + g := NewLiarsDiceGame() + g.Init(1) + // An impossible bid deterministically forces an immediate AI challenge. + out, _, _ := g.HandleInput("bid 100 6") + if !strings.Contains(out, "Actual count") { + t.Fatalf("challenge output missing the reveal: %q", out) + } + if !strings.Contains(out, "You lose a fragment") && !strings.Contains(out, "System loses a fragment") { + t.Fatalf("challenge did not resolve a fragment loss: %q", out) + } +} + +func TestLiarsDiceCallAndExactResolveWithoutPanic(t *testing.T) { + // Call path: bid, let the system raise, then challenge it. + g := NewLiarsDiceGame() + g.Init(1) + if out, _, _ := g.HandleInput("bid 2 3"); out == "" { + t.Fatal("opening bid produced no output") + } + if out, _, _ := g.HandleInput("call"); out == "" { + t.Fatal("call produced no output") + } + + // Exact path: bid, let the system raise, then claim exact. + g = NewLiarsDiceGame() + g.Init(1) + if out, _, _ := g.HandleInput("bid 2 3"); out == "" { + t.Fatal("opening bid produced no output") + } + if out, _, _ := g.HandleInput("exact"); out == "" { + t.Fatal("exact produced no output") + } +} + +func TestLiarsDiceFullLossGameResolvesWithoutPanic(t *testing.T) { + // Bid high enough that the system always calls and the player always + // loses a fragment, driving the game to its terminal resolution. + g := NewLiarsDiceGame() + g.Init(1) + for i := 0; i < 10; i++ { + out, done, won := g.HandleInput("bid 100 6") + if done { + if won { + t.Fatal("all-in losing bids cannot win") + } + return + } + if out == "" { + t.Fatal("round produced no output") + } + } + t.Fatal("game did not terminate within 10 forced-loss rounds") +} -- cgit v1.2.3