Cover game/rings.go with C-verified unit tests (closes #5) #34
71
TODO.md
71
TODO.md
@@ -29,11 +29,78 @@ Refactor ground rules:
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
|
Broaden unit test coverage where playtesting finds thin spots — sticks (#6) and
|
||||||
wizard commands).
|
wizard commands (#7). Rings, the first third of this step, is done; see the top
|
||||||
|
of Completed Steps.
|
||||||
|
|
||||||
# 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
|
- 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
|
#31): checked every case label in C's `command.c` against this port's
|
||||||
dispatch, and left the audit behind as a standing test
|
dispatch, and left the audit behind as a standing test
|
||||||
|
|||||||
751
game/rings_test.go
Normal file
751
game/rings_test.go
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user