From c61e2827c50af63beb9f4f96b2f24e18fda523fd Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 9 Aug 2026 14:53:46 +0000 Subject: [PATCH] Cover game/rings.go with C-verified unit tests (closes #5) game/rings.go had no test coverage at all: not one of the suite's tests touched wearing a ring, taking one off, choosing a hand, or the ring contribution to the hunger clock. New game/rings_test.go covers ringOn, pickRingHand, ringOff, gethand, ringEat and ringNum, plus the ring arm of things.c dropcheck (dropRing), which is what actually removes a worn ring. 17 tests, 44 subtests; package coverage 53.7% -> 56.2%. No game code changes. Every expected value is transcribed from the C reference on origin/c-master (rings.c, rogue.h, things.c) and quoted in the file, rather than from what the port currently returns. No divergence from C was found. ringEat is the reason this matters most: it feeds daemons.c's hunger clock, so a wrong entry is a slow, silent drift in when the hero starves. All fourteen ring kinds are pinned to C's uses[] table, both hands. The three C subtleties are handled explicitly: a negative uses[] entry is a one-in-n chance of a single unit and not a literal cost; R_DIGEST then flips the sign, so slow digestion returns 0 or -1; and ring_num's switch closes with the otherwise macro (rogue.h 53: break;default), so its four labels fall through to one sprintf and every other kind returns "" from a default arm. The chance rings are checked by snapshotting the generator, calling ringEat, and replaying C's own expression from the identical state, which pins the one-in-n denominator, the sign flip and the fact that exactly one rnd call is spent; a frequency check over 4000 trials backs it. The non-negative entries assert the opposite, that the generator is untouched, because C never reaches rnd on that path and a stray call there would desynchronise the game's RNG stream from C's. Scripted hand answers carry an abort tail (a space for the reprompt's --More--, then ESCAPE) so that a port which stopped accepting a key fails on its assertion instead of looping forever on the headless terminal's filler input. The "only one hand free" cases script the wrong hand key on purpose: a port that prompted anyway would consume it and land the ring on the wrong side. Mutation-proved with 23 mutations, each reverted, each failing its own test and only its own. All fourteen kinds are exercised; the eleven with no wear-time effect in C are documented at the foot of the file as deliberately not given a wear/remove test, and ring_off's unreachable "not wearing such a ring" arm is documented as unreachable. --- TODO.md | 71 ++++- game/rings_test.go | 751 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 820 insertions(+), 2 deletions(-) create mode 100644 game/rings_test.go diff --git a/TODO.md b/TODO.md index e4dd3f7..1ac4d16 100644 --- a/TODO.md +++ b/TODO.md @@ -29,11 +29,78 @@ Refactor ground rules: # Next Step -Broaden unit test coverage where playtesting finds thin spots (rings, sticks, -wizard commands). +Broaden unit test coverage where playtesting finds thin spots — sticks (#6) and +wizard commands (#7). Rings, the first third of this step, is done; see the top +of Completed Steps. # Completed Steps +- 2026-08-09 Ring unit-test coverage (`test/rings-coverage`, closes #5): the + first third of the standing coverage step. `game/rings.go` had **zero** tests + — not one of the 32 in the suite touched wear, removal, hand choice, or the + ring contribution to the hunger clock. New `game/rings_test.go` (17 tests, 44 + subtests) covers `ringOn`, `pickRingHand`, `ringOff`, `gethand`, `ringEat` and + `ringNum`, plus the ring arm of `things.c dropcheck` (`dropRing`), which is + what actually takes a ring off. Package coverage 53.7% -> 56.2%. `Next Step` + narrowed rather than rotated: #6 and #7 are the other two thirds. + + Every expected value is transcribed from `origin/c-master` (`rings.c`, + `rogue.h`, `things.c`), never from what the port returns, and the C is + quoted in the file. **No divergence from C was found**, which is the result + and is worth recording as a negative: `ringEat` is the one function here + whose being wrong would be invisible — it feeds `daemons.c`'s hunger clock, + so a bad entry is a slow drift in when the hero starves rather than anything + a playtest would notice — and it now has all fourteen ring kinds pinned to + C's table. + + Three C details the tests were written around. (1) `ring_eat`'s `uses[]` + holds negatives, and a negative is **not** a cost: C computes + `eat = (rnd(-eat) == 0)`, a one-in-n chance of a single unit. (2) `R_DIGEST` + then flips the sign, so slow digestion returns 0 or **-1** and is the only + ring that gives food back. (3) `ring_num`'s switch closes with the + `otherwise` macro, which `rogue.h` 53 defines as `break;default` — so its + four labels fall through to one `sprintf` and every other kind returns `""` + from a default arm, not by falling off the end. The `RingKind` iota matches + C's `R_` numbering index-for-index, so a `uses[]` index and a `RingKind` are + the same number; `R_ADDHIT` is `RingDexterity` and `R_ADDDAM` is + `RingIncreaseDamage`. + + The chance rings are checked two ways at once. Each call snapshots the + generator, runs `ringEat`, and replays C's own expression from the identical + state — which pins the one-in-n denominator, the sign flip, and the fact + that exactly one `rnd` call is spent — and a frequency check over 4000 + trials backs it with a number a human can read. The non-negative entries + assert the reverse: the generator must be **untouched**, because C never + reaches `rnd` on that path and a stray call there would desynchronise the + whole game's RNG stream from C's and cost seed compatibility. That assertion + is what caught the one real bug in this work, which was in the test and not + the game: `g.Rng` is a pointer, so the first draft's snapshots aliased + instead of copying. + + Two shapes worth keeping. Scripted hand answers carry an abort tail (a space + for the reprompt's `--More--`, then ESCAPE): without it a port that stopped + accepting a key would loop forever on the headless terminal's filler input + and the test would die of the 30s timeout instead of failing on its + assertion — which is exactly what the first draft did, and it was only + visible because the mutation run was inspected rather than trusted. And the + "only one hand free" case scripts the _wrong_ hand key deliberately: a port + that asked anyway consumes it and lands the ring on the wrong side, so the + test fails on a hand rather than on a hang. + + Mutation-proved, 23 mutations, each reverted: breaking `pickRingHand`'s + ask/auto/reject arms, `ring_on`'s type guard, `is_current` guard and all + three effect arms, `ring_off`'s no-rings message, hand selection and ESCAPE + abort, `gethand`'s uppercase keys, ESCAPE and reprompt, `dropRing`'s hand + clearing and both effect arms, `dropcheck`'s cursed gate, three `ringUses` + entries, the `R_DIGEST` sign flip, the one-in-n roll, the empty-hand zero, + and `ring_num`'s `ISKNOW` guard, label set and `RING`-vs-`WEAPON` + formatting. Each failed its own test and only its own; stripping all three + `ring_on` effect arms failed 3 of 3. All fourteen ring kinds are exercised; + the eleven with no wear-time effect in C are documented at the foot of the + file as deliberately not given a wear/remove test, with the files their + powers actually live in, and `ring_off`'s unreachable "not wearing such a + ring" arm is documented as unreachable rather than left looking untested. + - 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes #31): checked every case label in C's `command.c` against this port's dispatch, and left the audit behind as a standing test diff --git a/game/rings_test.go b/game/rings_test.go new file mode 100644 index 0000000..3fa8a19 --- /dev/null +++ b/game/rings_test.go @@ -0,0 +1,751 @@ +//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07) +package game + +import ( + "fmt" + "math" + "testing" +) + +// rings_test.go covers rings.c — ring_on, gethand, ring_off, ring_eat and +// ring_num — plus the ring arm of things.c dropcheck (dropRing), which is +// what actually takes a worn ring off. +// +// Every expected value below is transcribed from the C reference on the +// origin/c-master branch (rings.c, rogue.h, things.c), not from what this +// port happens to return. A test that asserts the current Go behaviour +// cannot catch the port drifting away from C, which is the only thing +// these tests exist to do. +// +// The C constants in play (rogue.h 122-123 and 275-289): +// +// #define LEFT 0 #define RIGHT 1 +// R_PROTECT 0 R_ADDSTR 1 R_SUSTSTR 2 R_SEARCH 3 +// R_SEEINVIS 4 R_NOP 5 R_AGGR 6 R_ADDHIT 7 +// R_ADDDAM 8 R_REGEN 9 R_DIGEST 10 R_TELEPORT 11 +// R_STEALTH 12 R_SUSTARM 13 MAXRINGS 14 +// +// The Go RingKind iota (types.go 303-316) runs in that same order, so a C +// uses[] index and a Go RingKind are the same number. R_ADDHIT is the +// dexterity ring (RingDexterity) and R_ADDDAM is RingIncreaseDamage. +// +// Nothing here can kill the hero — no ring path in rings.c touches HP, +// food, or experience — so these tests need no fortify() pinning. + +// C message text, verbatim, in the terse/verbose pairs C picks between. +const ( + cWearingTwo = "you already have a ring on each hand" + cWearingTwoTerse = "wearing two" + cNotARing = "it would be difficult to wrap that around a finger" + cNotARingTerse = "not a ring" + cNoRings = "you aren't wearing any rings" + cNoRingsTerse = "no rings" + cInUse = "That's already in use" + cCursed = "you can't. It appears to be cursed" +) + +// ringSeed is the fixed seed every test here runs on: nothing in rings.c +// depends on the layout, but the RNG stream must be reproducible for the +// ring_eat chance rolls. +const ringSeed = 5 + +// mkRingGame builds a headless game with a clear message line, so that a +// leftover mpos cannot turn the next msg() into a --More-- that eats the +// scripted keystrokes. +func mkRingGame(t *testing.T) *RogueGame { + t.Helper() + + g := mkGame(t, ringSeed) + g.Msgs.Mpos = 0 + g.Msgs.Huh = "" + + return g +} + +// handKeys scripts an answer to gethand and appends an abort tail. Without +// it, a port that stopped accepting the key under test would reprompt +// forever against the headless terminal's filler input, and the test would +// die of the 30s timeout instead of failing on its own assertion. The +// space acknowledges the reprompt's --More-- and the ESCAPE makes gethand +// give up, so the assertion gets to run and say what actually went wrong. +// For the same reason, a call that must not prompt at all is scripted with +// a lone ESCAPE rather than an empty script. +func handKeys(keys ...byte) []byte { + return append(keys, ' ', Escape) +} + +// mkRing builds a ring of the given kind; bonus is C's o_arm. +func mkRing(kind RingKind, bonus int) *Object { + obj := newObject() + obj.Kind = KindRing + obj.Which = int(kind) + obj.Bonus = bonus + obj.Count = 1 + + return obj +} + +// wear puts a ring straight onto a hand the way a restored save would, +// bypassing ring_on's prompting and effects. +func wear(g *RogueGame, hand int, obj *Object) *Object { + give(g, obj) + g.Player.CurRing[hand] = obj + + return obj +} + +func handDesc(obj *Object) string { + if obj == nil { + return "empty" + } + + return fmt.Sprintf("ring kind %d", obj.RingKind()) +} + +// assertHands pins both hands at once, which is what "no state change" +// means for every rejection path in ring_on. +func assertHands(t *testing.T, g *RogueGame, left, right *Object) { + t.Helper() + + if g.Player.CurRing[Left] != left { + t.Errorf("left hand = %s, want %s", + handDesc(g.Player.CurRing[Left]), handDesc(left)) + } + + if g.Player.CurRing[Right] != right { + t.Errorf("right hand = %s, want %s", + handDesc(g.Player.CurRing[Right]), handDesc(right)) + } +} + +// TestRingOnUsesTheHandTheHeroPicks covers the first arm of C's ring_on +// hand choice: "if (cur_ring[LEFT] == NULL && cur_ring[RIGHT] == NULL) +// { if ((ring = gethand()) < 0) return; }". +func TestRingOnUsesTheHandTheHeroPicks(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + key byte + hand int + }{ + {"lower l", 'l', Left}, + {"upper L", 'L', Left}, + {"lower r", 'r', Right}, + {"upper R", 'R', Right}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + ring := mkRing(RingAdornment, 0) + ch := give(g, ring) + setInput(t, g, handKeys(ch, tc.key)...) + + g.ringOn() + + if tc.hand == Left { + assertHands(t, g, ring, nil) + } else { + assertHands(t, g, nil, ring) + } + }) + } +} + +// TestRingOnEscapeFromGethandWearsNothing is the "< 0" half of that arm. +func TestRingOnEscapeFromGethandWearsNothing(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + ring := mkRing(RingAdornment, 0) + ch := give(g, ring) + setInput(t, g, ch, Escape) + + g.ringOn() + assertHands(t, g, nil, nil) +} + +// TestRingOnTakesTheOnlyFreeHandWithoutAsking covers C's second and third +// arms — "else if (cur_ring[LEFT] == NULL) ring = LEFT" and the RIGHT +// mirror — which must not prompt. The scripted hand key is deliberately +// the wrong hand: a port that asked anyway would consume it and put the +// ring on the occupied side's opposite, failing here instead of hanging. +func TestRingOnTakesTheOnlyFreeHandWithoutAsking(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + worn int + free int + badKey byte + }{ + {"left already worn", Left, Right, 'l'}, + {"right already worn", Right, Left, 'r'}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + old := wear(g, tc.worn, mkRing(RingStealth, 0)) + ring := mkRing(RingAdornment, 0) + ch := give(g, ring) + setInput(t, g, handKeys(ch, tc.badKey)...) + + g.ringOn() + + if g.Player.CurRing[tc.free] != ring { + t.Errorf("free hand = %s, want the new ring", + handDesc(g.Player.CurRing[tc.free])) + } + + if g.Player.CurRing[tc.worn] != old { + t.Error("ring_on disturbed the hand that was already worn") + } + }) + } +} + +// TestRingOnWithBothHandsFullIsRejected covers C's final else arm. The +// trailing ESCAPE is scripted so that a port which wrongly fell through +// to gethand() aborts instead of looping on the exhausted script. +func TestRingOnWithBothHandsFullIsRejected(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + terse bool + want string + }{ + {"wearing two, verbose", false, cWearingTwo}, + {"wearing two, terse", true, cWearingTwoTerse}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + g.Options.Terse = tc.terse + left := wear(g, Left, mkRing(RingStealth, 0)) + right := wear(g, Right, mkRing(RingRegeneration, 0)) + ch := give(g, mkRing(RingAdornment, 0)) + setInput(t, g, ch, Escape) + + g.ringOn() + + if g.Msgs.Huh != tc.want { + t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want) + } + + assertHands(t, g, left, right) + }) + } +} + +// TestRingOnRejectsANonRing covers C's "if (obj->o_type != RING)" guard. +func TestRingOnRejectsANonRing(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + terse bool + want string + }{ + {"not a ring, verbose", false, cNotARing}, + {"not a ring, terse", true, cNotARingTerse}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + g.Options.Terse = tc.terse + pot := newObject() + pot.Kind = KindPotion + pot.Which = int(PotionHealing) + ch := give(g, pot) + setInput(t, g, ch, Escape) + + g.ringOn() + + if g.Msgs.Huh != tc.want { + t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want) + } + + assertHands(t, g, nil, nil) + }) + } +} + +// TestRingOnRejectsARingAlreadyWorn covers C's "if (is_current(obj)) +// return", which sits between the type check and the hand choice. +func TestRingOnRejectsARingAlreadyWorn(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + worn := wear(g, Left, mkRing(RingStealth, 0)) + setInput(t, g, worn.PackCh, Escape) + + g.ringOn() + + if g.Msgs.Huh != cInUse { + t.Errorf("message = %q, want %q", g.Msgs.Huh, cInUse) + } + + assertHands(t, g, worn, nil) +} + +// TestRingOnAddStrengthAndRingOffReverseEachOther pins the R_ADDSTR arm +// of ring_on ("case R_ADDSTR: chg_str(obj->o_arm)") against the R_ADDSTR +// arm of things.c dropcheck ("chg_str(-obj->o_arm)"). +func TestRingOnAddStrengthAndRingOffReverseEachOther(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + ring := mkRing(RingAddStrength, 2) + ch := give(g, ring) + base := g.Player.Stats.Str + + setInput(t, g, handKeys(ch, 'l')...) + g.ringOn() + + if g.Player.Stats.Str != base+2 { + t.Errorf("strength after wearing = %d, want %d", + g.Player.Stats.Str, base+2) + } + + assertHands(t, g, ring, nil) + + // Only the left hand is worn, so ring_off's "else if (cur_ring[RIGHT] + // == NULL) ring = LEFT" arm picks it with no prompt. + setInput(t, g, Escape) + g.ringOff() + + if g.Player.Stats.Str != base { + t.Errorf("strength after removal = %d, want %d", + g.Player.Stats.Str, base) + } + + assertHands(t, g, nil, nil) +} + +// TestRingOnSeeInvisibleAndRingOffUndoIt pins the R_SEEINVIS arms: +// invis_on() on the way in, unsee() plus extinguish(unsee) on the way +// out. The pending fuse stands in for a potion of see invisible still +// running, which is the only way the extinguish is observable. +func TestRingOnSeeInvisibleAndRingOffUndoIt(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + ring := mkRing(RingSeeInvisible, 0) + ch := give(g, ring) + + setInput(t, g, handKeys(ch, 'r')...) + g.ringOn() + + if !g.Player.On(CanSeeInvisible) { + t.Error("ring of see invisible did not set CanSeeInvisible") + } + + g.Fuse(DUnsee, 0, 100, After) + + setInput(t, g, Escape) + g.ringOff() + + if g.Player.On(CanSeeInvisible) { + t.Error("taking the ring off left CanSeeInvisible set") + } + + if g.findSlot(DUnsee) != nil { + t.Error("taking the ring off did not extinguish the unsee fuse") + } +} + +// TestRingOnAggravateMonstersWakesThem pins the R_AGGR arm, which calls +// aggravate() — misc.c walks every monster through runTo, setting ISRUN. +func TestRingOnAggravateMonstersWakesThem(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + tp := spawnAdjacent(g, 'Z') + tp.Flags.Clear(Awake) + + ring := mkRing(RingAggravateMonsters, 0) + ch := give(g, ring) + + setInput(t, g, handKeys(ch, 'l')...) + g.ringOn() + + if !tp.On(Awake) { + t.Error("ring of aggravate monsters did not wake the monster") + } +} + +// TestGethand covers rings.c gethand end to end. The bad-key case needs +// the extra space: the reprompt happens with mpos still set from "please +// type L or R", so endmsg puts up a --More-- that wait_for absorbs. +func TestGethand(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input []byte + want int + }{ + {"l", []byte{'l'}, Left}, + {"L", []byte{'L'}, Left}, + {"r", []byte{'r'}, Right}, + {"R", []byte{'R'}, Right}, + {"escape aborts", []byte{Escape}, -1}, + {"bad key reprompts", []byte{'x', ' ', 'r'}, Right}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + setInput(t, g, handKeys(tc.input...)...) + + if got := g.gethand(); got != tc.want { + t.Errorf("gethand() = %d, want %d", got, tc.want) + } + }) + } +} + +// TestRingOffWithNoRingsSaysSo covers ring_off's first arm. +func TestRingOffWithNoRingsSaysSo(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + terse bool + want string + }{ + {"no rings, verbose", false, cNoRings}, + {"no rings, terse", true, cNoRingsTerse}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + g.Options.Terse = tc.terse + setInput(t, g, Escape) + + g.ringOff() + + if g.Msgs.Huh != tc.want { + t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want) + } + }) + } +} + +// TestRingOffWithBothHandsWornAsksWhich covers ring_off's else arm, both +// the answer and the "(ring = gethand()) < 0" abort. +func TestRingOffWithBothHandsWornAsksWhich(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + key byte + gone int + stays int + }{ + {"takes off the left", 'l', Left, Right}, + {"takes off the right", 'r', Right, Left}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + rings := [2]*Object{ + Left: wear(g, Left, mkRing(RingStealth, 0)), + Right: wear(g, Right, mkRing(RingRegeneration, 0)), + } + setInput(t, g, handKeys(tc.key)...) + + g.ringOff() + + if g.Player.CurRing[tc.gone] != nil { + t.Errorf("chosen hand still holds %s", + handDesc(g.Player.CurRing[tc.gone])) + } + + if g.Player.CurRing[tc.stays] != rings[tc.stays] { + t.Error("ring_off cleared the hand that was not chosen") + } + }) + } +} + +// TestRingOffEscapeKeepsBothRings is the abort half of that arm. +func TestRingOffEscapeKeepsBothRings(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + left := wear(g, Left, mkRing(RingStealth, 0)) + right := wear(g, Right, mkRing(RingRegeneration, 0)) + setInput(t, g, Escape) + + g.ringOff() + assertHands(t, g, left, right) +} + +// TestRingOffCursedRingStaysOn covers the dropcheck gate ring_off runs +// its removal through: things.c returns FALSE for an ISCURSED item after +// printing this message, and the hand is left alone. +func TestRingOffCursedRingStaysOn(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + ring := mkRing(RingAddStrength, -1) + ring.Flags.Set(Cursed) + wear(g, Left, ring) + setInput(t, g, Escape) + + g.ringOff() + + if g.Msgs.Huh != cCursed { + t.Errorf("message = %q, want %q", g.Msgs.Huh, cCursed) + } + + assertHands(t, g, ring, nil) +} + +// cRingUse is one entry of the rings.c ring_eat uses[] table. +type cRingUse struct { + kind RingKind + name string // the C R_ name, for failure messages + uses int +} + +// cRingUses transcribes ring_eat's static uses[] verbatim: +// +// static int uses[] = { +// 1, /* R_PROTECT */ 1, /* R_ADDSTR */ +// 1, /* R_SUSTSTR */ -3, /* R_SEARCH */ +// -5, /* R_SEEINVIS */ 0, /* R_NOP */ +// 0, /* R_AGGR */ -3, /* R_ADDHIT */ +// -3, /* R_ADDDAM */ 2, /* R_REGEN */ +// -2, /* R_DIGEST */ 0, /* R_TELEPORT */ +// 1, /* R_STEALTH */ 1 /* R_SUSTARM */ +// }; +// +// A negative entry is not a cost: C computes eat = (rnd(-eat) == 0), a +// one-in-n chance of a single unit. R_DIGEST then flips the sign, so slow +// digestion returns 0 or -1 and is the only ring that gives food back. +func cRingUses() []cRingUse { + return []cRingUse{ + {RingProtection, "R_PROTECT", 1}, + {RingAddStrength, "R_ADDSTR", 1}, + {RingSustainStrength, "R_SUSTSTR", 1}, + {RingSearching, "R_SEARCH", -3}, + {RingSeeInvisible, "R_SEEINVIS", -5}, + {RingAdornment, "R_NOP", 0}, + {RingAggravateMonsters, "R_AGGR", 0}, + {RingDexterity, "R_ADDHIT", -3}, + {RingIncreaseDamage, "R_ADDDAM", -3}, + {RingRegeneration, "R_REGEN", 2}, + {RingSlowDigestion, "R_DIGEST", -2}, + {RingTeleportation, "R_TELEPORT", 0}, + {RingStealth, "R_STEALTH", 1}, + {RingMaintainArmor, "R_SUSTARM", 1}, + } +} + +// TestRingEatMatchesTheCUsesTable exercises all fourteen ring kinds, both +// hands, against the C table above. This is the highest-value assertion in +// the file: ring_eat feeds the hunger clock through daemons.c, so a wrong +// entry is a silent, slow divergence from C that no other test would see. +func TestRingEatMatchesTheCUsesTable(t *testing.T) { + t.Parallel() + + for _, tc := range cRingUses() { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + for _, hand := range []int{Left, Right} { + g := mkRingGame(t) + g.Player.CurRing[hand] = mkRing(tc.kind, 0) + + if tc.uses >= 0 { + assertFixedRingEat(t, g, hand, tc) + } else { + assertChanceRingEat(t, g, hand, tc) + } + } + }) + } +} + +// assertFixedRingEat checks a non-negative uses[] entry. C returns it +// unchanged and, just as importantly, never reaches rnd() on that path — +// so the generator must be untouched, or the whole game's RNG stream +// desynchronises from C's and seed compatibility is gone. +func assertFixedRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) { + t.Helper() + + for range 4 { + before := *g.Rng + + if got := g.ringEat(hand); got != tc.uses { + t.Fatalf("ringEat(%d) for %s = %d, want C uses[] entry %d", + hand, tc.name, got, tc.uses) + } + + if *g.Rng != before { + t.Fatalf("ringEat for %s called rnd(); C only does that for a "+ + "negative uses[] entry", tc.name) + } + } +} + +// assertChanceRingEat checks a negative uses[] entry. Each call is replayed +// against C's own expression from the identical generator state, which pins +// the one-in-n denominator, the sign flip R_DIGEST gets, and the fact that +// exactly one rnd() call is spent. The frequency check on top of that +// fails loudly on a wrong denominator even if the replay were ever +// weakened to agree with the code by construction. +func assertChanceRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) { + t.Helper() + + const trials = 4000 + + sign := 1 + if tc.kind == RingSlowDigestion { + sign = -1 // rings.c: if (ring->o_which == R_DIGEST) eat = -eat + } + + nonzero := 0 + + for range trials { + before := *g.Rng + got := g.ringEat(hand) + after := *g.Rng + + // C: eat = (rnd(-eat) == 0), replayed from the same state. + *g.Rng = before + + want := 0 + if g.Rng.Rnd(-tc.uses) == 0 { + want = 1 + } + + want *= sign + + if *g.Rng != after { + t.Fatalf("ringEat for %s did not spend exactly one rnd(%d) call", + tc.name, -tc.uses) + } + + if got != want { + t.Fatalf("ringEat for %s = %d, want %d", tc.name, got, want) + } + + if want != 0 { + nonzero++ + } + } + + assertOneInN(t, tc, nonzero, trials) +} + +// assertOneInN checks the observed rate against C's 1/n. The tolerance is +// far tighter than the gap between the three denominators C uses (1/2, +// 1/3, 1/5) and far wider than the sampling noise at this trial count. +func assertOneInN(t *testing.T, tc cRingUse, nonzero, trials int) { + t.Helper() + + const tolerance = 0.03 + + rate := float64(nonzero) / float64(trials) + want := 1 / float64(-tc.uses) + + if math.Abs(rate-want) > tolerance { + t.Errorf("%s fired %.3f of the time over %d trials, want ~%.3f "+ + "(C's one-in-%d)", tc.name, rate, trials, want, -tc.uses) + } +} + +// TestRingEatEmptyHandIsZero is C's "if ((ring = cur_ring[hand]) == NULL) +// return 0" — the common case, since the hero usually wears nothing. +func TestRingEatEmptyHandIsZero(t *testing.T) { + t.Parallel() + + g := mkRingGame(t) + before := *g.Rng + + for _, hand := range []int{Left, Right} { + if got := g.ringEat(hand); got != 0 { + t.Errorf("ringEat(%d) with an empty hand = %d, want 0", hand, got) + } + } + + if *g.Rng != before { + t.Error("ringEat on an empty hand consumed RNG") + } +} + +// TestRingNum covers rings.c ring_num. Its switch ends in the `otherwise` +// macro, which rogue.h 53 defines as `break;default` — so the four labels +// R_PROTECT, R_ADDSTR, R_ADDDAM and R_ADDHIT fall through to a single +// sprintf(" [%s]", num(o_arm, 0, RING)) and every other kind returns "" +// from the default arm before the buffer is ever reached. Unknown rings +// return "" earlier still, from the ISKNOW guard. +// +// The game pointer is C's implicit global state; ring_num reads none of +// it, and the port's signature only carries one to satisfy nameit's +// prfunc type, so nil is the honest argument here. +func TestRingNum(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + kind RingKind + bonus int + known bool + want string + }{ + {"R_PROTECT known", RingProtection, 2, true, " [+2]"}, + {"R_ADDSTR known", RingAddStrength, 1, true, " [+1]"}, + {"R_ADDDAM known", RingIncreaseDamage, -1, true, " [-1]"}, + {"R_ADDHIT known", RingDexterity, 3, true, " [+3]"}, + {"R_PROTECT cursed", RingProtection, -1, true, " [-1]"}, + {"R_ADDSTR unknown", RingAddStrength, 2, false, ""}, + {"R_SEARCH known", RingSearching, 2, true, ""}, + {"R_DIGEST known", RingSlowDigestion, 2, true, ""}, + {"R_NOP known", RingAdornment, 0, true, ""}, + {"R_SUSTARM known", RingMaintainArmor, 2, true, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + obj := mkRing(tc.kind, tc.bonus) + if tc.known { + obj.Flags.Set(Known) + } + + if got := ringNum(nil, obj); got != tc.want { + t.Errorf("ringNum() = %q, want %q", got, tc.want) + } + }) + } +} + +// Coverage of the fourteen ring kinds, for the record: +// +// All fourteen are exercised by TestRingEatMatchesTheCUsesTable and ten of +// them by TestRingNum. Beyond that, only three kinds have a ring_on effect +// at all — R_ADDSTR, R_SEEINVIS and R_AGGR — and each has its own test +// above, paired with the dropcheck arm that undoes it. The remaining +// eleven are deliberately not given a wear/remove test: in C they are +// inert at wear time, their powers being read from ISWEARING() elsewhere +// (R_SEARCH and R_TELEPORT in the command.c per-turn tail, R_PROTECT and +// R_ADDHIT/R_ADDDAM in fight.c, R_REGEN and R_DIGEST in daemons.c, +// R_SUSTSTR and R_SUSTARM in the drain paths, R_STEALTH in chase.c), so a +// wear/remove assertion for them would test nothing that rings.c does. +// Those call sites belong to their own files' tests, not to this one. +// +// One branch is intentionally unreachable rather than untested: ring_off's +// "obj == NULL -> not wearing such a ring" cannot fire, because every arm +// that reaches it has already established that the chosen hand is worn. +// The port keeps C's defensive check; there is no state from which to +// provoke it.