aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hacking
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/hacking')
-rw-r--r--internal/game/hacking/liars_dice.go10
-rw-r--r--internal/game/hacking/liars_dice_test.go68
2 files changed, 75 insertions, 3 deletions
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")
+}