Unit-test the eight trap effects against the C reference (closes #14)

`trapHandlers` had eight entries and zero direct tests, on the one
subsystem besides combat that can kill the hero outright. New
`game/traps_test.go` covers all eight arms of `move.c be_trapped`, the
prologue every trap runs through, and the `rust_armor` tail `T_RUST`
calls. Test-only: no game code changes.

Every expected value is transcribed from `origin/c-master` (`move.c`,
`misc.c`, `fight.c`, `monsters.c`, `rogue.h`) and quoted in the file. No
divergence from C was found.

The trap set is `rogue.h` 192-200: there is no separate "poison dart"
kind — `T_DART` is the poisoned dart — and `T_MYST`, the eleven-way
`rnd(11)` message switch, is the eighth. Details the tests are built
around: `BEARTIME`/`SLEEPTIME` are `spread(3)`/`spread(5)`, both of which
reduce to `rnd(0)` and so cost no random number, which is asserted as
well as their values; `T_ARROW` swings at `s_lvl - 1` and `T_DART` at
`s_lvl + 1`; and the strength loss is gated on `!ISWEARING(R_SUSTSTR) &&
!save(VS_POISON)`, whose short circuit means the ring saves a random draw
as well as the strength.

Damage dice and swing arguments are checked by sweeps rather than single
shots: `rnd(n)` is "raw value % n", so one draw cannot separate a d6 from
a d5, and a forced hit or miss cannot see a wrong `at_lvl`. Both shapes
were forced by mutation runs that the single-shot versions survived.

`be_trapped` takes a coordinate, and which coordinate decides whether
`T_TELEP`'s `mvaddch(tc, TRAP)` does anything. Sprung under the hero
(`move.go` 105-108, `case Floor`) the line is redundant: `tc` is the
hero's square, already stamped `TRAP` by the prologue and redrawn by
`teleport()`'s opening `mvaddch(hero, floor_at())`. Walked onto
(`move.go` 94-98, `case Trap`) it is the only writer: `tc` is the square
being stepped onto while the hero still stands on the previous one,
`leave_room` writes blanks and never `TRAP`, and the arm returns before
`finishMove` so no `look()` follows. Both shapes are tested.

The two death messages are deliberately uncovered: each is printed
immediately before `death()`, which reaches `myExit` and `os.Exit`, so
provoking either would kill the test binary. The hero is pinned with
`fortify()` and the damage is checked by replaying C's arithmetic. They
are the only two: `rust_armor`'s `|| ISWEARING(R_SUSTARM)` operand and
its `if (!to_death)` message suppression are covered as well.
This commit is contained in:
2026-08-09 15:37:46 +00:00
parent c0741ad1ea
commit ba444a2002
2 changed files with 1226 additions and 0 deletions

86
TODO.md
View File

@@ -70,6 +70,92 @@ of Completed Steps.
reach only mean something against known walls and a known passage number. All
27 mutations tried against the new tests were caught.
- 2026-08-09 Trap unit-test coverage (`test/traps-coverage`, closes #14):
`trapHandlers` had eight entries and **zero** direct tests, on the one
subsystem besides combat that can kill the hero outright. New
`game/traps_test.go` (19 tests, 15 subtests) covers all eight arms of
`move.c be_trapped`, the prologue every trap runs through, and the
`rust_armor` tail `T_RUST` calls. Package coverage 56.2% -> 57.9% measured on
`main` at `bf820e3`, the branch point, before the sticks tests landed. Every
expected value is transcribed from `origin/c-master` and quoted in the file.
**No divergence from C was found.**
The issue body's trap list was wrong and the correction is the first thing
worth recording: there is no separate "poison dart" trap — `T_DART` **is**
the poisoned dart, its death message being "a poisoned dart killed you" —
and the list omitted `T_MYST`, the mystery trap, whose arm is an eleven-way
`rnd(11)` message switch. `rogue.h` 192-200 is the authority
(`T_DOOR`/`T_ARROW`/`T_SLEEP`/`T_BEAR`/`T_TELEP`/`T_DART`/`T_RUST`/`T_MYST`,
`NTRAPS` 8) and the Go `TrapKind` iota matches it index-for-index.
Three C details the tests are built around. (1) `BEARTIME` and `SLEEPTIME`
are `spread(3)` and `spread(5)` (`rogue.h` 108-109), and `spread` is
`nm - nm/20 + rnd(nm/10)`; for both, `nm/10` is 0 and C's `rnd` short
circuits a zero range without touching the generator, so each is an exact
constant that costs **no** random number — and the tests assert the no-draw
half as well as the value, because a stray draw desynchronises the
seed-compatible stream. (2) `T_ARROW` swings at `s_lvl - 1` and `T_DART` at
`s_lvl + 1`: opposite signs, which is exactly the kind of detail a
transliterating port drops. (3) The strength loss is gated on
`!ISWEARING(R_SUSTSTR) && !save(VS_POISON)`, and the `&&` is load-bearing —
with the ring on, C never rolls the save, so the arm must spend two random
numbers and not three.
Two shapes worth keeping, both forced by mutation results rather than
foresight. Damage dice are checked by a **sweep**, not one shot: `rnd(n)` is
"raw value % n", so a single draw agrees between a d6 and a d5 five times in
six and leaves the generator identical either way — the first draft's
single-trial arrow test passed with `roll(1,6)` mutated to `roll(1,5)`.
Likewise the swing arguments are pinned by a 200-trial boundary sweep at a
mid-range to-hit target: a forced hit and a forced miss cannot see a wrong
`at_lvl` or a dropped `op_arm`, because both arms are reachable at any level
and swing spends one `rnd(20)` regardless.
Mutation-proved, 33 mutations, each reverted, and every one of them is now
caught. Three were **not** caught on the first pass and the tests were
strengthened until they were, which is the useful part of the record. (a)
Deleting `new_level()` from `T_DOOR` left the suite green: `be_trapped`'s
own prologue stamps the trap glyph into the cell the hero fell through, so
"the map changed" is true even with no new level dug. The test now counts
differing cells — exactly one can change that way — and also requires the
staircase to move and the hero to be re-placed. (b) The `roll(1,6)` case
above.
(c) **`be_trapped` takes a coordinate, and which coordinate decides whether
`T_TELEP`'s `mvaddch(tc, TRAP)` does anything.** Deleting that line first
left the suite green, and the first draft wrote that off as an unavoidable
redundancy — wrongly, because the test only exercised one of the two call
sites. `move.go` 105-108 (`case Floor`) springs a trap under the hero and
passes `p.Pos`; there `tc` **is** the hero's square, the prologue has
already set its `p_ch` to `TRAP`, and `teleport()` opens by drawing
`floor_at()` — which returns `chat(hero)` — over it, so the glyph is on
screen before the line runs. But `move.go` 94-98 (`case Trap`), the ordinary
walk onto a hidden trap, passes `nh`, the square being stepped **onto**,
with the hero still on the previous square: `teleport()`'s opening `mvaddch`
paints the old square, `leave_room` writes blanks and never `TRAP`, and
nothing calls `look()` afterwards because the `case Trap` arm returns before
`finishMove` for a teleporter. There `mvaddch(tc, TRAP)` is the only writer,
exactly as C's comment says.
`TestTrapTeleportDrawsTheTrapOnTheSquareSteppedOnto` springs the trap at a
floor square next to the hero and pins it: unmutated the screen at `tc`
reads `^`, with the line deleted it reads `.`.
The other 30 each failed their own test and only their own; two also moved
`TestAutoSaveOnSignalRacesTurnLoop`, which drives real turns and is
legitimately sensitive to `BEARTIME` and to armor rusting.
Deliberately uncovered: the two death messages, "an arrow killed you" and "a
poisoned dart killed you". Each is printed immediately before `death()`,
which reaches `myExit` and `os.Exit`, so provoking either would take the
test binary with it; the hero is pinned with `fortify()` and the damage
rolls are checked by replaying C's arithmetic instead of by letting HP reach
zero. They are the only two: `rust_armor`'s `|| ISWEARING(R_SUSTARM)`
operand and its `if (!to_death)` suppression of the rust-vanishes message,
the last predicates that had no assertion, are pinned by
`TestTrapRustHonoursTheRingAndTheToDeathFlag`. This entry does **not**
rotate `Next Step`: #14 was an out-of-band gap found while surveying, not
part of the rings/sticks/wizard step.
- 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

1140
game/traps_test.go Normal file

File diff suppressed because it is too large Load Diff