//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.