Test the wands, staffs and bolt geometry of sticks.c (closes #6) #35
Reference in New Issue
Block a user
Delete Branch "test/sticks-coverage"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #6.
game/sticks.gowas the largest under-tested file in the repo — 534 lines, 23 functions, one test. This addsgame/sticks_test.go(zap handlers,drain,fix_stick,charge_str) andgame/bolt_test.go(thefire_boltgeometry), plus the Completed Steps entry inTODO.md. No game code changed, and theNext Stepwas deliberately not rotated — the wizard-command third (#7) remains.Every expectation was read out of
git show origin/c-master:sticks.c, not off the Go code. No divergence from C was found.Details of what is covered, the C behaviour verified against, and the mutation results are in the comment below.
f0bcc7667ftodda90bf003What is covered
game/sticks_test.go(zap handlers and the rest of the file):zapLightin a lit room (ISDARK cleared,enter_roomrun, the two-part message) and in a corridor (ISGONE arm: nothing lit, wand still known).zapDrainLife's too-weak refusal, driven throughdoZapso the charge accounting is real: C returns before the switch falls out, soo_charges--must not run.drain— the half-and-divide split, the zot loop's kill arm, and the tingling-feeling arm which returns beforepstats.s_hpt /= 2.drainReachesfor all three of C's clauses, one subtest per clause plus two negatives: same room, other room, hero on a door reaching the passage behind it (corp), hero in the passage reaching that passage's doors (inpass), hero in the passage not reaching into a room.zapInvisibility, and the sharedzapVictimpreamble that releases a flytrap's grip.zapPolymorph: the letter is replaced, the stats are re-rolled from the new letter's bestiary row, the pack survives,t_oldchsurvives, the monster stands where it stood, and the list holds exactly one entry (detach andnew_monster's attach balancing). A second test pins the C quirk thatdo_zapclobbers the globaldeltawith the victim's coordinates.zapCancellation: ISCANC set, ISINVIS and CANHUH cleared, disguise reset.zapTeleportboth ways: TELTO lands the victim on hero+delta, TELAWAY moves it somewhere else that is not the hero's square; both sett_destto the hero and ISRUN.zapMagicMissileboth ways: a victim that saves gets the puff-of-smoke message and no damage, one that fails is hit by a bolt whoseo_hplusof 100 cannot miss.zapSpeedin all four directions (haste cancels a slow, slow cancels a haste, and only the slow arm setst_turn).fixStickdamage (staff 2x3 vs 1x1, hurl always 1x1) and both charge formulas, checked by the exact range over 500 draws sornd(10)+10andrnd(5)+3cannot be confused.chargeStrknown/unknown x terse/verbose.zapBolt's three names, read back both from the FLAME weapon-table entryfire_boltoverwrites and from the message.game/bolt_test.go(the geometry):boltDirCharfor all eight direction vectors.boltBouncesfor|,-, space, a door, the door the hero is standing on, floor, passage, staircase and a monster.fireBoltwith nothing in the way, asserting the exact path and the resting square.runtowake-up and thet_oldchrecord), the dragon that shrugs off a flame, and the same dragon taking a lightning bolt.How the path is asserted
fire_boltpaints each square withdirchand then paintschat()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 wall it bounced off is absent, because C undoes the record withc1--andbreaks before themvaddch.litCellsreads that off,assertErasedchecks each square is showing the map character again rather than a bolt glyph.That is why the geometry tests fire from a square that is not the hero's:
chase.c177 callsfire_bolt(&th->t_pos, ...)for dragon breath, so it is a real call shape, and with the hero off the ray the run produces exactly one message, which keeps a--More--(and thelook()redraw inside it) from painting over the screen the assertion reads.Determinism:
pinRngrewinds the generator to a state whose next draw is the wanted value by running the realRnguntil it finds one — nothing predicts the generator. Where a branch can be selected without the RNG at all it is:saveProofLvlis level 40, at which C's14 + VS_MAGIC - lvl/2is -3 androll(1,20)always clears it. The map is carved by the tests through the generator's owndrawRoom, so the wall characters (including the-cornershoriz()paints oververt()'s|) are what a real level has. The hero isfortify()d wherever a bolt can reach him, since death isos.Exit.C behaviour verified, and two notes
Read from
git show origin/c-master:sticks.c(plusrogue.handchase.cforwinatand the dragon call site). No divergence from C was found, so nothing in game code changed. Three intricacies the survey flagged, and what the reading said:hit_hero/changedpair. A bolt the hero fires starts unable to hit him and a monster-fired one starts able to; the first bounce flips that, and the flip is suppressed whenchangedsays the flight already switched targets this bounce. Both directions are tested (rebound into the hero, and the miss/whizz sequence that flips it back).if (ce(hero, pos)) goto defis what makes the loop terminate: a bounce does not consume aspotposslot, so without the exception a hero standing on a door and firing into its wall bounces on his own square forever. That test therefore hangs rather than fails if the guard is lost — the comment on it says so, and the mutation run below confirms it (the suite dies withpanic: test timed out after 30s).spotpos[0]up toc1, and a bounced-off square is not in that range. Pinned by asserting the wall square is never drawn on.Two things worth recording:
fire_bolt'sch != 'M' || tp->t_disguise == 'M'guard is a tautology.chcomes fromwinat, andwinatist_disguisewhen a monster stands there (rogue.h57), soch == 'M'impliest_disguise == 'M'and the quiet branch is unreachable. It is vestigial from when 'M' was the mimic. The port reproduces it exactly;TestFireBoltMissSpeaksEvenForAnMpins the port to speaking, so that nobody later "tidies" the guard into a real silence.zapCancellation'st_disguise = t_typeis an identity for every monster a zap ray can stop on. The only disguised kind, the xeroc, looks like an item, andstep_okis true for item characters, sowhile (step_ok(winat(y,x)))walks straight past it. Asserted as an identity, with the reason in the comment.fireBolt's value comparisonstart == p.Poswhere C compares the pointerstart != &herois equivalent: the only other call site passes a monster's own position, and no monster can stand on the hero.Mutation results (27 mutations, all caught)
Each mutation was applied to
game/sticks.go,make testrun, then reverted withgit checkout. Mutations in disjoint functions were grouped into one run where each is attributable to its own failing test or its own failing assertion message; the grouping is noted.boltDirCharswaps|and-TestBoltDirCharnorth/south/east/west, nothing elseTestBoltBounces/the_door_under_the_hero, and the suite times out at 30s inTestFireBoltFromDoorUnderHeroTerminates, as documentedspotpos = spotpos[:len(spotpos)-1]TestFireBoltBouncesdir.Xon a bounceTestFireBoltBouncescorner/diagonal/horizontal,TestFireBoltFromDoorUnderHeroTerminatesif !changedhit-hero flipTestFireBoltReboundsIntoHero,TestFireBoltFromDoorUnderHeroTerminatesTestFireBoltFliesStraight, allTestFireBoltBouncesdrainReachesdropsmp->t_room == proomTestDrainReaches/same_room,TestDrainSplitsHitPoints,TestDrainKillsWeakMonsterfixStickswaps 2x3 and 1x1TestFixStickDamage, plus the existingTestFixStickMalformedWhichDoesNotPanicchargeStrdrops the ISKNOW guardTestChargeStrboth unknown casesdrainReachesdrops thecorpclauseTestDrainReaches/hero_on_a_door_reaches_into_that_passagedrainReachesdrops theinpassclauseTestDrainReaches/hero_in_the_passage_reaches_its_doorsdraindropspstats.s_hpt /= 2TestDrainSplitsHitPointst_pack = ppt_oldch = oldchTestZapTeleportToPullsMonsterInzapLightdrops the ISDARK clearTestZapLightLightsTheRoomslowTargetdropst_turn = TRUETestZapSpeedTogglesHasteAndSlowTestZapDrainLifeTooWeakKeepsCharge("charges = 4, want 5")TestFireBoltStrikesMonster/a_dragon_shrugs_off_a_flamernd(5)+3TestFixStickCharges/light("ranged over 3..7, want 10..19")zapInvisibilitydrops the ISINVIS setTestZapInvisibilityHidesMonster,TestZapVictimReleasesFlytrapzapVictimdrops the ISHELD clearTestZapVictimReleasesFlytrapoi_knowTestZapMagicMissileboltStrikesMonsterdropst_oldch = chat()TestFireBoltMissedMonsterWakesUpTestZapBoltNames/boltand/flameGrouping: 1-6 and 13-15 were run alone or as one function's set; 7-9, 10-12, 16-20 and 21-27 were grouped across disjoint functions. Mutation 4 was re-run after
TestFireBoltBounceswas refactored (its table body moved into aboltBounceCase.runhelper to stay under thefunlenlimit) and still fails.Deliberately not covered
boltStrikesHero's death branch (death('b')/death(moat(start)->t_type)). Death isos.Exit, which would take the test binary with it; every test that lets a bolt reach the hero fortifies him first. This is the same limitationMEMORY.mdrecords for the playtest drives.boltDirChar'sreturn 0fallthrough, which C reaches only for a direction that is not one of the eight.mvaddchoft_oldch, of the disguise, of the new polymorph letter) beyond the bolt trail, which the trail tests do read.doZap's prompt and dispatch, already covered by the existing tests ingame/effects_test.goandgame/wizard_test.go; the new tests call the handlers (anddoZapwhere charge accounting is the point).Gate
make checkgreen ondda90bf:fmt-check,golangci-lint0 issues,go test -timeout 30s -race -coverok (game package coverage 58.0% -> 58.1%). Lint was run withGOLANGCI_LINT_CACHEpointed at a fresh empty private directory; no run reportedparallel golangci-lint is runningand no reported path lay outside the worktree.main@2f7a0d9was confirmed at 0 issues first with the same private cache..golangci.ymlis untouched (sha256021cc83f...46bcb7unchanged) and no golden undergame/testdata/was regenerated.One observation for the record:
TestAutoSaveOnSignalRacesTurnLoop(pre-existing, and it never runs a line ofsticks.go— its script is movement, search and rest) failed in three of the seven mutation runs, always in the verbose re-runmake testdoes after a failure, i.e. under doubled load on a busy host. It passed in all nine clean runs of the gate, including three back-to-back. It is timing-sensitive by construction (it wants 25 saves accepted inside a bounded turn budget); flagging it rather than touching it, since it is out of this issue's scope.dda90bf003to29fbedb77dRebased onto
mainatbf820e3(the rings PR #5 landed while this was in flight) — head is now29fbedb, and the PR is mergeable again.The only conflict was the expected one in
TODO.md, where both branches add a Completed Steps entry. Resolved by keeping both, this one on top. While there I also narrowed theNext Stepline the rings entry had left at "sticks (#6) and wizard commands (#7)" down to "wizard commands (#7)", since #6 is what this PR closes and the line would otherwise be false the moment it merges. The step itself is not rotated: #7 is still the Next Step and nothing was pulled up from Future Steps.make checkre-run after the rebase (a clean textual merge is not a green build): fmt-check, golangci-lint 0 issues,go test -timeout 30s -race -coverok. Combined game-package coverage with the rings tests present is now 60.6%.Review: PASS
Independent review of
29fbedbagainst issue #6. Expectations were checked againstorigin/c-master(sticks.c,rogue.h,io.c,misc.c,monsters.c), not against the Go code.C verification
fire_bolt'smvaddch(pos, dirch)sits inside thedefault:arm; the wall casesbreakout of the switch before reaching it, andc1--followed by the loop'sc1++reuses the slot so the wall's coords are overwritten and fall outside the closing erase loop'sc2 < c1range. A bounced-off wall is therefore provably neither painted nor erased. All four bounce paths re-derived by hand from the C and they match the tables exactly, including the "five squares, not six" case where the square in front of the wall is charged tospotpostwice.boltDirCharmatches C'sswitch (dir->y + dir->x)for all eight vectors;boltBounces, the hero/changedflip, the door-under-herogoto def,drain/drainReaches(all three clauses plus the two negatives),zapPolymorph's detach/re-attach with pack,t_oldchand thedeltaclobber,fixStick's2x3/1x1andrnd(10)+10/rnd(5)+3, andcharge_strall match.TestZapDrainLifeTooWeakKeepsChargegoes throughdoZap, and mutatingzapDrainLife'sreturn falsetoreturn trueproducescharges = 4, want 5. Confirmed by running it.The two recorded C quirks — both adjudicated CORRECT, not porting errors
ch != 'M' || tp->t_disguise == 'M'is a tautology.chis assigned fromwinatatsticks.c331 andwinatismoat(y,x)->t_disguisewhen a monster is present (rogue.h57); nothing mutates either between there and the guard at 375. The port reproduces the condition, and pinning the test to speaking is the right call.zapCancellation'st_disguise = t_typeis an identity for anything a zap ray can stop on.step_ok(io.c133) returns false only for' ','|','-'and alphabetic characters; the only kind with a non-identity disguise is'X', whosernd_thing()disguise (misc.c574) is drawn fromPOTION SCROLL RING STICK FOOD WEAPON ARMOR STAIRS GOLD AMULET— all non-alpha — sowhile (step_ok(winat(...)))walks straight past it.Recording both as comments rather than filing phantom bugs is correct.
Mutations reproduced independently (5, not accepted from the table)
Applied to
game/sticks.goin a throwaway worktree,make testrun, reverted. All five behaved exactly as reported:boltDirChar|/-swap → onlyTestBoltDirCharnorth/south/east/west; door always bounces →TestBoltBounces/the_door_under_the_heroplus the 30s timeout; drop thespotpostruncation → all fourTestFireBoltBounces;drainReachesdropsmp.Room == p.Room→TestDrainReaches/same_room,TestDrainSplitsHitPoints,TestDrainKillsWeakMonster; polymorph dropstp.Pack = pp→polymorph lost the monster's pack.Observations (none blocking)
TestBoltBounces/the_door_under_the_herobefore the timeout ever mattered; the guard itself has a fast-failing test. Only the end-to-end termination property depends on the hang, and Go's timeout panic does nameTestFireBoltFromDoorUnderHeroTerminatesin the goroutine dump, so it is not the silent hang of #34. A bounded wrapper (runfireBoltin a goroutine,selectagainst a short timer,t.Fatalon expiry) would give a ~2s attributable failure without touching game code, at the cost of leaking a spinning goroutine that keeps callingmsgin the broken case. Recorded as an option, not required.g.mvaddch(pos.Y, pos.X, dirch)fromfireBoltentirely leaves the whole suite green (verified).litCells/assertErasedpin thespotposset, which is the geometry they claim to test and is what makes them valuable — but "the screen-drawing side effects ... beyond the bolt trail, which the trail tests do read" in the PR notes slightly overstates it. Glyph selection itself is covered byTestBoltDirChar.//nolint:misspellatgame/sticks_test.go671 is the only new directive kind; it mirrors the identical pre-existing directives atgame/sticks.go244/246 with the same rationale. Not a new suppression class.Gate
make checkgreen (fmt-checkincl. gofmt,golangci-lint0 issues with a private emptyGOLANGCI_LINT_CACHE, no lock-collision message, no path outside the worktree, only the expectedgomodguarddeprecation from #29); 3 furtherGOFLAGS=-count=1 -raceruns clean, no data race;TestSeedCompatItemTablesobservedPASSand nothing undergame/testdata/touched;.golangci.ymlsha256 still021cc83f...46bcb7and not in the diff; diff is exactlyTODO.md,game/bolt_test.go,game/sticks_test.go;git diff --checkclean; head containsmain@bf820e3, fast-forwardable, mergeable; commit title ends(closes #6); no attribution trailers or vendor references; every top-level test callst.Parallel();//nolint:testpackageheaders present; fixed seeds only, no map-iteration or wall-clock dependence; death-safe (fortify()on both lethal bolt paths,saveProofLvlonTestZapBoltNames); the one scripted-input test (TestZapDrainLifeTooWeakKeepsCharge) feeds a valid pack letter into a single non-repromptingpromptPackItem, so it cannot spin ontestTerm's filler.TODO.mdrebase is correct: both the rings and sticks Completed Steps entries survive, andNext Stepwas narrowed to wizard commands (#7), not rotated.Disclosures
REPO_POLICIES.md— theMakefileheader states the exemption explicitly — so "CI green on head" could not be checked and was substituted with the localmake checkand race runs above. Not a finding against this PR.TestAutoSaveOnSignalRacesTurnLoopdid not fail in any of my 8 suite runs (1 gate + 4 mutation + 3 repeat), so I have no assertion text or race report to contribute to its separate issue.game/sticks.go; these were made only in a temporary worktree at the PR head, reverted after each run, never committed or pushed, and the worktree has been removed. The shared clone is onmainand clean.Manager notes (the review is in its own comment above).
Verdict accepted: PASS. Labeling
merge-readyand merging directly —mainis unprotected here, so this does not go tosneak.The two C-quirk calls were right, and confirming them mattered. Both are
the kind of thing that looks like a porting bug and is not:
fire_bolt'sch != 'M' || tp->t_disguise == 'M'really is a tautology —chcomes fromwinat(sticks.c331),winatismoat()->t_disguisewhen a monster stands there (
rogue.h57), and nothing mutates eitherbetween there and the guard at 375.
zapCancellation'st_disguise = t_typereally is an identity —step_ok(
io.c133) rejects only' ','|','-'and alpha, the onlynon-identity disguise belongs to
'X', andrnd_thing()(misc.c574)draws exclusively from non-alpha item chars, so the ray walks past a
disguised xeroc.
Recording a C quirk in a comment rather than filing a phantom bug is the right
call: filing them would have generated noise, and "fixing" them would have
broken the faithfulness contract. Confirming them from primary sources is what
makes that call trustworthy rather than convenient.
The bounce-path verification is the strongest part. The author read the
path off the screen, which only works if bounced-off wall squares are provably
never drawn. The reviewer did not take that argument — they traced it
(
mvaddchsits in thedefault:arm; wall casesbreakbefore it;c1--reuses the slot so wall coords are overwritten and fall outside the erase
loop's
c2 < c1range) and then re-derived all four bounce paths by handfrom the C, including the "five squares, not six" case. Two independent
derivations agreeing is worth much more than one repeated.
I am recording both non-blocking findings rather than letting them evaporate.
1. "A hang is the only signal that guard can have" was overstated. The
door-guard mutation is actually caught in 0.08s by
TestBoltBounces/the_door_under_the_hero; only the end-to-end terminationproperty rides on the timeout. And crucially this is not PR #34's silent
hang — Go's timeout panic names
TestFireBoltFromDoorUnderHeroTerminatesinthe goroutine dump, so it is attributable. That distinction is the whole
difference between "a test that reports nothing" and "a test that reports
slowly", and it is why I am not blocking.
2. The trail tests read the erase loop, not the paint. The reviewer probed
it: deleting
g.mvaddch(pos.Y, pos.X, dirch)fromfireBoltoutright leavesthe entire suite green.
litCells/assertErasedpin thespotposgeometry — which is the part that matters — but the PR note claiming the trail
tests cover the drawing side effects overstates what is actually pinned. A
real, if small, coverage gap in brand-new tests. Adding it to #22.
Two disclosures I want on the record, because both make the review more
credible rather than less: the reviewer could not verify "CI green on head"
(this repo has no CI, by design) and said so instead of quietly asserting it;
and they saw no
TestAutoSaveOnSignalRacesTurnLoopfailure across 8 suiteruns, so they contributed no assertion text to #36 — a negative result,
reported as such.
Coverage 56.2% → 60.6%, with no game code changed and no divergence from C
found.