Test the wands, staffs and bolt geometry of sticks.c (closes #6)

game/sticks.go was the largest under-tested file in the repo: 534 lines,
23 functions and a single test. It now has two test files, both written
against the C reference (git show origin/c-master:sticks.c) rather than
against the current Go code, so they can catch divergence instead of
recording it.

game/sticks_test.go covers every zap handler that had none — light in a
room and in a corridor, drain-life's too-weak refusal (which returns
before o_charges--), drain's hit-point split and its kill arm,
drainReaches for all three of C's clauses, invisibility and the flytrap
release, polymorph's detach/re-attach dance with the pack, under-
character and delta-clobbering it does on the way, cancellation, both
teleport wands, magic missile, haste/slow in both directions, fix_stick's
damage and charge formulas, and charge_str.

game/bolt_test.go covers fire_bolt: dirch for all eight directions,
boltBounces including the door the hero stands on, an end-to-end flight
asserting the path and resting square, bounces off both wall
orientations, off a corner and diagonally off a wall (which pins C's rule
that a bounce negates both components rather than reflecting), a bounced
bolt striking the hero who fired it, the strike and miss arms, and the
dragon that shrugs off a flame but not a lightning bolt.

The tests read the flight path off the screen: fire_bolt paints its trail
and then paints chat() back over every square it recorded, so on an
otherwise blank screen the non-blank cells are exactly the squares the
bolt occupied, and the walls it bounced off are absent because C undoes
the record before the mvaddch. Determinism comes from a pinRng helper
that searches for a seed whose next draw is the wanted value, and from a
level the tests carve themselves with the generator's own drawRoom. The
hero is fortified wherever a bolt can reach him, since death exits the
process.

No divergence from C was found. Two notes are recorded in the test
comments: fire_bolt's "ch != 'M'" guard is a tautology, because winat is
t_disguise whenever a monster stands there, and the door-under-hero
exception can only be tested by the fact that the run terminates.
This commit is contained in:
2026-08-09 15:06:52 +00:00
parent bf820e3ec9
commit 29fbedb77d
3 changed files with 1408 additions and 2 deletions

39
TODO.md
View File

@@ -29,12 +29,47 @@ Refactor ground rules:
# Next Step
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
Broaden unit test coverage where playtesting finds thin spots — wizard commands
(#7). Rings and sticks, the first two thirds of this step, are done; see the top
of Completed Steps.
# Completed Steps
- 2026-08-09 Wands and staffs under test (`test/sticks-coverage`, closes #6):
the second of the three thin spots the Next Step names. `game/sticks.go` was
the largest under-tested file in the repo — 534 lines, 23 functions, one test
— and now has `game/sticks_test.go` (the zap handlers, `drain`, `fix_stick`,
`charge_str`) and `game/bolt_test.go` (the `fire_bolt` geometry). Every
expectation was read out of `sticks.c` rather than off the Go code; **no
divergence from C was found**, and three things worth knowing came out of the
reading. (1) **The bolt trail is the test instrument.** `fire_bolt` paints
each square with `dirch` and then paints `chat()` back over every square it
recorded, so on a screen nothing else has drawn on, the non-blank cells
afterwards are exactly the squares the bolt occupied — and the walls it
bounced off are absent, because C undoes the record with `c1--` and `break`s
before the `mvaddch`. That gives an exact assertion of the path and the
resting place without touching game code, and it is why the tests fire from a
square that is not the hero's (which is what `chase.c` does for dragon
breath): with the hero off the ray the run produces one message and the screen
stays readable. (2) **A bounce reverses both components of the direction, not
one.** A bolt entering a wall at 45 degrees goes back the way it came instead
of reflecting off the surface, so the diagonal-into-a-vertical-wall case is
the one that separates C's rule from the plausible wrong one, and it is
tested. (3) **The `ch != 'M'` guard on the miss message is a tautology.** `ch`
comes from `winat`, and `winat` _is_ `t_disguise` when a monster stands there
(`rogue.h` 57), so `ch == 'M'` implies `t_disguise == 'M'` and the arm can
never go quiet; it is vestigial from when 'M' was the mimic, and the test pins
the port to speaking, so nobody "tidies" it into a real silence. The
door-under-hero exception has no assertion of its own because it cannot have
one: without it the bolt bounces on the hero's own square forever, recording
nothing, and `fire_bolt` never returns — the test for it hangs rather than
fails, which the comment on it says. Determinism comes from a `pinRng` helper
that searches for a seed whose next draw is the wanted value (running the real
`Rng`, never predicting it) and from a level the tests carve themselves
through `drawRoom`, since bounce geometry and `drain`'s room/passage/door
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 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