28 Commits

Author SHA1 Message Date
e3ab4aba8b build: add a make cover target for per-function coverage (closes #17)
make cover writes build/coverage.out and prints the per-function report;
make cover-html renders the same profile to build/coverage.html. The
per-package percentage make test prints cannot say which function is
untested.

Both write files, so neither is in check, and neither may be added to it:
check must not modify the working tree. Their output lands under the
already-ignored build/.

Verified: make cover printed per-function lines and a 62.6% total,
make cover-html wrote build/coverage.html, and git status stayed clean.
GOFLAGS=-count=1 make check green in 24s with the lint layer executing
(12.0s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.037s, game 3.410s); check is still fmt-check lint test and git status is
clean afterwards.
2026-08-10 13:48:58 +00:00
60442ce103 build: add a make build target, drop README's raw go build (closes #19)
The executable builds to build/rogue. All generated artifacts go under
build/, which .gitignore covers as a whole; a target writing outside it
can commit its output.

build is in neither check nor test: check stays fmt-check lint test and
still writes nothing into the working tree.

README's "Building and running" block and the run examples use
./build/rogue, and no raw go invocation is left in the file.

Verified: make build writes build/rogue and git status stays clean;
GOFLAGS=-count=1 make check green in 34s with the lint layer executing
(21.9s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.027s, game 3.450s), git status clean afterwards.
2026-08-10 13:48:58 +00:00
6f997b8d5c docs: trim the lint-gate comments to the traps (closes #44)
Comments and documentation only; the docker build invocation and its three
flags are byte-identical and .dockerignore's effective rules are unchanged.

script/lint and Dockerfile.lint stated how the shape was derived — why two
stages, why `golangci-lint config verify` was omitted, what earlier drafts
of the comments claimed. That is in the history. What survives is the three
traps, each of which yields a green run over an unlinted or partly linted
tree: --target and --no-cache-filter must both stay with $stage matching the
stage name in Dockerfile.lint; --target checks that the stage exists, not
that it runs golangci-lint, and halts the build there; and .dockerignore
decides what reaches the container, so excluding a self-contained Go file
drops it from the lint silently.

The TODO.md entry loses its "Hardened" and "Corrected" paragraphs, which
argued with earlier versions of themselves, and keeps the flags, the durable
property, the three unguarded seams, and the evidence that the gate was
verified rather than assumed.
2026-08-10 13:38:15 +00:00
9f079ab594 Merge next: run all linting in Docker (closes #41)
Pins golangci-lint by digest in Dockerfile.lint and removes the host lint path entirely, killing the false-green class that started the issue.
2026-08-10 15:33:21 +02:00
3eb9f81fc4 docs: name the two required flags, and record .dockerignore as part of the gate
Comments and documentation only; the docker build invocation and its flags
are untouched, and .dockerignore's effective rules are unchanged.

Two prose gaps from review. First, "both flags below must stay" had lost
its anchor: it ended a paragraph naming only --no-cache-filter, --target
was not introduced until the next one, and three flags follow on the
command, so a reader could pick the wrong pair. It now names --target and
--no-cache-filter explicitly.

Second, the list of things the tooling does not check covered the $stage
seam but not .dockerignore, which sits in the same trust boundary and is
the more likely thing to be edited — the first review on this change
actively suggested extending it for build artifacts. Only what reaches the
container is linted, so excluding a Go source there removes it from the
lint with no warning. Verified rather than asserted: a planted violation
plus that one path in .dockerignore yields `0 issues.` at exit 0 with the
violation still in the working tree, while excluding a file other code
still references fails loudly on `undefined:` typecheck errors instead.
The warning is recorded in script/lint alongside the $stage seam and in
.dockerignore itself, where the edit would actually be made.
2026-08-10 13:29:44 +00:00
20cfb47912 build: define the lint stage name once so the two flags cannot diverge
The previous commit claimed --target and --no-cache-filter "validate each
other's magic string". They do not. --target validates only its own
argument; a typo confined to --no-cache-filter left the build green and
linting nothing:

    docker build --target lint --no-cache-filter=lnit ...
    #10 [lint 2/2] RUN golangci-lint run ... CACHED   exit 0

Three of the four edit paths were caught and one was not, so the original
false green survived in the narrow case.

The duplication was the defect: the stage name appeared twice on one
command line and nothing tied the copies together. Correcting only the
prose would have left the hazard live and merely warned about, so the name
is now written once, as `stage=lint`, and passed to both flags. Divergence
is unrepresentable rather than documented — there is a single name to get
wrong, and --target rejects it loudly when it is not a stage in
Dockerfile.lint, which now covers the filter too because it is the same
string.

The comments in script/lint and Dockerfile.lint and the TODO.md entry drop
the false "validate each other" claim and state the real property, along
with the residual hazard that is genuinely unguarded: --target checks that
the name exists, not that it names the stage which actually runs
golangci-lint, and it stops the build there, so relocating the lint step
or appending a stage after it would go unnoticed.
2026-08-10 13:11:42 +00:00
329c03f06e build: make the lint cache-busting self-validating in script/lint
`--no-cache-filter=lint` is silently ignored by BuildKit when no stage
matches the name, so the entire anti-false-green mechanism hung on one
unvalidated magic string: renaming or mistyping the `lint` stage would
have left the lint layer served from cache and `script/lint` reporting
green having linted nothing. Reproduced here — with the filter pointed at
a nonexistent stage and no `--target`, an unchanged tree built with
`RUN golangci-lint run ... CACHED` and exited 0.

`--target lint` closes it: a stage name that does not exist now fails
loudly (`target stage "nosuchstage" could not be found`, exit 1) instead
of passing. The two flags name the same stage from the same string and
validate each other; both the script and the stage definition in
Dockerfile.lint carry a comment saying they must be kept in sync.

`--output=type=cacheonly` drops the image export. Nothing consumes the
image — the deliverable of this build is an exit code — and the export
cost seconds per run and left one dangling image behind every time, on a
host where pruning is prohibited. The lint stage still executes and a
lint failure still exits non-zero, both verified rather than assumed.

TODO.md: the 2026-08-07 entry's claim that the repo has no linter pin and
lints on the host is marked superseded in place rather than rewritten;
the narrowed scaffold exemption now names `.dockerignore` alongside
`Dockerfile.lint` and `script/lint`; and the specific wall-clock timings
are replaced by the durable property they were evidence for, since they
vary per host and per run.
2026-08-10 12:55:40 +00:00
599286a88e build: run golangci-lint in a pinned container via script/lint (closes #41)
golangci-lint is no longer invoked on the host anywhere in the repo.
Dockerfile.lint pins golangci/golangci-lint:v2.12.2 by digest and runs
the linter as a build step, so a successful build IS a clean lint, and
`make lint` becomes a thin shim over script/lint. This removes the host
linter install that produced a false green here, where a branch that was
genuinely red with a goconst finding reported "0 issues" off the shared
host cache; a container per run has its own cache and lock.

Two deliberate divergences from the sneak/homoicon reference shape:

  - Two stages rather than one. A cached `deps` stage holds
    `go mod download`, then `FROM deps AS lint` carries the source copy
    and the lint run, and script/lint builds with
    `--no-cache-filter=lint`. Caching of the lint result is explicitly
    waived (a cached build lints nothing), and splitting the stages means
    busting the lint layer does not re-fetch the module cache over the
    network on every run.

  - No `golangci-lint config verify` step. It resolves its JSON schema
    over a live, unpinned HTTPS call: an unpinned network input inside
    the one step whose purpose is a pinned, reproducible gate, and a
    schema-host outage would surface as a red build. `golangci-lint run`
    already fails on a malformed config. The reason is recorded in a
    comment in Dockerfile.lint.

.dockerignore excludes .git only; the lint reads the Go sources,
go.mod/go.sum and .golangci.yml, none of which come from there.

The TODO.md scaffold-exemption note is narrowed rather than dropped:
Dockerfile.lint and script/lint are now permitted and required, while CI
config, REPO_POLICIES.md, an application Dockerfile and any other
script/ entrypoint still are not.

Verified, since a green docker build is the classic false green: two
consecutive script/lint runs on an unchanged tree each showed the
`golangci-lint run` layer executing (9.8s and 7.9s, both "0 issues.")
while the deps layers reported CACHED; a deliberate indent-error-flow
violation failed the build naming that finding and the unused one, and a
revert went clean again. `make check` green.
2026-08-10 12:33:41 +00:00
bde4eae450 Merge fix/autosave-turn-budget-36 (drive on a condition, not a turn budget) 2026-08-09 19:18:56 +02:00
clawbot
3061931291 test: drive the autosave race test to a condition, not a turn count (closes #36)
TestAutoSaveOnSignalRacesTurnLoop failed intermittently under load. The
captured failure text settles what it was: driveUntilDone's

    t.Fatal("the turn loop ran out of turns before the saves were taken")

with no WARNING: DATA RACE anywhere in the log. The handoff is fine; the
test's own drive loop ran out of its fixed 1000-turn budget first.

Confirmed by instrumenting the loop to report the turns it actually
used. The count tracks scheduling pressure and nothing else: about
60-120 turns at host load ~57 with the whole machine to spread over, 418
at GOMAXPROCS=4, 539 and 655 at 2 and 1, and past 1000 under the doubled
load of the verbose rerun the test target performs after a failure. The
turns spent between one save being answered and the next request
arriving are not work, they are the saving goroutine's wake-up latency,
so a fixed turn count is a wall-clock assumption in disguise. Raising it
would hide the flake, not fix it. Each old-code failure took 0.12
seconds - 1000 turns burned in a tenth of a second - which is why every
attempt to reproduce this by loading the host failed: the cap was never
a wall-clock allowance at all.

So the budget is gone rather than larger. driveUntilDone drives until
the saving goroutine finishes and nothing else. Termination still holds,
it just belongs to the code under test: every AutoSaveOnSignal returns
within the timeout it is handed, so the saving goroutine always
finishes. A handoff that has stopped answering costs one autoSaveWait in
total, because g.sigSave is one deep and an unserviced request stays in
the channel for every later call to find full and fail on at once; what
fails is then the real assertion, "saves taken = 0, want 25", rather
than "out of turns". That is not the worst case, and the comment states
the bound that actually holds: a handoff that drains each request but
slower than autoSaveWait costs one timeout per save, wantSaves *
autoSaveWait = 250s, which would run past the 30s package timeout. It
needs about ten seconds of scheduler starvation per save against the
0.12s-per-1000-turns regime above, so it is remote, and a turn cap did
not bound it either.

Removing the cap exposed a second assumption underneath it. testTerm
answers space and newline for ever once its script is exhausted, and
neither key takes a turn, so command(), which loops until the player
consumes one, never returns; the old cap was silently sized to the
script. An uncapped drive wedged inside a single command() call. The two
drive tests now use driveTerm, a headless terminal whose script repeats.
Repeating is necessary and not sufficient, and the comment on driveTerm
says which property is load-bearing: ' ' clears After outright and all
eight movement keys clear it on a refused step, so a script of only
those keys wedges exactly as testTerm's tail did - with the script set
to " " the drive hits the 30s timeout inside command(). What makes the
wedge impossible is that the cycle always contains an unconditional
turn-taker, and these scripts contain two, '.' and 's', neither of which
can be refused by being blocked in all directions, Held, in a bear trap,
or under NoCommand > 0. Trimming both out would bring the wedge back.

The guard is undiminished, shown by mutation and reverted afterwards.
Reverting the fix from the earlier signal-autosave work - AutoSaveOnSignal
replaced by a direct g.autoSave(), encoding on the calling goroutine -
still fails the test with a flood of DATA RACE reports (139 here, 62-110
on another machine; the property is what is pinned, not the number),
the encoder reading what the turn loop writes. Removing
serviceAutoSaveRequest from command() still fails it too, now in 10s
with "saves taken = 0, want 25" instead of by hanging.

Under load: at GOMAXPROCS=2 on a 48-core host at load ~150, with an
unrelated deliberate failure in the tree so every run took the verbose
rerun, the old code failed 8 of 8 runs and the new code 0 of 8. Also
green across 24 concurrent unconstrained runs at load ~120, 10 runs
alongside a spinner load, and 5 runs each at GOMAXPROCS 1, 2 and 4.

No non-test code changed. make check green, lint 0 issues, .golangci.yml
byte-identical.
2026-08-09 17:06:45 +00:00
13caec4298 Merge test/wizard-coverage (wizard commands verified against C) 2026-08-09 18:15:32 +02:00
df45f4cb24 Merge trap coverage (eight trap effects verified against C) 2026-08-09 18:15:08 +02:00
ba444a2002 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.
2026-08-09 16:01:19 +00:00
6f409bda9e Cover the wizard commands with C-verified unit tests (closes #7)
game/wizard.go had no tests of its own. It is not purely a debug
surface: set_know writes the per-game discovered tables that name items
in ordinary play, and teleport is what the teleport ring calls every
fiftieth turn, so a defect in either leaks into a normal game.

Adds coverage for createObj (pack filing and the gold arm),
createWeaponArmor, createRing, showMap, whatis, whatisPick, setKnow,
teleport and command.go's wizardKit. Package coverage 60.6% -> 62.4%.
Expected values are transcribed from wizard.c, command.c, extern.c,
weapons.c and rogue.h rather than read off the port; wizard mode is
entered through Params.Wizard, the field main.go fills from
ROGUE_WIZARD, so no test pokes the flag.

Two notes from the C. A wizard-created "cursed" weapon is not cursed in
either language: init_weapon assigns o_flags over the ISCURSED bit
create_obj had just set, leaving only the o_hplus penalty, and the test
pins the whole flag word to the init_dam[] row to say so. And show_map
turns standout on for a square missing F_REAL but off only for a square
whose whole flag word is zero. Exactly three sites clear F_REAL:
putpass, which sets F_PASS first and so leaves 0x80; door's secret-door
arm, on a room-wall exit still holding exactly F_REAL, leaving zero;
and new_level's trap loop, whose rnd(NTRAPS) is 0..7, so the T_DOOR
(00) case leaves zero as well. C's wstandend therefore does fire, at
secret doors and unsprung trapdoors; what it gets wrong is leaking the
attribute forward from a secret passage or a non-trapdoor trap until it
reaches one of those - intermittent bands of reverse video, not a
permanently reversed map. That display-only difference is reported on
the issue and left alone here, and the test asserts standout only up to
the first secret square.

No game behavior is changed.
2026-08-09 16:01:07 +00:00
c0741ad1ea Merge test/sticks-coverage (wand, staff and bolt-geometry tests) 2026-08-09 17:23:54 +02:00
29fbedb77d 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.
2026-08-09 15:11:02 +00:00
bf820e3ec9 Merge test/rings-coverage (ring tests verified against C) 2026-08-09 17:08:28 +02:00
c61e2827c5 Cover game/rings.go with C-verified unit tests (closes #5)
game/rings.go had no test coverage at all: not one of the suite's tests
touched wearing a ring, taking one off, choosing a hand, or the ring
contribution to the hunger clock. New game/rings_test.go covers ringOn,
pickRingHand, ringOff, gethand, ringEat and ringNum, plus the ring arm of
things.c dropcheck (dropRing), which is what actually removes a worn ring.
17 tests, 44 subtests; package coverage 53.7% -> 56.2%. No game code
changes.

Every expected value is transcribed from the C reference on origin/c-master
(rings.c, rogue.h, things.c) and quoted in the file, rather than from what
the port currently returns. No divergence from C was found.

ringEat is the reason this matters most: it feeds daemons.c's hunger clock,
so a wrong entry is a slow, silent drift in when the hero starves. All
fourteen ring kinds are pinned to C's uses[] table, both hands. The three C
subtleties are handled explicitly: a negative uses[] entry is a one-in-n
chance of a single unit and not a literal cost; R_DIGEST then flips the
sign, so slow digestion returns 0 or -1; and ring_num's switch closes with
the otherwise macro (rogue.h 53: break;default), so its four labels fall
through to one sprintf and every other kind returns "" from a default arm.

The chance rings are checked by snapshotting the generator, calling
ringEat, and replaying 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; a frequency check over 4000 trials backs it. The
non-negative entries assert the opposite, that the generator is untouched,
because C never reaches rnd on that path and a stray call there would
desynchronise the game's RNG stream from C's.

Scripted hand answers carry an abort tail (a space for the reprompt's
--More--, then ESCAPE) so that a port which stopped accepting a key fails
on its assertion instead of looping forever on the headless terminal's
filler input. The "only one hand free" cases script the wrong hand key on
purpose: a port that prompted anyway would consume it and land the ring on
the wrong side.

Mutation-proved with 23 mutations, each reverted, each failing its own test
and only its own. All fourteen 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, and ring_off's unreachable "not
wearing such a ring" arm is documented as unreachable.
2026-08-09 14:53:46 +00:00
2f7a0d980d Merge audit/command-switch-coverage (pin dispatch to C's command switch) 2026-08-09 16:27:51 +02:00
2e02e7d190 test: pin command dispatch to C's command.c switch (closes #31)
Audits every case label in C's command.c against this port's dispatch and
leaves the audit behind as a standing test, so the two lists cannot drift
again. A missing dispatch entry is the one porting error that leaves no
trace at build time: the port is function-by-function, so every C function
has a Go counterpart and a dropped key dangles nothing, fails to compile
nowhere, and simply answers "illegal command" the first time a player
presses it. That is how '+' (#11) survived until PR #30 found it by
accident.

Result: no further missing keys. '+' was the only one. Recording that as a
negative result — the class of bug is real, it has now been searched for
exhaustively rather than stumbled upon, and the search came back empty.
Eighty labels: sixty-five in the main switch, fifteen in the wizard
sub-switch.

commandHandlers is pinned by set equality in both directions. A missing key
is the '+' bug; an extra key is the same bug mirrored, since the likeliest
way to acquire one is promoting a key out of the wizard sub-switch, which
would expose a MASTER debug command in ordinary play. The main-switch keys
that commandHandlers cannot hold - the goto-over re-dispatches, F-to-f, 'a'
and 'm' - are covered separately through dispatchKey, because dropping one
of those is just as silent as dropping a map entry.

Two traps are documented in the file. rogue.h 52-53 defines when as
break;case, so a grep for 'case ' finds ten of the eighty labels. And the
main/wizard split is load-bearing: '+' was a divergence in ordinary play,
not just wizard mode, precisely because it is a main-switch key.

Confirms this port targets the MASTER build: all four #ifdef MASTER sites
in command.c are ported unconditionally, as is sticks.c 237.
2026-08-09 14:27:26 +00:00
a653cc76f2 Merge fix/lost-c-behaviors (schtick message, forced redraw, greeting) 2026-08-09 12:13:59 +02:00
1142f43aed Restore three lost C behaviors: schtick message, forced redraw, greeting (closes #13)
Three behaviors from 5.4.4 that the port dropped silently. Each is a few
lines; grouped because they are all "restore something C did".

1. sticks.c 237: the "otherwise" arm closing do_zap's switch printed
   "what a bizarre schtick!", and doZap had turned it into doing nothing.
   The arm is under #ifdef MASTER, not under a runtime wizard test, so in
   the MASTER build this port is it printed for every player and must not
   be gated on g.Wizard. WS_NOP is a case of that switch in its own right
   ("when WS_NOP: break;"), so "no handler ran" cannot be the trigger:
   the wand of nothing does nothing quietly. C's switch covers all 14 WS_
   values, so its otherwise is reachable only for an o_which outside the
   table, which is what Object.hasValidWhich already screens for. All
   three arms fall through to obj.Charges--, as C's do.

2. command.c 288-291: CTRL('R') is "after = FALSE; clearok(curscr, TRUE);
   wrefresh(curscr);" — a forced full repaint. The port called
   g.refresh(), the ordinary diffing blit, which cannot fix the only
   situation the command exists for: a screen corrupted by another
   program's output leaves the game's record of it still correct, so the
   diff sends nothing. New Terminal.Repaint (tcell Screen.Sync, which
   discards tcell's record of the terminal rather than diffing against
   it), Screen.Repaint and g.repaint(), implemented in term.Tcell and in
   both headless test terminals. Named for the curses operation: the
   interface is the game's abstraction, not tcell's. It repaints what was
   last rendered — C repainted curscr, not stdscr — so it takes no
   window.

3. main.c 107-113: the startup greeting existed nowhere in the tree. New
   game.Greeting, printed on stdout by cmd/rogue/main.go before
   term.New(), the port's initscr(). Only the wizard wording is #ifdef
   MASTER; the other is unconditional. The %d is dnum, which main.c has
   just assigned to seed, so it is Params.Seed. Neither wording ends in a
   newline. Two placement details the tests pin: the printf sits after
   parse_opts, so a ROGUEOPTS name= is what the player is greeted by; and
   it sits after the -s/-d handling and after restore(), which never
   returns, so a resumed game does not announce that a dungeon is being
   dug (digsNewDungeon).

Greeting parses ROGUEOPTS into a throwaway game built the way New builds
the real one, tables and home directory included: ParseOpts handles every
option, not just the one the greeting reads, and inven= is matched
against inv_t_name[], which lives on the game.

All three message strings verified byte-for-byte against origin/c-master
sticks.c and main.c. No RNG call is added on any path and nothing under
game/testdata/ changed; TestSeedCompatItemTables is green against the
untouched golden.

Mutation-proved, each behavior removed in turn with only its own test
failing: dropping the message arm fails
TestZapUnhandledWandSaysBizarreSchtick; extending the message to WS_NOP
fails TestZapWandOfNothingIsSilent; putting g.refresh() back fails
TestRedrawCommandForcesFullRepaint; swapping the two wordings, and
ignoring the ROGUEOPTS name, both fail TestGreeting; greeting on the
restore path fails TestDigsNewDungeon.

ARCHITECTURE.md 5.3 gains Repaint and why a blit cannot substitute for
it; nothing here is deliberately dropped, so section 9 is unchanged.
TODO.md gets a Completed Steps entry; Next Step deliberately not rotated,
this being out-of-band issue work.
2026-08-09 10:04:54 +00:00
727dfb2642 Merge fix/wizard-toggle-off (restore the '+' wizard toggle) 2026-08-09 10:30:21 +02:00
c95f98ffe5 Port the '+' wizard-mode toggle-off (closes #11)
C's command.c 317-338 has a `when '+'` arm in the main command switch,
under #ifdef MASTER, that toggles wizard mode. The port had no '+' at
all, so the key fell through dispatchKey's default to illcom and
answered "illegal command '+'".

The password half of that arm was dropped deliberately (wizard mode is
ROGUE_WIZARD configuration) and is recorded in ARCHITECTURE.md section
9. The leave half was lost silently, and it is a different decision: it
does not touch the password machinery. The substantive part of it is
turn_see(TRUE) rather than the flag -- wizard sight draws every monster
the hero cannot see, so without the re-hide there is no way back to
normal visibility, and clearing the flag alone would leave the screen
lying.

New wizardToggleCommand, registered in commandHandlers between '^' and
Escape, which is C's own switch order. Because C's arm sits in the main
switch rather than the `if (wizard) switch (ch)` sub-switch that
wizardCommand ports, it is reachable whether or not wizard is set, so
the non-wizard case was a divergence too. It resolves the way the
dropped passwd() forces: a password check that no longer exists can
never succeed, so the else arm is what C did on a wrong answer -- the
message "sorry", with no prompt, since nothing typed into one could
change the outcome, and none of the noscore/turn_see(FALSE) bookkeeping
of C's unreachable success branch. The choice is stated in the doc
comment and in section 9.

Two tests drive '+' through g.dispatch. The wizard one spawns a phantom
(ISINVIS straight from the monster table, so seeMonst is false and it is
on screen only because wizard sight put it there), asserts the
precondition, then asserts the flag cleared, SenseMonsters cleared, the
cell restored to the map char under the monster with standout off, the
exact message text, and After false. Deleting the turnSee(true) call
fails it on all three visibility assertions. The other pins "sorry".

No RNG call is added: the turn_off arm of turn_see never reaches rnd.
TestSeedCompatItemTables is green against the untouched golden.
2026-08-09 08:22:05 +00:00
630038eedb Merge cleanup/pr26-followups (post-#26 doc and naming cleanups) 2026-08-09 10:13:19 +02:00
f7670cf86a docs,save: correct a stale TODO claim and rename encodeSnapshot (closes #27)
Four cleanups recorded as advisories during the PR #26 review and
deliberately kept out of it. No behaviour change.

The 2026-08-09 sig-leave (closes #12) TODO entry still argued in the
present tense that declining to save on SIGINT/SIGQUIT was also the safe
choice, "because AutoSave gob-encodes live state that the main goroutine
is still mutating, after removing the old file". Both halves stopped
being true with #24: the encode runs on the game goroutine and saveFile
is CreateTemp/Sync/Chmod/Rename with no Remove. The paragraph is now in
the past tense and marked superseded, pointing at the fix/autosave-race
entry, and says the split stands on C and on semantics alone — which is
what the current savesOnSignal comment says. The false claim was in the
#12 entry, not the #24 one; the latter's account of the old
remove-then-write is correctly historical and is untouched, as is the
err113 mention in the 2026-07-06 entry.

encodeSnapshot becomes writeSnapshotFile: it encodes, fsyncs, chmods
0400 and closes, and the old name claimed only the first of those. Its
doc comment now names all four and why the fsync is there.

TestAutoSaveOnSignalWhileInShellEscape used t.Error for a precondition,
so a save that was never taken fell through into assertRestorable, which
can then only report a second, derived failure. t.Fatal, matching the
identical assertion in the blocked-on-input test.

serviceAutoSaveRequest's doc comment carried a 24-column stub line
("The result is still a") left by an earlier edit. gofmt does not rewrap
comments, so fmt-check was legitimately green and nothing would have
caught it; the paragraph is rewrapped to the block's width.

Next Step deliberately not rotated: out-of-band issue work.
2026-08-09 08:05:30 +00:00
85354f2e6b Merge fix/autosave-race (service signal saves on the game goroutine) 2026-08-09 10:00:59 +02:00
clawbot
3a01283358 fix: take the signal-time autosave on the game goroutine (closes #24)
The SIGHUP/SIGTERM handler gob-encoded the live game tree from the signal
goroutine while the game goroutine was mid-turn mutating it, and AutoSave
removed the save file before encoding — so the failure mode was not a
stale save but a deleted one followed by a possibly torn replacement,
with a window in which the player had neither. The suite has run under
-race since 2026-08-09 and was green because nothing had ever driven the
turn loop concurrently with a signal: evidence of untested, not of safe.

The handler no longer writes anything. AutoSaveOnSignal posts a request,
wakes the input read, and waits up to signalSaveTimeout for the game
goroutine to take it; the encode runs on the goroutine that owns the
state, at the three points where that goroutine can sit: between turns
(command), on waking from a blocked readchar, and while parked in the `!`
shell escape (runShellEscape, which now runs the shell on a helper
goroutine so a hangup during it still rescues the game).

Blocked on input is the case that matters — a dropped connection lands
while the player is thinking, so a flag checked only between turns would
never be looked at. Terminal.ReadChar therefore returns (byte, bool),
with ok false meaning "woken by Interrupt, no key", and term.Tcell posts
a tcell.EventInterrupt onto tcell's own event queue to unpark PollEvent.
readchar services the request and reads again, so no caller sees it.

Running the shell on a helper goroutine would also have moved
term.Tcell.ShellEscape's panic on a failed Screen.Resume onto it, and a
panic at the top of any goroutine terminates the process without running
the deferred calls of the others — including cmd/rogue/main.go's
`defer t.Fini()`. The tty would have been left raw on precisely the path
where the terminal is already broken, which is issue #12's failure on a
path this change created. runShellEscape therefore recovers the helper's
panic and re-raises it on the game goroutine, whose stack has the restore
in it, so "every path restores the terminal via Terminal.Fini before
exiting" stays true.

saveFile writes a temporary file in the save's own directory, fsyncs it
and renames it over the target instead of truncating in place, so a save
that fails — or never happens because the deadline ran out — leaves the
player's previous save whole.

What the handoff guarantees is stated exactly rather than flatteringly:
the encode runs on the state-owning goroutine, so the snapshot is
internally consistent and restorable, but it is not necessarily taken
between commands. Only the check at the top of command is; the other two
service points both sit inside a command call already under way. readchar
is reached from mid-command prompts (--More--, askOverwrite, getStr, the
direction and pack prompts) with the command's mutations already applied,
and runShellEscape is reached from shell, an ordinary '!' command handler
dispatched inside command, with that turn's DoDaemons(Before) and
DoFuses(Before) already fired and its AFTER pass not yet. Restoring
re-enters playit at the top of command, so either way the rest of that
command is lost and a fresh BEFORE pass runs on top of the one in the
snapshot.

The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering
guarantee are untouched. pendingSaver reads the game out from under its
mutex rather than delegating with it held, because the delegated call now
blocks until the save is taken.
2026-08-09 07:50:23 +00:00
29 changed files with 5861 additions and 141 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
# Part of the lint gate: only what reaches the container is linted, so
# excluding a self-contained Go source here drops it from the lint silently.
# Never exclude Go sources, go.mod/go.sum or .golangci.yml.
.git
# Generated artifacts only; `make build` puts a multi-megabyte binary here
# and it would otherwise be shipped into the build context.
/build/

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
*.log
*.out
*.test
/build/
/rogue

View File

@@ -1488,6 +1488,8 @@ type Terminal interface {
ReadChar() (byte, bool) // event loop → C char codes (arrows→hjkl, ^C→quit);
// ok is false when Interrupt woke the read
Interrupt() // wake a blocked ReadChar (signal goroutine only)
Repaint() // forced full redraw for CTRL-R
// (curses clearok(curscr,TRUE)+wrefresh(curscr))
Fini() // restore the device (curses endwin) on exit
}
@@ -1507,16 +1509,25 @@ func (w *Window) Printwf(format string, a ...any); func (w *Window) Inch(y, x in
func (w *Window) Clear(); func (w *Window) Clrtoeol(); func (w *Window) Standout(on bool)
func (s *Screen) Refresh() // blit stdscr to the device
func (s *Screen) RefreshWin(w *Window) // blit any window
func (s *Screen) Repaint() // force a full redraw of the device
func (s *Screen) Fini() // tear the device down
```
Ported drawing code keeps its structure: `mvaddch(y, x, ch)`
`g.scr.Std.MvAddCh(y, x, ch)`. Curses `mvinch` reads come from the Window
buffer, preserving the "screen is a data structure" idiom without touching the
real terminal. `md_readchar`'s escape decoding is deleted; tcell's `EventKey`
provides decoded keys and we translate to the byte codes `command()` already
handles (KeyUp → 'k', etc.). Resize is handled by tcell, which registers only
SIGWINCH — SIGTSTP is not among the signals it takes, and is dropped (§9).
real terminal. `Repaint` is the one drawing call that is not a blit: it is the
`CTRL('R')` command's `clearok(curscr, TRUE)` plus `wrefresh(curscr)`,
implemented as tcell's `Screen.Sync`, and it exists because `Render` cannot do
its job. Both diff a new frame against the device's record of the old one and
send nothing where they agree, which is the wrong answer precisely when the
screen has been corrupted by something else's output — the only reason a player
types `CTRL('R')`. Like C's, it repaints what was last drawn rather than
re-blitting `stdscr`, so it takes no window. `md_readchar`'s escape decoding is
deleted; tcell's `EventKey` provides decoded keys and we translate to the byte
codes `command()` already handles (KeyUp → 'k', etc.). Resize is handled by
tcell, which registers only SIGWINCH — SIGTSTP is not among the signals it
takes, and is dropped (§9).
Signals are handled in `cmd/rogue/main.go`: one `os/signal` channel read by one
goroutine that reads exactly one signal, so a second signal can never call
@@ -1559,10 +1570,17 @@ yet run `DoDaemons(After)`, `DoFuses(After)` or `ringTurnEffects`. Restoring
re-enters `playit` at the top of `command` in either case, so the rest of that
command never runs — its AFTER daemons and fuses and its ring effects are lost —
and the restored game opens with a fresh BEFORE pass on top of the one already
in the snapshot: `rollwand` ticks again, any BEFORE fuse is decremented again.
The result is a coherent state one turn's worth of effects off, which is the
price of being able to save at all for a player whose line dropped mid-prompt or
who is away in a shell.
in the snapshot: `rollwand`, a live BEFORE daemon once `swander` has fired,
ticks again, and any BEFORE fuse is decremented again. Not every consequence of
that pass is shared by both service points, though. `visuals` returns
immediately unless `g.After`, and `After` is part of the snapshot, so `DVisuals`
never re-ticks after a shell-escape save — `shell` sets `g.After = false` as its
first statement, before it parks — whereas after a `readchar` save it usually
does, because `turnUpkeep` sets `g.After = true` just before the top-of-turn
read; the exception is a handler that clears `After` before prompting, as
`identifyTrapCommand` does ahead of `promptDirection`. The result is a coherent
state one turn's worth of effects off, which is the price of being able to save
at all for a player whose line dropped mid-prompt or who is away in a shell.
The shell escape runs the shell on a helper goroutine so that the game goroutine
stays free to answer, but a panic out of `Terminal.ShellEscape` (which is how a
@@ -1777,7 +1795,7 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7").
| `md_readchar` escape decoding | tcell decodes keys | key-event translation table |
| XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) |
| save-file symlink/hardlink checks | single-user era anti-cheat | none |
| DES crypt wizard password | ditto | `ROGUE_WIZARD` env var |
| DES crypt wizard password (`passwd()`, the `'+'` enter arm) | ditto | `ROGUE_WIZARD` env var |
| load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none |
| tty dsusp/ltc character juggling | tcell owns the tty | none |
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell |
@@ -1786,6 +1804,16 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7").
| SIGINT → the interactive `quit()` prompt | see below | `Q`; SIGINT exits cleanly |
| `auto_save` on SIGILL/TRAP/FPE/BUS/SEGV/SYS | see below | none |
Only half of C's `'+'` command (`command.c` 317-338) goes with the password row.
The leave arm is ported in full as `wizardToggleCommand`: it clears the wizard
flag, calls `turnSee(true)` — C's `turn_see(TRUE)`, without which there is no
way back out of wizard sight once it is on — and prints "not wizard any more".
What `passwd()` takes with it is the enter arm. A password check that no longer
exists is a password check that can never succeed, so `'+'` outside wizard mode
reduces to what C did when the answer was wrong: the message "sorry", with no
prompt, since nothing typed into one could change the outcome, and none of the
`noscore`/`turn_see(FALSE)` bookkeeping of C's success branch.
The three signal rows warrant more than a table cell.
**SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game

20
Dockerfile.lint Normal file
View File

@@ -0,0 +1,20 @@
# Lint image, built by script/lint: golangci-lint runs as a build step, so
# a successful build is a clean lint.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
# This stage must stay the one that runs golangci-lint, and its name must
# match $stage in script/lint. --target halts the build at this stage, so
# moving the lint step to another stage, or adding a stage after this one,
# is not caught.
FROM deps AS lint
COPY . .
RUN golangci-lint run --config .golangci.yml ./...

View File

@@ -1,18 +1,47 @@
# Development convenience targets. This repo is exempt from the standard
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
# is only a thin wrapper around the Go toolchain, golangci-lint, and
# prettier so `make fmt` / `make check` behave the same as in sneak's
# other repos.
# policy scaffold (no CI config, no REPO_POLICIES.md, no application
# Dockerfile) except for the lint container: per sneak's 2026-08-09
# ruling, linting runs in docker only, so Dockerfile.lint and script/lint
# are part of this repo. This Makefile is otherwise only a thin wrapper
# around the Go toolchain and prettier so `make fmt` / `make check` behave
# the same as in sneak's other repos.
GO_PKGS := ./...
MD_FILES := $(shell git ls-files '*.md')
PRETTIER := prettier --tab-width 4 --prose-wrap always
GO_PKGS := ./...
MD_FILES := $(shell git ls-files '*.md')
PRETTIER := prettier --tab-width 4 --prose-wrap always
.PHONY: check fmt fmt-check lint test
# Every generated artifact goes here, and the whole directory is
# git-ignored. Targets that write outside it can commit their output.
BUILD_DIR := build
BIN := $(BUILD_DIR)/rogue
COVERPROF := $(BUILD_DIR)/coverage.out
COVERHTML := $(BUILD_DIR)/coverage.html
# Format, lint, and test — the full local pre-commit gate.
.PHONY: build check cover cover-html fmt fmt-check lint test
# Format, lint, and test — the full local pre-commit gate. Keep this list
# to targets that write nothing into the working tree.
check: fmt-check lint test
# Build the executable into $(BUILD_DIR). `go build -o` does not create the
# parent directory.
build:
@mkdir -p $(BUILD_DIR)
go build -o $(BIN) ./cmd/rogue
# Per-function coverage, for finding which functions are untested. The
# percentage `make test` prints is a per-package total and cannot answer
# that. Writes files, so it stays out of `check`.
cover:
@mkdir -p $(BUILD_DIR)
go test -timeout 30s -coverprofile=$(COVERPROF) $(GO_PKGS)
go tool cover -func=$(COVERPROF)
# Render the same profile as annotated source.
cover-html: cover
go tool cover -html=$(COVERPROF) -o $(COVERHTML)
@echo "wrote $(COVERHTML)"
# Format Go and Markdown in place.
fmt:
gofmt -w .
@@ -26,9 +55,11 @@ fmt-check:
fi
$(PRETTIER) --check $(MD_FILES)
# Run the house linter (config in .golangci.yml).
# Run the house linter. golangci-lint is never installed on the host: the
# work happens inside the pinned container built by Dockerfile.lint, and
# this target is a thin shim over the script that builds it.
lint:
golangci-lint run $(GO_PKGS)
./script/lint
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
# full output and still fail the target (the first run already proved the

View File

@@ -21,19 +21,19 @@ original program structure and the design of this port.
Requires Go 1.25 or later and a terminal at least 80x24.
```bash
go build ./cmd/rogue
./rogue
make build
./build/rogue
```
```bash
# Restore a saved game
./rogue ~/rogue.save
./build/rogue ~/rogue.save
# View high scores
./rogue -s
./build/rogue -s
# Test the death screen (demo mode)
./rogue -d
./build/rogue -d
```
## In-game commands
@@ -57,7 +57,7 @@ Press `?` in game for the full list.
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
# Wizard (debug) mode, with a reproducible dungeon
ROGUE_WIZARD=1 SEED=12345 ./rogue
ROGUE_WIZARD=1 SEED=12345 ./build/rogue
```
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
@@ -77,10 +77,14 @@ sequences, dungeon-generation golden checks, and an RNG compatibility test
against the original C generator.
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race
detector with coverage and a timeout), and `make check` (all three). Use the
targets rather than invoking `go test` directly — they carry the flags the
project relies on.
prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
pinned container built from `Dockerfile.lint` — it is never installed on the
host, so docker is required), `make test` (the suite, under the race detector
with coverage and a timeout), `make check` (all three), `make build` (the
executable), and `make cover` / `make cover-html` (per-function coverage, and
the same profile as annotated source at `build/coverage.html`). Everything they
generate lands in the git-ignored `build/`. Use the targets rather than the
toolchain directly — they carry the flags the project relies on.
## License

653
TODO.md
View File

@@ -29,11 +29,514 @@ Refactor ground rules:
# Next Step
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
wizard commands).
Tag a release once a full game (Amulet retrieval and score entry) completes
without defects. Promoted from Future Steps now that the coverage step above it
is finished.
# Completed Steps
- 2026-08-10 `make cover` added (https://git.eeqj.de/sneak/rgoue/issues/17).
`make cover` writes `build/coverage.out` and prints the per-function report;
`make cover-html` renders the same profile to `build/coverage.html`. The
per-package percentage `make test` prints cannot say _which_ function is
untested, which is how the coverage gaps closed so far had to be found — by
grepping test files for identifiers.
Neither target is in `check`, and neither may be added to it: both write
files, and `make check` must not modify the working tree.
- 2026-08-10 `make build` added (https://git.eeqj.de/sneak/rgoue/issues/19). The
executable is built to `build/rogue`; `README.md` no longer contains a raw
`go` invocation anywhere. `build` is in neither `check` nor `test`
`make check` stays `fmt-check lint test` and still writes nothing into the
working tree.
Generated artifacts now all live under `build/`, which `.gitignore` covers
as a whole. Anything written outside it is committable, so a target that
puts its output elsewhere reintroduces the stray-artifact problem.
- 2026-08-10 Linting moved into a container
(https://git.eeqj.de/sneak/rgoue/issues/41). `golangci-lint` is no longer
invoked on the host anywhere in the repo: `Dockerfile.lint` pins
`golangci/golangci-lint:v2.12.2` by digest and runs the linter as a build
step, so a successful build is a clean lint, and `make lint` is now a shim
over `script/lint`. This is what killed the false green seen earlier, where a
branch that was genuinely red with a `goconst` finding reported `0 issues` off
the shared host cache; a container per run has its own cache and lock.
`script/lint` builds with `--target "$stage"`, `--no-cache-filter="$stage"`
and `--output=type=cacheonly`. The durable property to check when touching
any of this: the lint stage executes on every run and is never served from
cache. Three things no tooling checks, left to whoever edits the gate —
`$stage` must match the stage name in `Dockerfile.lint`; that stage must
stay the one running `golangci-lint`, since `--target` halts the build
there; and `.dockerignore` governs what reaches the container, so excluding
a self-contained Go source drops it from the lint silently.
Verified rather than assumed, since a green docker build is the classic
false green: two consecutive runs on an unchanged tree each showed the
`golangci-lint run` layer executing and reporting `0 issues.` while the
`deps` layers reported `CACHED`; the same build with `--no-cache-filter`
removed reported that layer `CACHED`, so the re-execution is attributable to
the flag rather than to a changed context; deliberate violations failed the
build naming the specific finding and reverted clean; a stage-name typo
failed loudly at exit 1; and a Go file excluded via `.dockerignore` reported
`0 issues.` at exit 0 with the violation still in the tree. Wall-clock
durations vary per host and per run, so they are not recorded here.
- 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause
(`fix/autosave-turn-budget-36`, closes #36). The failure text was captured
before anything was changed and it is **not** a data race: the assertion was
`driveUntilDone`'s
`t.Fatal("the turn loop ran out of turns before the saves were taken")`, with
no `WARNING: DATA RACE` anywhere in the log. The handoff fixed in #24 was
working; the test's own drive loop was running out of its fixed 1000-turn
budget first.
Confirmed rather than taken on trust. Instrumenting the loop to report the
turns it actually used showed the count tracking scheduling pressure and
nothing else: about 60-120 turns at host load ~57 with the whole machine to
spread over, 418 at `GOMAXPROCS=4`, 539 and 655 at 2 and 1, and past 1000 —
the recorded failure — under the doubled load of the verbose rerun that the
test target performs after a failure. The turns between one save being
answered and the next request arriving are not work; they are the saving
goroutine's wake-up latency, so a fixed turn count is a wall-clock
assumption in disguise, which is why raising it would have hidden the flake
rather than fixed it.
So the budget is gone rather than larger. `driveUntilDone` now drives until
the saving goroutine finishes and nothing else. Termination is not lost, it
just belongs to the code under test instead of to the test: every
`AutoSaveOnSignal` returns within the timeout it is handed, so the saving
goroutine always finishes. A handoff that has stopped answering costs one
`autoSaveWait` in total — `g.sigSave` is one deep, so an unserviced request
stays in the channel and every later call finds it full and fails at once —
and the failure is then the real assertion (`saves taken = 0, want 25`)
instead of "out of turns". The worst case is not that one: a handoff that
drains each request but slower than `autoSaveWait` costs one timeout per
save, `wantSaves × autoSaveWait` = 250s, which would run past the 30s
package timeout instead of reaching the assertion. It takes ~10s of
scheduler starvation per save against a measured 0.12s per 1000 turns, so it
is remote, and the turn cap did not bound it either. The comment in the test
states that bound rather than the optimistic one.
Removing the cap exposed a second assumption underneath it, which is the
reason this is not a one-line diff. `testTerm` answers space and newline for
ever once its script is exhausted, and neither key takes a turn, so
`command()` — which loops until the player consumes one — never returns; the
old cap was silently sized to the script (4000 characters, two per turn,
against 1000 turns). An uncapped drive wedged inside a single `command()`
call. The two drive tests therefore use a new `driveTerm`, a headless
terminal whose script repeats. Repeating is necessary but not sufficient,
and the test says so: `' '` clears `After` outright and all eight movement
keys clear it on a refused step, so a script of only those keys wedges just
as `testTerm`'s tail did. What makes the wedge impossible is that the cycle
always holds an _unconditional_ turn-taker, and these scripts hold two —
`'.'` (empty handler) and `'s'` (`search`, which writes `After` on no path),
neither refusable by blocked-in-all-directions, `Held`, a bear trap, or
`NoCommand > 0`. Removing both would bring the wedge back.
Both halves of the definition of done were demonstrated by mutation, with
the deliberately-broken tree reverted afterwards and `.golangci.yml` left
byte-identical (sha256 `021cc83f...46bcb`). Reverting #24
`AutoSaveOnSignal` replaced by a direct `g.autoSave()`, encoding on the
calling goroutine — still fails the test with 139 `WARNING: DATA RACE`
reports naming `snapshotHeader` reading what `executeCommand` writes, so the
guard is undiminished. Removing the `serviceAutoSaveRequest` call from
`command()` still fails it too, now in 10s with `saves taken = 0, want 25`
rather than by hanging.
Under load, an A/B at `GOMAXPROCS=2` on a 48-core host at load ~150, with an
unrelated deliberate failure in the tree so that every run took the verbose
rerun: the old code failed 8 of 8 runs with "ran out of turns"; the new code
failed 0 of 8, the only failure being the planted one. Also green across 24
concurrent unconstrained runs at load ~120, 10 runs alongside a spinner
load, and 5 runs each at `GOMAXPROCS` 1, 2 and 4. `make check` green, lint 0
issues.
- 2026-08-09 Wizard commands under test (`test/wizard-coverage`, closes #7): the
last of the three thin spots, so the coverage step is now closed rather than
narrowed. `game/wizard.go`'s eight functions had no tests of their own, and
the file is not purely a debug surface — `set_know` writes the per-game
discovered tables that name items in ordinary play, and `teleport` is what the
teleport ring calls every fiftieth turn. Package coverage 60.6% -> 62.4%.
Everything expected was transcribed from `wizard.c`, `command.c` (the
`CTRL('I')` kit), `extern.c` (`a_class[]`), `weapons.c` (`init_dam[]`) and
`rogue.h`; 30 mutations were tried and all 30 were caught.
Two findings came out of the reading. (1) **A wizard-created cursed weapon
is not cursed, in C or here.** `create_obj` sets `ISCURSED` and then calls
`init_weapon`, which _assigns_ `weap->o_flags = iwp->iw_flags` and so
overwrites the bit it just set; only the `o_hplus` penalty survives, and the
"cursed" weapon can still be dropped and unwielded. The port reproduces this
exactly. The test asserts the whole flag word comes back as the `init_dam[]`
row's value whatever blessing was answered, and deleting the `ISCURSED` line
from the port leaves every weapon test green — which is the evidence that
the line is dead for weapons. The armor arm has no such clobber and does
keep the curse. (2) **`show_map`'s standout is asymmetric in C and symmetric
here.** C tests `!(real & F_REAL)` before drawing and `!real` — the whole
flag word — after. `new_level` seeds every square with `p_flags = F_REAL`,
and exactly three sites clear that bit. `passages.c putpass` sets `F_PASS`
first, so its secret passage is left at `0x80`. `passages.c door`'s
secret-door arm clears it on a room-wall exit whose flags are still exactly
`F_REAL` (`rooms.c` writes no `p_flags` at all), leaving `p_flags == 0`; its
per-square gate is `rnd(5) == 0` against `putpass`'s `rnd(40) == 0`, and
`game/passages.go`'s `door` reproduces it. `new_level`'s trap loop then ORs
in `rnd(NTRAPS)`, which is `abs((int) RN) % 8` and so yields `0..7`, and
`T_DOOR` is `00` — an unsprung trapdoor square is also exactly zero
(`be_trapped` is what later ORs `F_SEEN` into it). So C _does_ turn standout
off again, at secret doors and unsprung trapdoors; what it gets wrong is
leaking the attribute forward from a secret passage or a non-trapdoor trap
until it reaches one of those. Intermittent bands of reverse video, not a
permanently reversed map. `game/wizard.go` tests `isReal` both times and
highlights the one square. That is a display-only difference in a
wizard-only command and was reported on the issue rather than changed here;
the test asserts the map characters unconditionally but the standout
attribute only up to the first secret square, so it pins nothing that C
contradicts.
Two things the tests had to be built around. The `insist` arm of `whatis` is
a loop whose only exits are picking a matching item and `n_objs == 0`, so a
script that runs dry hangs instead of failing — every sequence that can
re-prompt ends in an abort tail, the `n_objs == 0` exit is reached the way a
player reaches it (`*` for a list with nothing appropriate in the pack)
rather than by poking the counter, and the one mutation that deletes that
exit is the only one of the 30 that fails by timeout instead of fast,
necessarily so. And `show_map` does **not** mark squares seen — it writes
into `hw` and touches no `PLACE` at all — so the issue's wording for it
could not be tested as written; the loop bounds are asserted instead by
planting a marker in the rows C's loop excludes, since those rows are blank
on a real level and copying blanks over blanks would have made the bound
unfalsifiable.
- 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 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
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
#31): checked every case label in C's `command.c` against this port's
dispatch, and left the audit behind as a standing test
(`game/dispatch_test.go`) so the two cannot silently drift again. **No further
missing keys were found** — `'+'` (#11) was the only one. That is the result,
and it is worth recording as a negative: the class of bug exists, it has now
been searched for exhaustively rather than stumbled upon, and the search came
back empty. Three tables transcribe C's labels with their line numbers:
main-switch keys answered from `commandHandlers`, main-switch keys whose arms
need `dispatchKey`'s own switch (the `goto over` re-dispatches, `F`-to-`f`,
`a`, `m`), and the `if (wizard)` sub-switch. `commandHandlers` is pinned by
set equality in **both** directions: a missing key is the `'+'` bug, and an
extra key is the same bug mirrored — a MASTER debug command leaking into
ordinary play. Two traps make this audit harder than it sounds and are
documented in the file: `rogue.h` 52-53 defines `when` as `break;case`, so a
grep for `case ` finds ten of the eighty labels; and the main/wizard split is
load-bearing, since `'+'` was a divergence in ordinary play precisely because
it is a main-switch key. Confirms the port targets the MASTER build — all four
`#ifdef MASTER` sites in `command.c` are ported unconditionally, as is
`sticks.c` 237.
- 2026-08-09 Three small lost C behaviors (`fix/lost-c-behaviors`, closes #13):
grouped because each is a few lines and all are "restore something the port
dropped silently". (1) **"what a bizarre schtick!"**, `sticks.c` 237 — the
`otherwise` arm that closes `do_zap`'s switch, which `doZap` had turned into
doing nothing at all. Two things about it are easy to get wrong and are why
the fix is not one line. It is under `#ifdef MASTER`, **not** under a `wizard`
test, so in the MASTER build this port is it printed for every player — gating
it on `g.Wizard` would be issue #11's trap in reverse. And `WS_NOP` is a case
of that switch in its own right (`when WS_NOP: break;`), so "no handler ran"
cannot be the trigger: the wand of nothing does nothing _quietly_, and only a
kind C had no case for is bizarre. Since C's switch covers all 14 `WS_`
values, its `otherwise` is reachable only for an `o_which` outside the table,
which is exactly what `Object.hasValidWhich` already screens for — so the
split needed no new state, just a three-way switch on handler / valid-Which /
neither. All three arms fall through to `obj.Charges--`, as C's do: even the
bizarre schtick costs a charge. Replaces the deferral comment PR #20 left
there. (2) **`CTRL('R')` now actually redraws.** C is
`after = FALSE; clearok(curscr, TRUE); wrefresh(curscr);` (`command.c`
288-291); the port called `g.refresh()`, the ordinary diffing blit, **which
cannot fix the only situation the command exists for** — a screen corrupted by
something else's output leaves the game's record of it still correct, so the
diff sends nothing and the corruption stays. New `Terminal.Repaint` (tcell
`Screen.Sync`, which discards tcell's record of the terminal instead of
diffing against it), `Screen.Repaint`, `g.repaint()`; three implementations to
update, the same shape as PR #26's `ReadChar` change, so no split was needed.
Named for the curses operation, not for tcell: the interface is the game's
abstraction. It repaints what was last rendered — C repainted `curscr`, not
`stdscr` — so it takes no window, and the arm drops the `refresh()` C never
had there (`command` refreshes before the next key read anyway). (3) **The
startup greeting**, `main.c` 107-113, which existed nowhere in the tree. New
`game.Greeting`, printed by `cmd/rogue/main.go` before `term.New()` — the
port's `initscr()`. Only the wizard wording is `#ifdef MASTER`; the other is
unconditional. The `%d` is `dnum`, which `main.c` has just assigned to `seed`,
so it is `Params.Seed`. Two placement details the issue did not mention and
the tests now pin: the printf sits **after** `parse_opts`, so a ROGUEOPTS
`name=` is what the player is greeted by and the account name is only the
fallback (`Greeting` re-runs `ParseOpts`, which does nothing but assign into
fields — no RNG, no screen); and it sits after the `-s`/`-d` handling and
after `restore()`, which never returns, so a resumed game does not announce
that a dungeon is being dug (`digsNewDungeon`). The game `Greeting` parses
into is a throwaway but is built the way `New` builds the real one, tables and
home directory included, because `ParseOpts` handles every option and not just
the one the greeting reads: `inven=` is matched against `inv_t_name[]`, which
lives on the game, so a bare `&RogueGame{}` turned a legal `ROGUEOPTS` into a
nil dereference before the player saw a character. No RNG call is added on any
path and nothing under `game/testdata/` moved; `TestSeedCompatItemTables` is
green against the untouched golden. Mutation-proved, each new behaviour
deleted in turn and only its own test failing: dropping the message arm fails
`TestZapUnhandledWandSaysBizarreSchtick`; extending it to `WandNothing` fails
`TestZapWandOfNothingIsSilent`; putting `g.refresh()` back fails
`TestRedrawCommandForcesFullRepaint`; swapping the two wordings, or the
ROGUEOPTS name for the account name, fails `TestGreeting`; greeting on the
restore path fails `TestDigsNewDungeon`. ARCHITECTURE.md §5.3 gains `Repaint`
and the paragraph on why a blit cannot substitute for it. `Next Step`
deliberately not rotated: out-of-band issue work.
- 2026-08-09 The `'+'` wizard-mode toggle (`fix/wizard-toggle-off`, closes #11):
C's `command.c` 317-338 has a `when '+'` arm that leaves wizard mode —
`wizard = FALSE`, `turn_see(TRUE)`, `msg("not wizard any more")` — and the
port had no `'+'` anywhere, so the key fell through `dispatchKey`'s default to
`illcom` and answered "illegal command '+'". The password half of that arm was
dropped on purpose (wizard mode is `ROGUE_WIZARD` configuration) and is in
ARCHITECTURE.md §9; the leave half was lost silently and is not the same
decision — it does not touch the password machinery at all. **The substantive
part is `turn_see(TRUE)`**, not the flag: wizard sight draws every monster the
hero cannot see, so without the re-hide there is no way back to normal
visibility once wizard mode is on, and clearing the flag alone would have left
the screen lying. New `wizardToggleCommand` in `game/command.go`, registered
in `commandHandlers` between `'^'` and `Escape` — C's own switch order, and
note that C's arm sits in the **main** command switch under `#ifdef MASTER`,
not in the `if (wizard) switch (ch)` sub-switch that `wizardCommand` ports, so
it is reachable whether or not `wizard` is set. That makes the non-wizard case
a divergence too, and it resolves the way the dropped `passwd()` forces: a
password check that no longer exists can never succeed, so the else arm is
what C did on a wrong answer, the message "sorry" — no prompt, since nothing
typed into one could change the outcome, and none of the
`noscore`/`turn_see(FALSE)` bookkeeping of C's unreachable success branch. The
choice is stated in the function's doc comment and in §9, whose password row
now names the `'+'` enter arm and whose new paragraph records that the leave
arm is ported in full. Two tests in `game/wizard_test.go` drive `'+'` through
`g.dispatch`: the wizard one spawns a phantom (`ISINVIS` straight from the
monster table, so `seeMonst` is false and it is on screen only because wizard
sight put it there), asserts the precondition — monster glyph drawn in
standout at its cell, `SenseMonsters` set — and then asserts the flag cleared,
`SenseMonsters` cleared, the cell back to the map char under the monster with
standout off, the exact message, and `After` false; the non-wizard one pins
"sorry" and that `'+'` is no longer an illegal command. Mutation-proved:
deleting the `turnSee(true)` call fails the test on all three visibility
assertions, which is the half a flag-only test would have missed. No RNG call
is added — the `turn_off` arm of `turn_see` never reaches `rnd`, only the
turn-on arm does — and `TestSeedCompatItemTables` stays green against the
untouched golden. `Next Step` deliberately not rotated: out-of-band issue
work.
- 2026-08-09 Cleanups deferred from the PR #26 review (`cleanup/pr26-followups`,
closes #27): four items, no behaviour change. (1) The `sig-leave` entry below
still argued, in the present tense, that declining to save on SIGINT/SIGQUIT
was the safe choice because `AutoSave` encodes live state after removing the
file — both halves untrue since #24, and the entry read as a claim about how
the code works now rather than a record of what was weighed then. It is in the
past tense and marked superseded, pointing at the `fix/autosave-race` entry.
Nothing else in the file was touched — in particular the `err113` linter name
in the 2026-07-06 entry, which a `grep` for `113` still matches, and the "over
a hundred reports" wording the #26 rework had already corrected. Note for
anyone chasing this class of bug: the false claim was in the #12 entry, not
the #24 one, whose account of the old remove-then-write is correctly past
tense — find these by content, since `make fmt` reflows the file and cited
line numbers rot. (2) `encodeSnapshot` is `writeSnapshotFile`: it encodes,
fsyncs, chmods 0400 and closes, and the old name claimed only the first of
those. One call site (`saveFile`), and the doc comment now lists what it does
and why the fsync is there. (3) `TestAutoSaveOnSignalWhileInShellEscape` used
`t.Error` for its precondition, so a save that was never taken fell through
into `assertRestorable`, which can then only report a second, derived failure;
it is `t.Fatal`, matching the identical assertion in the blocked-on-input
test. (4) `serviceAutoSaveRequest`'s doc comment had a 24-column stub line
("The result is still a") left by an earlier edit — `gofmt` does not rewrap
comments, so `fmt-check` was legitimately green and nothing would ever have
caught it. Rewrapped to the block's width. `Next Step` deliberately not
rotated: out-of-band issue work.
- 2026-08-09 Signal-time autosave moved onto the game goroutine
(`fix/autosave-race`, closes #24): the SIGHUP/SIGTERM handler gob-encoded the
live game tree from the signal goroutine while the game goroutine was mid-turn
@@ -70,25 +573,25 @@ wizard commands).
the save, which still reads the old file whole after it. Each was
mutation-proved: reverting `AutoSaveOnSignal` to encode on the calling
goroutine (the pre-fix behavior) makes the turn-loop test fail under `-race`
with 113 reports, and removing each of the three service points fails exactly
the test for that park with its own message. `pendingSaver` now reads the game
out from under its mutex instead of delegating with it held, because the
delegated call blocks until the save is taken — the PR #23 review's N3 note,
load-bearing rather than hypothetical, and pinned by a test. The
SIGINT/SIGQUIT no-save decision and the single-signal-read ordering guarantee
are untouched; `savesOnSignal`'s third ground ("safety") is rewritten, since
the corruption window it weighed no longer exists. `MEMORY.md` stops listing
signal-time autosave among the deliberate `_ =` discards and states the new
discipline; `ARCHITECTURE.md` §5.3, the `Terminal` sketch, the C-to-Go mapping
row and §9's SIGTSTP paragraph are corrected to match. Two things review
caught and this entry records so they are not undone: moving the shell onto a
helper goroutine also moved `term.Tcell.ShellEscape`'s `panic` on a failed
`Screen.Resume` there, and a panic at the top of any goroutine kills the
process without running the deferred calls of the others — including
`cmd/rogue/main.go`'s `defer t.Fini()`, so the tty would have been left raw on
exactly the path where the terminal is already broken (issue #12's failure,
reintroduced on a new path). `runShellEscape` recovers the helper's panic and
re-raises it on the game goroutine, pinned by
with over a hundred reports, and removing each of the three service points
fails exactly the test for that park with its own message. `pendingSaver` now
reads the game out from under its mutex instead of delegating with it held,
because the delegated call blocks until the save is taken — the PR #23
review's N3 note, load-bearing rather than hypothetical, and pinned by a test.
The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering
guarantee are untouched; `savesOnSignal`'s third ground ("safety") is
rewritten, since the corruption window it weighed no longer exists.
`MEMORY.md` stops listing signal-time autosave among the deliberate `_ =`
discards and states the new discipline; `ARCHITECTURE.md` §5.3, the `Terminal`
sketch, the C-to-Go mapping row and §9's SIGTSTP paragraph are corrected to
match. Two things review caught and this entry records so they are not undone:
moving the shell onto a helper goroutine also moved `term.Tcell.ShellEscape`'s
`panic` on a failed `Screen.Resume` there, and a panic at the top of any
goroutine kills the process without running the deferred calls of the others —
including `cmd/rogue/main.go`'s `defer t.Fini()`, so the tty would have been
left raw on exactly the path where the terminal is already broken (issue #12's
failure, reintroduced on a new path). `runShellEscape` recovers the helper's
panic and re-raises it on the game goroutine, pinned by
`TestShellEscapePanicUnwindsTheGameGoroutine`. And the doc comment took two
rounds to get right: the first version claimed in four places that nothing is
half-mutated at the `readchar` service point, and the revision that fixed that
@@ -115,45 +618,51 @@ wizard commands).
HUP/TERM — and the semantics agree: HUP/TERM are involuntary teardown worth
rescuing a game from, while INT/QUIT are a deliberate "stop now" that must not
become a one-keystroke checkpoint against a save discipline built to be
anti-save-scum. It is also the safe choice: `AutoSave` gob-encodes live state
that the main goroutine is still mutating, after removing the old file, so on
the signals with nothing to rescue the port takes the option with no
corruption window. The single-reader design closes the window the issue warned
about: a second signal arriving mid-save stays unread in the buffer instead of
exiting out from under the writer (`TestLeaveOnSignalIgnoresLaterSignals`
reproduces exactly that interleaving). New `cmd/rogue/main_test.go` pins the
membership of `handledSignals()` itself (`TestHandledSignalsSet` — without it
the rest of the file, which iterates that set, would pass against a set that
had silently lost SIGINT and SIGQUIT again), and covers the ordering for each
signal, the save/no-save split against `savesOnSignal`, the
mid-save-second-signal case, the pre-game `pendingSaver` window, and real
SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered to the test process through the same
`notifySignals` wiring the game uses; the tty leaving raw mode is the one step
not checkable headlessly (it needs a controlling terminal), and
`term.Tcell.Fini` is a direct pass-through to tcell's `Screen.Fini` that
`myExit` already depends on. Two premises in the issue turned out to be wrong
and are recorded in ARCHITECTURE.md: `leave()` is not installed on
SIGINT/SIGQUIT during play (the wiring is in `mdport.c`, the shipped build
calls `md_onsignal_default()` and installs nothing, and `leave()` appears only
in the endgame paths of `rip.c`/`main.c`), and Ctrl-C never generated SIGINT
here anyway, since tcell's raw mode clears `ISIG` and the key arrives as byte
`0x03` — as it did in C, whose `setup()` calls curses `raw()`. The real
exposure is `kill -INT`/`kill -QUIT`, a SIGINT to the process group while the
`!` shell escape has the screen suspended, and the window **after**
`term.New()`: nothing is raw before it, and the handlers used to be installed
only once the game existed, leaving the restore path and `-d`'s `DeathDemo()`
— which never returns, blocking in `waitFor` inside `death()` — running raw
with no handler at all. The handlers are therefore installed immediately after
`term.New()`, with the game handed to them afterwards via `pendingSaver`; a
signal before the game exists restores the terminal and exits with nothing to
save, and the SIGHUP/SIGTERM autosave behavior on the play path is unchanged.
ARCHITECTURE.md §9 gained rows for SIGTSTP/`tstp()` (dropped: raw mode means
Ctrl-Z cannot reach us, a suspend from the signal goroutine would race the
drawing goroutine, and C armed `tstp` only after a `restore()`; the `!` shell
escape covers the need), for SIGINT not routing to the interactive `quit()`
prompt, and for `auto_save` on the fault signals; §5.3's claim that tcell
handles SIGTSTP was false — tcell registers only SIGWINCH — and is corrected.
`Next Step` deliberately not rotated: out-of-band issue work.
anti-save-scum. A third ground was weighed at the time and has since been
superseded: back then `AutoSave` gob-encoded live state that the main
goroutine was still mutating, after removing the old file, so declining to
save on the signals with nothing to rescue was also the option with no
corruption window. That window is gone as of the `fix/autosave-race` entry
above (#24) — the encode now runs on the game goroutine and `saveFile` renames
a temporary file into place — so nothing here should be read as a statement
about how saving works now; the split stands on C and on semantics alone, as
the current `savesOnSignal` comment says. The single-reader design closes the
window the issue warned about: a second signal arriving mid-save stays unread
in the buffer instead of exiting out from under the writer
(`TestLeaveOnSignalIgnoresLaterSignals` reproduces exactly that interleaving).
New `cmd/rogue/main_test.go` pins the membership of `handledSignals()` itself
(`TestHandledSignalsSet` — without it the rest of the file, which iterates
that set, would pass against a set that had silently lost SIGINT and SIGQUIT
again), and covers the ordering for each signal, the save/no-save split
against `savesOnSignal`, the mid-save-second-signal case, the pre-game
`pendingSaver` window, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered to the
test process through the same `notifySignals` wiring the game uses; the tty
leaving raw mode is the one step not checkable headlessly (it needs a
controlling terminal), and `term.Tcell.Fini` is a direct pass-through to
tcell's `Screen.Fini` that `myExit` already depends on. Two premises in the
issue turned out to be wrong and are recorded in ARCHITECTURE.md: `leave()` is
not installed on SIGINT/SIGQUIT during play (the wiring is in `mdport.c`, the
shipped build calls `md_onsignal_default()` and installs nothing, and
`leave()` appears only in the endgame paths of `rip.c`/`main.c`), and Ctrl-C
never generated SIGINT here anyway, since tcell's raw mode clears `ISIG` and
the key arrives as byte `0x03` — as it did in C, whose `setup()` calls curses
`raw()`. The real exposure is `kill -INT`/`kill -QUIT`, a SIGINT to the
process group while the `!` shell escape has the screen suspended, and the
window **after** `term.New()`: nothing is raw before it, and the handlers used
to be installed only once the game existed, leaving the restore path and
`-d`'s `DeathDemo()` — which never returns, blocking in `waitFor` inside
`death()` — running raw with no handler at all. The handlers are therefore
installed immediately after `term.New()`, with the game handed to them
afterwards via `pendingSaver`; a signal before the game exists restores the
terminal and exits with nothing to save, and the SIGHUP/SIGTERM autosave
behavior on the play path is unchanged. ARCHITECTURE.md §9 gained rows for
SIGTSTP/`tstp()` (dropped: raw mode means Ctrl-Z cannot reach us, a suspend
from the signal goroutine would race the drawing goroutine, and C armed `tstp`
only after a `restore()`; the `!` shell escape covers the need), for SIGINT
not routing to the interactive `quit()` prompt, and for `auto_save` on the
fault signals; §5.3's claim that tcell handles SIGTSTP was false — tcell
registers only SIGWINCH — and is corrected. `Next Step` deliberately not
rotated: out-of-band issue work.
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
@@ -245,7 +754,11 @@ wizard commands).
24 long lines wrapped or their comments tightened, control bytes in
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
`make lint` runs whatever `golangci-lint` is on the host).
`make lint` runs whatever `golangci-lint` is on the host). Superseded
2026-08-10: there is a pin now, and no host lint path — `Dockerfile.lint` pins
the linter image by digest and `script/lint` runs it in a container. See the
2026-08-10 entry at the top of this section
(https://git.eeqj.de/sneak/rgoue/issues/41).
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
@@ -389,13 +902,17 @@ wizard commands).
# Future Steps
1. Tag a release once a full game (Amulet retrieval and score entry) completes
without defects.
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
1. Full-terminal-size support (deferred by explicit decision 2026-07-06):
per-game dungeon dimensions instead of the 80x24 constants; open design
questions are resize policy, gameplay tuning at larger sizes, and a --classic
80x24 mode.
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
2026-07-07 request, but do not add a Dockerfile, CI config, or
REPO_POLICIES.md.
2. Note: this repo is exempt from the standard policy scaffold, but the
exemption is narrower than it was. A minimal dev Makefile
(fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07
request. `Dockerfile.lint` and `script/lint` are now also permitted, and
required, along with the `.dockerignore` that scopes their build context:
sneak's 2026-08-09 ruling (https://git.eeqj.de/sneak/rgoue/issues/41) is that
every repo lints in a container invoked through `script/lint`, and being
later and explicit it overrides the 2026-07-07 exemption for those three
files only. Still do not add: CI config, `REPO_POLICIES.md`, an application
`Dockerfile`, or any other `script/` entrypoint.

View File

@@ -40,6 +40,15 @@ func run() int {
return 0
}
// C printed its greeting just before initscr(); here that means just
// before the tcell screen takes the terminal, and on stdout, exactly
// as C did (main.c main). C followed the printf with fflush because
// its stdout was buffered; os.Stdout is not, so the write is the
// flush.
if digsNewDungeon(*deathDemo, flag.Args()) {
_, _ = fmt.Fprint(os.Stdout, game.Greeting(params)) // CLI output
}
t, err := term.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -87,6 +96,23 @@ func run() int {
return 0
}
// digsNewDungeon reports whether this invocation is the one that digs a
// fresh dungeon, and so the only one that greets.
//
// C's printf is the last statement before initscr(), and everything that
// does something else has already left by then: -s scores and exits, -d
// runs the death demo and exits, and restore() — the argc == 2 case that
// is neither — never returns. So a saved game resumes without a greeting,
// which is right: nothing is being dug.
//
// The restore test is duplicated from run's own, deliberately. Keeping
// them as one predicate would mean deciding the startup path before the
// terminal exists and carrying it past the error returns, which is more
// rearrangement of run than a greeting is worth.
func digsNewDungeon(deathDemo bool, args []string) bool {
return !deathDemo && len(args) != 1
}
// loadParams gathers the game parameters from the environment: home
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
// (main.c's startup).

View File

@@ -380,3 +380,40 @@ func (s *stuckSaver) AutoSaveOnSignal(time.Duration) bool {
return true
}
// TestDigsNewDungeon pins which invocations reach C's greeting. In main.c
// the printf is the last statement before initscr(), so -s and -d, which
// exit earlier, never see it, and neither does a restored game, because
// restore() does not return. The saved-game case is the one worth having
// a test for: resuming a dungeon must not announce that one is being dug.
func TestDigsNewDungeon(t *testing.T) {
t.Parallel()
cases := []struct {
name string
deathDemo bool
args []string
want bool
}{
{name: "new game", args: nil, want: true},
{name: "restore a save", args: []string{"rogue.save"}, want: false},
{name: "death demo", deathDemo: true, want: false},
{
name: "death demo wins over a save argument",
deathDemo: true,
args: []string{"rogue.save"},
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := digsNewDungeon(tc.deathDemo, tc.args); got != tc.want {
t.Errorf("digsNewDungeon(%v, %v) = %v, want %v",
tc.deathDemo, tc.args, got, tc.want)
}
})
}
}

View File

@@ -9,7 +9,6 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -17,7 +16,9 @@ import (
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
// expect the save to be taken. It is long enough that a loaded machine
// cannot turn a working handoff into a spurious failure, and it is never
// actually waited out on a passing run.
// actually waited out on a passing run. It is also what bounds
// driveUntilDone, by way of the saving goroutine it waits for — see
// there for what that bound comes to.
const autoSaveWait = 10 * time.Second
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
@@ -34,12 +35,14 @@ const autoSaveWait = 10 * time.Second
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
t.Parallel()
// Same mix as TestTurnLoopCrashSweep: the spaces answer any --More--
// prompt, and the script is long enough that the drive never runs it
// out.
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
// Same mix as TestTurnLoopCrashSweep the spaces answer any --More--
// prompt — on a driveTerm, so the drive can run for as long as the
// saves take rather than for as long as a script lasts. The '.' and
// the 's' are what make an unbounded drive safe, and at least one of
// the two has to stay in the cycle: see driveTerm.
term := &driveTerm{script: []byte("h j k l y u b n s . ")}
g := New(Params{Seed: 20260809, Term: &testTerm{input: script}})
g := New(Params{Seed: 20260809, Term: term})
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
g.startLevel()
g.prePlay()
@@ -74,14 +77,50 @@ func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
// driveUntilDone runs turns until the saving goroutine is finished,
// fortifying the hero each turn so no death exits the test binary. The
// turn cap keeps a broken handoff from hanging the suite instead of
// failing it.
// condition it waits on is that goroutine finishing — nothing else.
//
// It used to stop after a fixed 1000 turns and fail, and that cap was a
// load-sensitive assumption wearing a counter's clothes (issue #36). The
// turns this loop spends between one save request being answered and the
// next arriving are not work; they are the saving goroutine's scheduling
// latency, so the turn count 25 saves costs is a function of how
// contended the machine is rather than of anything the code under test
// does. Measured here on a 48-core host at load ~57: about 60-120 turns
// with a whole machine to spread over, 418 to 655 as GOMAXPROCS was cut
// from 4 to 1, and past 1000 under the doubled load of the verbose
// rerun, which is the flake this replaces. A budget that has to be
// guessed cannot be guessed right, so there is no budget.
//
// Dropping it costs no termination guarantee, because the bound belongs
// to the code under test and not to this loop: each AutoSaveOnSignal
// call returns within the timeout the caller hands it, so the saving
// goroutine always finishes and done always closes. That bound is worth
// stating exactly, because it is not one autoSaveWait.
//
// A handoff that has stopped answering altogether costs one, in total,
// however many saves were asked for. g.sigSave
// is one deep, so the unserviced request stays in the channel and every
// later call finds it full and reports failure immediately — measured
// at 10.0s for 25 saves with the service point deleted from command().
// What fails is then the caller's own assertion, the count of saves
// actually taken, which says far more than "out of turns" ever did.
//
// A handoff that still drains every request but takes longer than
// autoSaveWait to do it is the worst case, and costs one timeout per
// save: wantSaves * autoSaveWait, 250s at these constants, which would
// run past the package timeout rather than reach the assertion. It
// takes about ten seconds of scheduler starvation per save to get
// there, against a regime measured at 0.12s per 1000 turns, so it is
// remote — and the 1000-turn cap did not bound it either, a turn count
// being no kind of time bound. `go test -timeout 30s` is the backstop
// under all of it.
//
// The one thing the caller does have to supply is a terminal that can
// feed an unbounded drive: see driveTerm.
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
t.Helper()
const maxTurns = 1000
for range maxTurns {
for {
select {
case <-done:
return
@@ -91,8 +130,6 @@ func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
fortify(g)
g.command()
}
t.Fatal("the turn loop ran out of turns before the saves were taken")
}
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
@@ -152,7 +189,7 @@ func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) {
<-st.entered
if !g.AutoSaveOnSignal(autoSaveWait) {
t.Error("the save was not taken while the game was in the shell escape")
t.Fatal("the save was not taken while the game was in the shell escape")
}
assertRestorable(t, g.FileName)
@@ -266,9 +303,7 @@ func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 5, Term: &testTerm{
input: []byte(strings.Repeat("s . ", 200)),
}})
g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}})
g.FileName = ""
g.startLevel()
g.prePlay()
@@ -437,6 +472,66 @@ func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
return g
}
// driveTerm is a headless Terminal whose script repeats instead of
// running out, for the tests that drive the turn loop until something
// else finishes rather than for a set number of turns.
//
// testTerm cannot do that job. Once its script is exhausted it answers
// space and newline for ever, and neither takes a turn, so command() —
// which loops until the player does something that consumes one, the
// `if !g.After { ntimes++ }` in command.c — never returns. A drive with
// a turn cap sized to its script never notices; a drive that runs until
// the saves are taken wedges inside a single command() call, which is
// what a first attempt at issue #36 did.
//
// Repeating the script is necessary but nowhere near sufficient, and
// the difference is what anyone editing one of these scripts has to
// know. Most keys take a turn only conditionally. ' ' is the "legal
// illegal command" and clears After outright (tables.go). All eight
// movement keys clear it whenever the step is refused: a wall or the
// map edge (move.go moveResolve), an illegal diagonal (moveTarget), or
// a confused step that lands back in place (moveHero). A script of
// nothing but those keys wedges exactly the way testTerm's tail does,
// repetition or no repetition — with the script set to just " " this
// drive hits the 30s package timeout inside command().
//
// What actually makes the wedge impossible is that the cycle always
// contains at least one *unconditional* turn-taker, and the scripts
// here carry two: '.', the rest command, whose handler is empty, and
// 's', search, which writes After on no path. Nothing refuses either
// one — not being blocked in all eight directions, not Held, not stuck
// in a bear trap, and not NoCommand > 0, where playTurn skips
// executeCommand altogether and After is simply left true. Trim both
// out and the wedge this test exists to remove comes straight back.
//
// One further precondition, from what this fake does not supply:
// testTerm's tail answered a newline every other read and this does
// not. Nothing reachable from these scripts asks for one — waitFor('\n')
// sits on the death and score paths (rip.go, score.go), which fortify
// prevents from ever being reached — but a script that could reach them
// would park in waitFor for ever.
type driveTerm struct {
script []byte
pos int
}
func (t *driveTerm) Render(*Window) {}
func (t *driveTerm) Repaint() {}
func (t *driveTerm) Fini() {}
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
func (t *driveTerm) Interrupt() {}
// ReadChar hands out the next scripted key, wrapping at the end.
func (t *driveTerm) ReadChar() (byte, bool) {
ch := t.script[t.pos]
t.pos = (t.pos + 1) % len(t.script)
return ch, true
}
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
// block, cannot reproduce.
@@ -457,6 +552,10 @@ func newBlockingTerm() *blockingTerm {
func (t *blockingTerm) Render(*Window) {}
// Repaint has nothing to redraw: this terminal exists for its input
// behaviour, and no autosave test types CTRL-R.
func (t *blockingTerm) Repaint() {}
func (t *blockingTerm) Fini() {}
// Interrupt wakes a blocked ReadChar; called from the saving goroutine.

497
game/bolt_test.go Normal file
View File

@@ -0,0 +1,497 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"slices"
"testing"
)
// The bolt geometry of sticks.c fire_bolt, tested on the hand-carved
// level built by mkCarvedGame in sticks_test.go.
//
// Most of these fire from a square that is not the hero's, which is what
// chase.c does when a dragon breathes (fire_bolt(&th->t_pos, ...)). That
// keeps the hero off the ray, so the run produces exactly one message
// and the screen stays readable as a record of where the bolt went — see
// litCells.
// The three names sticks.c fires a bolt under (do_zap's WS_ELECT,
// WS_FIRE and WS_COLD arms); fire_bolt prints them and hangs them on the
// FLAME weapon-table entry.
const (
boltName = "bolt"
flameName = "flame"
iceName = "ice"
)
// litCells reports every non-blank cell of the map area of the screen.
//
// fire_bolt paints its trail with dirch and then erases it by writing
// back chat() for each square it recorded, so on a screen nothing else
// has drawn on, the squares left non-blank are exactly the ones the bolt
// occupied. Squares it bounced off are absent by construction: C undoes
// the record with c1-- and breaks before the mvaddch, so a wall is
// neither painted nor erased.
func litCells(g *RogueGame) []Coord {
var out []Coord
// Row 0 is the message line, not the map.
for y := 1; y < NumLines; y++ {
line := g.scr.Std.Line(y)
for x := range len(line) {
if line[x] != ' ' {
out = append(out, Coord{X: x, Y: y})
}
}
}
return out
}
// assertErased checks that every square the bolt flew over is showing
// the map character underneath it again: fire_bolt's closing loop paints
// chat() back over the whole trail, so a bolt leaves no '/' or '\'
// behind.
func assertErased(t *testing.T, g *RogueGame, cells []Coord) {
t.Helper()
for _, c := range cells {
got := g.scr.Std.Line(c.Y)[c.X]
if want := g.Level.Char(c.Y, c.X); got != want {
t.Errorf("square %v shows %q, want the map's %q: the trail "+
"was not erased", c, got, want)
}
}
}
// TestBoltDirChar covers the dirch switch for all eight directions. C
// keys it on dir->y + dir->x: the two sums of zero are the '/' pair, the
// two of magnitude two are the '\' pair, and the four axis directions
// split on whether y is zero.
func TestBoltDirChar(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dir Coord
want byte
}{
{name: "north", dir: Coord{X: 0, Y: -1}, want: '|'},
{name: "south", dir: Coord{X: 0, Y: 1}, want: '|'},
{name: "east", dir: Coord{X: 1, Y: 0}, want: '-'},
{name: "west", dir: Coord{X: -1, Y: 0}, want: '-'},
{name: "north east", dir: Coord{X: 1, Y: -1}, want: '/'},
{name: "south west", dir: Coord{X: -1, Y: 1}, want: '/'},
{name: "north west", dir: Coord{X: -1, Y: -1}, want: '\\'},
{name: "south east", dir: Coord{X: 1, Y: 1}, want: '\\'},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := boltDirChar(tt.dir); got != tt.want {
t.Errorf("boltDirChar(%v) = %q, want %q", tt.dir, got, tt.want)
}
})
}
}
// TestBoltBounces covers the case labels a bolt reflects off, and the
// door exception: C jumps to the default arm when the hero is standing
// on the door, "otherwise it would loop infinitely".
func TestBoltBounces(t *testing.T) {
t.Parallel()
const heroX, heroY = 5, 5
tests := []struct {
name string
ch byte
pos Coord
want bool
}{
{name: "vertical wall", ch: '|', pos: Coord{X: 6, Y: 5}, want: true},
{name: "horizontal wall", ch: '-', pos: Coord{X: 6, Y: 5}, want: true},
{name: "solid rock", ch: ' ', pos: Coord{X: 6, Y: 5}, want: true},
{name: "door", ch: Door, pos: Coord{X: 6, Y: 5}, want: true},
{
name: "the door under the hero",
ch: Door,
pos: Coord{X: heroX, Y: heroY},
want: false,
},
{name: "floor", ch: Floor, pos: Coord{X: 6, Y: 5}, want: false},
{name: "passage", ch: Passage, pos: Coord{X: 6, Y: 5}, want: false},
{name: "staircase", ch: Stairs, pos: Coord{X: 6, Y: 5}, want: false},
{name: "a monster", ch: 'Z', pos: Coord{X: 6, Y: 5}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
hero := Coord{X: heroX, Y: heroY}
if got := boltBounces(tt.ch, hero, tt.pos); got != tt.want {
t.Errorf("boltBounces(%q) = %v, want %v", tt.ch, got, tt.want)
}
})
}
}
// TestFireBoltFliesStraight is the end-to-end run with nothing in the
// way: six squares, BOLT_LENGTH of them, and the last one is where the
// bolt stops.
func TestFireBoltFliesStraight(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 41)
dir := Coord{X: 1, Y: 0}
g.fireBolt(Coord{X: 2, Y: 2}, &dir, flameName)
want := []Coord{
{X: 3, Y: 2}, {X: 4, Y: 2}, {X: 5, Y: 2},
{X: 6, Y: 2}, {X: 7, Y: 2}, {X: 8, Y: 2},
}
got := litCells(g)
if !slices.Equal(got, want) {
t.Errorf("bolt path = %v, want %v", got, want)
}
assertErased(t, g, got)
if g.Msgs.Huh != "" {
t.Errorf("a bolt that hit nothing said %q", g.Msgs.Huh)
}
if (dir != Coord{X: 1, Y: 0}) {
t.Errorf("direction = %v, want it unchanged", dir)
}
}
// TestFireBoltBounces covers the reflection rule in both wall
// orientations, off a corner, and — the case that separates C's rule
// from a plausible wrong one — diagonally off a vertical wall. C negates
// *both* components, so a bolt that came in at 45 degrees goes back the
// way it came instead of reflecting off the surface.
// boltBounceCase is one wall-bounce run: where the bolt sets off, which
// way it goes, the wall it must reflect off, and the squares it must end
// up having occupied.
type boltBounceCase struct {
name string
start Coord
dir Coord
wall Coord
want []Coord
}
// run fires the case's bolt and checks its whole flight.
func (tt boltBounceCase) run(t *testing.T) {
t.Helper()
g := mkCarvedGame(t, 42)
dir := tt.dir
g.fireBolt(tt.start, &dir, flameName)
got := litCells(g)
if !slices.Equal(got, tt.want) {
t.Errorf("bolt path = %v, want %v", got, tt.want)
}
assertErased(t, g, got)
if slices.Contains(got, tt.wall) {
t.Errorf("the wall at %v was drawn on; C drops the bounce "+
"square from spotpos before the mvaddch", tt.wall)
}
if want := (Coord{X: -tt.dir.X, Y: -tt.dir.Y}); dir != want {
t.Errorf("direction = %v after one bounce, want %v", dir, want)
}
if g.Msgs.Huh != "the flame bounces" {
t.Errorf("message = %q, want %q", g.Msgs.Huh, "the flame bounces")
}
}
func TestFireBoltBounces(t *testing.T) {
t.Parallel()
tests := []boltBounceCase{
{
name: "off a vertical wall",
start: Coord{X: 3, Y: corridorY},
dir: Coord{X: -1, Y: 0},
wall: Coord{X: 1, Y: corridorY},
// Five squares, not six: the square in front of the wall is
// flown over twice, and C charges spotpos for both.
want: []Coord{
{X: 2, Y: 4}, {X: 3, Y: 4}, {X: 4, Y: 4},
{X: 5, Y: 4}, {X: 6, Y: 4},
},
},
{
name: "off a horizontal wall",
start: Coord{X: 5, Y: 2},
dir: Coord{X: 0, Y: -1},
wall: Coord{X: 5, Y: 1},
want: []Coord{
{X: 5, Y: 2}, {X: 5, Y: 3}, {X: 5, Y: 4},
{X: 5, Y: 5}, {X: 5, Y: 6}, {X: 5, Y: 7},
},
},
{
name: "off a corner",
start: Coord{X: 3, Y: 3},
dir: Coord{X: -1, Y: -1},
wall: Coord{X: 1, Y: 1},
want: []Coord{
{X: 2, Y: 2}, {X: 3, Y: 3}, {X: 4, Y: 4},
{X: 5, Y: 5}, {X: 6, Y: 6},
},
},
{
name: "diagonally off a vertical wall",
start: Coord{X: 3, Y: corridorY},
dir: Coord{X: -1, Y: -1},
wall: Coord{X: 1, Y: 2},
want: []Coord{
{X: 2, Y: 3}, {X: 3, Y: 4}, {X: 4, Y: 5},
{X: 5, Y: 6}, {X: 6, Y: 7},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tt.run(t)
})
}
}
// TestFireBoltReboundsIntoHero covers the hit_hero/changed pair: a bolt
// the hero fires starts unable to hit him, and the first bounce flips
// that, so a wall one square away throws his own bolt back at him.
func TestFireBoltReboundsIntoHero(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lvl int
wantMsg string
wantHurt bool
}{
{
name: "the hero saves",
lvl: saveProofLvl,
wantMsg: "the flame whizzes by you",
wantHurt: false,
},
{
name: "the hero is hit",
lvl: 1,
wantMsg: "you are hit by the flame",
wantHurt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 43)
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
fortify(g) // a bolt to the face must not exit the test binary
g.Player.Stats.Lvl = tt.lvl
pinRng(t, g, d20, 1) // the lowest save throw there is
hp := g.Player.Stats.HP
dir := Coord{X: -1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, flameName)
if g.Msgs.Huh != tt.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
}
lost := hp - g.Player.Stats.HP
if hurt := lost > 0; hurt != tt.wantHurt {
t.Errorf("hero lost %d hit points, want hurt = %v",
lost, tt.wantHurt)
}
// roll(6, 6) is six to thirty-six.
if tt.wantHurt && (lost < 6 || lost > 36) {
t.Errorf("hero lost %d hit points, want 6..36", lost)
}
})
}
}
// TestFireBoltFromDoorUnderHeroTerminates covers the guard C wrote the
// door case for: the hero standing on a door and firing into the wall
// that door sits in. Without the ce(hero, pos) exception the bolt
// bounces on his own square forever, never recording a spot and never
// filling spotpos, and fire_bolt does not return — this test hangs
// rather than fails if the exception is lost.
func TestFireBoltFromDoorUnderHeroTerminates(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 44)
placeHero(g, Coord{X: doorAX, Y: corridorY})
fortify(g)
pinRng(t, g, d20, 1) // no save: the strike ends the flight
hp := g.Player.Stats.HP
dir := Coord{X: 0, Y: -1} // north, into the wall the door is in
g.fireBolt(g.Player.Pos, &dir, boltName)
if g.Msgs.Huh != "you are hit by the bolt" {
t.Errorf("message = %q, want the hero to be hit", g.Msgs.Huh)
}
if lost := hp - g.Player.Stats.HP; lost < 6 || lost > 36 {
t.Errorf("hero lost %d hit points, want 6..36", lost)
}
}
// TestFireBoltStrikesMonster covers the monster arm both ways, and the
// dragon's immunity to flame that C spells out in the same breath.
func TestFireBoltStrikesMonster(t *testing.T) {
t.Parallel()
tests := []struct {
name string
typ byte
lvl int
bolt string
wantMsg string
wantHurt bool
}{
{
name: "it fails its save",
typ: 'Z',
lvl: 1,
bolt: boltName,
wantMsg: "the bolt hits the zombie",
wantHurt: true,
},
{
name: "it saves",
typ: 'Z',
lvl: saveProofLvl,
bolt: boltName,
wantMsg: "the bolt whizzes past the zombie",
wantHurt: false,
},
{
name: "a dragon shrugs off a flame",
typ: 'D',
lvl: 1,
bolt: flameName,
wantMsg: "the flame bounces off the dragon",
wantHurt: false,
},
{
name: "but not a lightning bolt",
typ: 'D',
lvl: 1,
bolt: boltName,
wantMsg: "the bolt hits the dragon",
wantHurt: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 45)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, tt.typ, Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = tt.lvl
tp.Stats.HP = 500 // enough to survive 6x6 and stay assertable
pinRng(t, g, d20, 1) // the lowest save throw there is
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, tt.bolt)
if g.Msgs.Huh != tt.wantMsg {
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
}
if hurt := tp.Stats.HP < 500; hurt != tt.wantHurt {
t.Errorf("monster hit points = %d, want hurt = %v",
tp.Stats.HP, tt.wantHurt)
}
})
}
}
// TestFireBoltMissedMonsterWakesUp covers the rest of the miss arm: a
// bolt the hero fired sets the monster running (runto) before it says
// what it whizzed past, and the bolt flies on for its full length.
func TestFireBoltMissedMonsterWakesUp(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 46)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = saveProofLvl
tp.Flags.Clear(Awake)
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, boltName)
if !tp.On(Awake) {
t.Error("the monster the bolt missed is still asleep")
}
if tp.Dest != &g.Player.Pos {
t.Error("the woken monster is not chasing the hero")
}
if tp.OldCh != Floor {
t.Errorf("under-character = %q, want %q: fire_bolt records chat() "+
"before it resolves the save", tp.OldCh, Floor)
}
}
// TestFireBoltMissSpeaksEvenForAnM pins the "ch != 'M' ||
// tp->t_disguise == 'M'" guard on C's miss message, which reads as
// though something looking like an 'M' can be missed silently. It
// cannot: ch comes from winat, and winat *is* t_disguise whenever a
// monster stands there (rogue.h 57), so ch == 'M' implies
// t_disguise == 'M' and the condition is always true. The guard is
// vestigial — 'M' was the mimic in earlier Rogues — and a port that
// "tidied" it into a real silence would go quiet where C speaks.
func TestFireBoltMissSpeaksEvenForAnM(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 47)
placeHero(g, Coord{X: 5, Y: corridorY})
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = saveProofLvl
tp.Flags.Clear(Awake)
dir := Coord{X: 1, Y: 0}
g.fireBolt(g.Player.Pos, &dir, boltName)
const want = "the bolt whizzes past the medusa"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if !tp.On(Awake) {
t.Error("the missed medusa was not set running")
}
}

View File

@@ -398,6 +398,36 @@ func (g *RogueGame) identifyTrapCommand() {
}
}
// wizardToggleCommand handles '+': leave wizard mode (command.c
// command). C's arm lives in the main command switch under
// #ifdef MASTER, not in the wizard sub-switch, so it is reachable
// whether or not wizard is set.
//
// The entry half is deliberately not ported. C ran wizard = passwd(),
// which compared a DES-crypted answer against a compiled-in password;
// this port drops that machinery and makes wizard mode configuration
// instead (ROGUE_WIZARD, ARCHITECTURE.md §9). A password check that is
// gone is a password check that can never succeed, so the else arm
// reduces to exactly what C did when the answer was wrong: wizard stays
// off and the game says "sorry". No prompt is shown, since nothing typed
// into it could change the outcome, and the noscore/turn_see(FALSE)
// bookkeeping of C's success branch is unreachable and so is absent.
func (g *RogueGame) wizardToggleCommand() {
g.After = false
if !g.Wizard {
g.msg("sorry")
return
}
g.Wizard = false
// Re-hide the monsters wizard sight was showing: without this there
// is no way back to normal visibility.
g.turnSee(true)
g.msg("not wizard any more")
}
// wizardCommand handles the MASTER debug commands (command.c).
func (g *RogueGame) wizardCommand(ch byte) {
p := &g.Player

37
game/command_test.go Normal file
View File

@@ -0,0 +1,37 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// TestRedrawCommandForcesFullRepaint pins CTRL-R to a forced repaint
// rather than an ordinary refresh. C's arm is "after = FALSE;
// clearok(curscr, TRUE); wrefresh(curscr);" (command.c), and the
// clearok is the command: a diffing refresh compares the new frame
// against the device's record of the old one and sends nothing when they
// agree, which is exactly the situation after some other program has
// scribbled on the terminal. Only the terminal can tell the difference,
// so the test watches the terminal rather than the window contents.
func TestRedrawCommandForcesFullRepaint(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
term, ok := g.scr.term.(*testTerm)
if !ok {
t.Fatal("game terminal is not a testTerm")
}
before := term.repaints
g.After = true
g.dispatch(CTRL('R'))
if term.repaints != before+1 {
t.Errorf("terminal repainted %d times, want %d: CTRL-R did not force "+
"a full redraw", term.repaints-before, 1)
}
if g.After {
t.Error("CTRL-R consumed a turn; C sets after = FALSE")
}
}

248
game/dispatch_test.go Normal file
View File

@@ -0,0 +1,248 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"strings"
"testing"
)
// This file is the standing form of the issue #31 audit: every case
// label in C's command switch against this port's dispatch. It exists
// because a missing dispatch entry is the one porting error that leaves
// no trace at build time. The port is function-by-function, so every C
// function has a Go counterpart and a dropped key dangles nothing and
// fails to compile nowhere; it simply answers "illegal command" the
// first time a player presses it. That is how '+' (issue #11) survived
// until PR #30 found it by accident.
//
// The tables below are transcribed from origin/c-master:command.c, with
// the C line numbers alongside. Read them there with rogue.h 52-53 in
// hand:
//
// #define when break;case
// #define otherwise break;default
//
// The labels are therefore written "when 'x':", and a grep for "case "
// finds ten of the eighty. CTRL is extern.h:113, (c & 037); ESCAPE is
// rogue.h:121, 27.
//
// There are two switches and the split between them is load-bearing. A
// key C answers from the main switch (151-427) must be answered here
// whether or not wizard mode is on. A key C answers only from the
// "if (wizard) switch (ch)" sub-switch (369-423) must not be reachable
// outside it. '+' was a divergence in ordinary play, not just in wizard
// mode, precisely because it is a main-switch key.
//
// The whole sub-switch, and '+' with it, is #ifdef MASTER. This port
// targets the MASTER build: all four #ifdef MASTER sites in command.c
// (67, 128, 317, 368) are ported unconditionally, as is sticks.c 237.
// cMainSwitchTableKeys are the main-switch labels whose arms are a plain
// call, and which this port therefore answers from commandHandlers.
func cMainSwitchTableKeys() []byte {
return []byte{
',', // 153
'!', // 180
'h', 'j', 'k', 'l', 'y', 'u', 'b', 'n', // 181-188 do_move
'H', 'J', 'K', 'L', 'Y', 'U', 'B', 'N', // 189-196 do_run
't', // 241
'q', 'Q', 'i', 'I', 'd', 'r', 'e', 'w', // 258-269
'W', 'T', 'P', 'R', 'o', 'c', // 270-275
'>', '<', '?', '/', 's', 'z', 'D', // 276-286
CTRL('P'), CTRL('R'), // 287-291
'v', // 292
'S', // 295
'.', // 298 rest
' ', // 299 "legal" illegal command
'^', // 300
'+', // 318 (#ifdef MASTER)
Escape, // 339
')', ']', '=', // 354-360
'@', // 361
}
}
// cMainSwitchMultiStepKeys are the main-switch labels whose arms need
// more than a call — C's "goto over" re-dispatch, or the F-to-f
// fallthrough — and which this port therefore answers from dispatchKey's
// own switch rather than from commandHandlers. They are main-switch keys
// all the same, and a player reaches them without wizard mode.
func cMainSwitchMultiStepKeys() []byte {
return []byte{
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'), // 197
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'), // 198
'F', // 214 sets kamikaze, then falls through
'f', // 217
'a', // 246
'm', // 344
}
}
// cWizardSwitchKeys are the labels of the "if (wizard) switch (ch)"
// sub-switch, which sits inside the main switch's otherwise: arm.
func cWizardSwitchKeys() []byte {
return []byte{
'|', // 371
'C', // 372
'$', // 373
CTRL('G'), CTRL('W'), // 374-375
CTRL('D'), CTRL('A'), // 376-377
CTRL('F'), CTRL('T'), // 378-379
CTRL('E'), CTRL('C'), // 380-381
CTRL('X'), // 382
CTRL('~'), // 383
CTRL('I'), // 390
'*', // 419
}
}
// assertNotIllegalCommand fails if the top line reports the key as
// illegal. illcom is the only thing that writes that message, so it is a
// reliable "the dispatch had no arm for this key" probe: it holds
// whether the arm printed its own message, printed nothing, or cleared
// the line on the way out.
func assertNotIllegalCommand(t *testing.T, g *RogueGame, ch byte) {
t.Helper()
// End() upper-cases the first letter, so match from the second.
if line := g.scr.Std.Line(0); strings.Contains(line, "llegal command") {
t.Errorf("dispatching '%s' reached illcom (top line %q); C answers "+
"it from command.c's switch", unctrl(ch), strings.TrimSpace(line))
}
}
// TestCommandHandlersMatchCMainSwitch pins commandHandlers to exactly the
// set of main-switch keys C answers with a plain call. It is checked in
// both directions on purpose. A missing key is the '+' bug. An extra key
// is the same bug mirrored: the most likely way to acquire one is to
// promote a key out of the wizard sub-switch, which would make a MASTER
// debug command available in ordinary play.
func TestCommandHandlersMatchCMainSwitch(t *testing.T) {
t.Parallel()
handlers := newGameData().commandHandlers
keys := cMainSwitchTableKeys()
want := make(map[byte]bool, len(keys))
for _, ch := range keys {
want[ch] = true
if _, ok := handlers[ch]; !ok {
t.Errorf("commandHandlers has no entry for '%s'; C answers it "+
"from the main command.c switch, so this port says "+
"\"illegal command\" where C does not", unctrl(ch))
}
}
for ch := range handlers {
if !want[ch] {
t.Errorf("commandHandlers has an entry for '%s' that C's main "+
"switch does not; if C answers it only under if (wizard), "+
"it belongs in wizardCommand", unctrl(ch))
}
}
if len(want) != len(keys) {
t.Errorf("cMainSwitchTableKeys lists %d keys, %d of them distinct; "+
"a duplicate hides a missing key", len(keys), len(want))
}
}
// TestDispatchKeyAnswersCMultiStepKeys covers the main-switch keys that
// commandHandlers cannot hold, which the set-equality test above cannot
// see. Removing one of these from dispatchKey's switch is just as silent
// as removing a map entry: it falls into the default arm and lands on
// illcom, so that is what is checked.
func TestDispatchKeyAnswersCMultiStepKeys(t *testing.T) {
t.Parallel()
for _, ch := range cMainSwitchMultiStepKeys() {
t.Run(unctrl(ch), func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
// Not a wizard: these are ordinary-play keys, so the default
// arm they must not reach is illcom itself.
g.Wizard = false
g.Options.Terse = false
// 'a' replays the last command; give it one to replay so it
// takes its re-dispatch arm rather than its complaint arm.
g.LastComm = '.'
// F, f and m prompt for a direction. Escape backs out of the
// prompt, which keeps them from moving the hero or starting a
// fight while still proving their arm ran.
setInput(t, g, Escape, Escape, Escape)
next, again := g.dispatchKey(ch)
t.Logf("dispatchKey(%s) = %s, again=%v",
unctrl(ch), unctrl(next), again)
assertNotIllegalCommand(t, g, ch)
})
}
}
// TestDispatchKeyRedispatchesCtrlDirections is the positive half of the
// test above for the eight ctrl-directions: C's arm converts the key to
// its upper-case run command and does "goto over" (command.c 197-213),
// which this port spells as a true second result. Checking the returned
// key, and not merely that illcom was missed, is what would catch the
// arm being present but wired to the wrong direction.
func TestDispatchKeyRedispatchesCtrlDirections(t *testing.T) {
t.Parallel()
for _, ch := range []byte{
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'),
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'),
} {
t.Run(unctrl(ch), func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
// C's "ch += ('A' - CTRL('A'))": ctrl-h becomes 'H'.
wantCh := ch + 'A' - CTRL('A')
next, again := g.dispatchKey(ch)
if !again {
t.Fatalf("dispatchKey(%s) did not ask to re-dispatch; C's "+
"arm ends in goto over", unctrl(ch))
}
if next != wantCh {
t.Errorf("dispatchKey(%s) re-dispatched as %q, want %q",
unctrl(ch), next, wantCh)
}
})
}
}
// TestWizardDispatchAnswersCWizardSwitch is the same guard for the
// MASTER sub-switch, driven through dispatchKey rather than through
// wizardCommand directly so that the routing is covered too: these keys
// must be answered because wizard mode is on, not because they leaked
// into commandHandlers.
func TestWizardDispatchAnswersCWizardSwitch(t *testing.T) {
t.Parallel()
for _, ch := range cWizardSwitchKeys() {
t.Run(unctrl(ch), func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
g.Wizard = true
// ctrl-a is "level--; new_level()", so start deep enough for
// it to have somewhere to go.
g.Depth = 5
// Escape backs out of the item and type prompts that ctrl-w,
// ctrl-~, 'C' and '*' put up. testTerm keeps answering after
// the script runs out, so nothing here can block.
setInput(t, g, Escape, Escape, Escape, Escape)
g.dispatchKey(ch)
assertNotIllegalCommand(t, g, ch)
})
}
}

View File

@@ -194,6 +194,74 @@ func TestZapSlowMonster(t *testing.T) {
}
}
// bizarreSchtick is C's message for a zap that matched no case at all
// (sticks.c do_zap, the "otherwise" arm). Shared by the pair of tests
// below so that the one asserting it appears and the one asserting it
// does not can never drift apart.
const bizarreSchtick = "what a bizarre schtick!"
// TestZapUnhandledWandSaysBizarreSchtick pins the closing arm of C's zap
// switch. Every WS_ kind has a case, so the arm is reachable only for an
// o_which outside the table — here a wand one past the end, the state a
// corrupt save file can still describe. C's message is not gated on the
// wizard flag, only on the MASTER build this port is, so no test setup
// turns it on.
func TestZapUnhandledWandSaysBizarreSchtick(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
wand := malformed(KindWand)
wand.Charges = 3
ch := give(g, wand)
setInput(t, g, ch)
g.Msgs.Huh = ""
g.doZap()
if g.Msgs.Huh != bizarreSchtick {
t.Errorf("message = %q, want %q", g.Msgs.Huh, bizarreSchtick)
}
// C falls out of the switch into o_charges-- from the otherwise arm
// as much as from any other.
if wand.Charges != 2 {
t.Errorf("charges = %d after zapping, want 2", wand.Charges)
}
}
// TestZapWandOfNothingIsSilent is the other half, and the reason the
// message cannot simply be attached to "no handler ran". WS_NOP is a case
// of C's switch in its own right — "when WS_NOP: break;" — so the wand
// that does nothing does it quietly, and only a kind C had no case for
// is bizarre.
func TestZapWandOfNothingIsSilent(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
stick := newObject()
stick.Kind = KindWand
stick.Which = int(WandNothing)
g.fixStick(stick)
ch := give(g, stick)
setInput(t, g, ch)
charges := stick.Charges
g.Msgs.Huh = ""
g.doZap()
if g.Msgs.Huh == bizarreSchtick {
t.Errorf("the wand of nothing said %q; WS_NOP is a case of C's "+
"switch, not an unhandled kind", bizarreSchtick)
}
if stick.Charges != charges-1 {
t.Error("zap did not use a charge")
}
}
func TestParseOpts(t *testing.T) {
t.Parallel()

View File

@@ -1,6 +1,8 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
// ItemLore is the per-game item identity state: the randomized appearance
// names and the seven mutable ObjInfo tables (extern.c/init.c).
type ItemLore struct {
@@ -151,6 +153,54 @@ type RogueGame struct {
data *gameData
}
// Greeting is the line C printed on stdout while the player waited for
// the dungeon to be dug, immediately before initscr() (main.c main). The
// caller prints it before the terminal package takes the screen, which is
// where initscr() sat; there is no trailing newline in either wording,
// because C followed the printf with fflush and let curses have the
// display.
//
// Only the wizard wording is #ifdef MASTER in C, and it carries the
// dungeon number, which is the seed (main.c assigns seed = dnum right
// after choosing dnum). The other wording is unconditional.
//
// The name is C's whoami, resolved the way main.c resolves it: parse_opts
// runs before the printf, so a ROGUEOPTS "name=" setting is what the
// player is greeted by, and the account name is only the fallback. New
// does the same parse a moment later; doing it here too is safe because
// ParseOpts does nothing but assign into the fields it is handed — no
// RNG, no screen — so it cannot disturb the item tables the seed-compat
// golden pins.
//
// The game it parses into is a throwaway, but it is built the way New
// builds the real one, because ParseOpts handles every option and not
// just the one this function reads: "inven=" is matched against the
// inv_t_name[] table and "file=~/..." against the home directory, both
// of which live on the game. A greeting that skimped on them faulted on
// a perfectly legal ROGUEOPTS before the player saw a single character.
func Greeting(params Params) string {
whoami := params.Name
if params.RogueOpts != "" {
opts := &RogueGame{
data: newGameData(),
Whoami: params.Name,
Home: params.Home,
}
opts.ParseOpts(params.RogueOpts)
whoami = opts.Whoami
}
if params.Wizard {
return fmt.Sprintf("Hello %s, welcome to dungeon #%d",
whoami, params.Seed)
}
return fmt.Sprintf(
"Hello %s, just a moment while I dig the dungeon...", whoami)
}
// New builds a game from params, seeds the RNG, and randomizes the item
// appearance tables (the front half of main.c main(); the player roll-up
// and first level arrive with later porting phases).

80
game/greeting_test.go Normal file
View File

@@ -0,0 +1,80 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"strings"
"testing"
)
// TestGreeting pins both wordings of main.c's pre-initscr printf byte for
// byte. The wizard one carries dnum, which main.c has just assigned to
// seed, so it is the seed the player sees. Neither ends in a newline: C
// printed, flushed, and handed the display to curses.
func TestGreeting(t *testing.T) {
t.Parallel()
// The account name main.c copies into whoami when ROGUEOPTS does not
// name the player itself.
const account = "conan"
cases := []struct {
name string
params Params
want string
}{
{
name: "normal",
params: Params{Name: account, Seed: 4242},
want: "Hello conan, just a moment while I dig the dungeon...",
},
{
name: "wizard names the dungeon",
params: Params{Name: account, Seed: 4242, Wizard: true},
want: "Hello conan, welcome to dungeon #4242",
},
{
// parse_opts runs before the printf in main.c, and whoami
// falls back to the account name only when ROGUEOPTS left it
// empty, so the option is what the player is greeted by.
name: "ROGUEOPTS name wins over the account name",
params: Params{
Name: account, Seed: 7, RogueOpts: "name=Rodney",
},
want: "Hello Rodney, just a moment while I dig the dungeon...",
},
{
name: "ROGUEOPTS without a name keeps the account name",
params: Params{
Name: account, Seed: 7, RogueOpts: "terse,fruit=mango",
},
want: "Hello conan, just a moment while I dig the dungeon...",
},
{
// ParseOpts reaches every option, not just name=, and the
// inventory style is matched against a table (options.c
// parse_opts, inv_t_name[]) that lives in the game data. A
// greeting parsed on a game without those tables faulted on
// this ROGUEOPTS before it could print anything at all.
name: "ROGUEOPTS inventory style parses without a fault",
params: Params{
Name: account, Seed: 7, RogueOpts: "inven=slow,name=Rodney",
},
want: "Hello Rodney, just a moment while I dig the dungeon...",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := Greeting(tc.params)
if got != tc.want {
t.Errorf("Greeting() = %q, want %q", got, tc.want)
}
if strings.HasSuffix(got, "\n") {
t.Error("greeting ends in a newline; C's printf did not")
}
})
}
}

View File

@@ -174,12 +174,14 @@ func stepOk(ch byte) bool {
// save runs here, on the game goroutine, before reading again.
//
// What that buys is a snapshot taken by the goroutine that owns the
// state, so it is internally consistent and restorable. It is not
// necessarily a between-commands snapshot: readchar is also reached from
// prompts raised part-way through a command — --More--, askOverwrite,
// getStr, the direction and pack prompts — and mutation has already
// happened by then. See serviceAutoSaveRequest (save.go) for what that
// costs the player.
// state, so it is internally consistent and restorable. It is never a
// between-commands snapshot: readchar is reached from readCommand at the
// top of a turn that has already run its BEFORE daemons and turnUpkeep,
// and from prompts raised part-way through a command — --More--,
// askOverwrite, getStr, the direction and pack prompts — by which point
// the command has mutated state as well. See serviceAutoSaveRequest
// (save.go) for the full statement of what the handoff guarantees and
// what it costs the player.
func (g *RogueGame) readchar() byte {
for {
ch, ok := g.scr.term.ReadChar()

751
game/rings_test.go Normal file
View 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.

View File

@@ -670,7 +670,7 @@ func (g *RogueGame) saveFile(path string) error {
tmp := f.Name()
writeErr := encodeSnapshot(f, g.snapshot())
writeErr := writeSnapshotFile(f, g.snapshot())
if writeErr != nil {
_ = os.Remove(tmp) // never leave a half-written file behind
@@ -687,11 +687,13 @@ func (g *RogueGame) saveFile(path string) error {
return nil
}
// encodeSnapshot encodes the snapshot into an open temporary file and
// closes it, leaving it read-only as the C game's saves were (save.c
// save_file). It never removes the file: its caller owns the cleanup, so
// that one place decides what happens to a failed write.
func encodeSnapshot(f *os.File, st *SaveState) error {
// writeSnapshotFile writes the snapshot into an open temporary file: it
// encodes, fsyncs so the bytes reach the disk before the caller renames
// the file into place, chmods it read-only as the C game's saves were
// (save.c save_file), and closes it. It never removes the file: its
// caller owns the cleanup, so that one place decides what happens to a
// failed write.
func writeSnapshotFile(f *os.File, st *SaveState) error {
encErr := gob.NewEncoder(f).Encode(st)
if encErr == nil {
encErr = f.Sync()
@@ -801,11 +803,21 @@ func (g *RogueGame) AutoSaveOnSignal(timeout time.Duration) bool {
// fresh BEFORE pass on top of the one already in the snapshot. That
// second BEFORE pass is not free: rollwand, a live Before daemon once
// swander has fired, ticks again and draws from the RNG every fourth
// tick, and any Before fuse is decremented again. The result is still a
// coherent game state, one turn's worth of effects off — strictly better
// than the torn encode this replaced, and the cost of being able to save
// a player whose line dropped mid-prompt, or who is away in a shell, at
// all.
// tick, and any Before fuse is decremented again.
//
// Not every consequence of that pass is shared by both, though. visuals
// returns immediately unless g.After, and After is part of the snapshot,
// so DVisuals never re-ticks after a shell-escape save: shell sets
// g.After = false as its first statement, before it parks. After a
// readchar save it usually does re-tick, because turnUpkeep sets
// g.After = true just before the top-of-turn read; the exception is a
// handler that clears After before prompting, as identifyTrapCommand
// does ahead of promptDirection.
//
// The result is still a coherent game state, one turn's worth of effects
// off — strictly better than the torn encode this replaced, and the cost
// of being able to save a player whose line dropped mid-prompt, or who
// is away in a shell, at all.
func (g *RogueGame) serviceAutoSaveRequest() {
select {
case req := <-g.sigSave:

View File

@@ -23,6 +23,15 @@ type Terminal interface {
// the one Terminal method called from another goroutine, so an
// implementation must be safe to call concurrently with ReadChar.
Interrupt()
// Repaint forces the device to redraw every cell it is showing, the
// redraw command's whole point (curses clearok(curscr, TRUE) followed
// by wrefresh(curscr)). Render cannot stand in for it: a device that
// diffs against its own idea of what is on screen will do nothing at
// all when the screen has been corrupted by something else's output,
// which is the case the player types CTRL-R for. It repaints what was
// last rendered — C repainted curscr, not stdscr — so it neither
// needs nor takes a window.
Repaint()
// Fini restores the device to its pre-game state (curses endwin). The
// game calls it on its way out, since one game run is one process.
Fini()
@@ -223,6 +232,14 @@ func (s *Screen) Refresh() {
}
}
// Repaint forces the device to redraw everything it is showing, if there
// is a device (curses clearok(curscr, TRUE) + wrefresh(curscr)).
func (s *Screen) Repaint() {
if s.term != nil {
s.term.Repaint()
}
}
// Fini restores the terminal device, if there is one (curses endwin).
func (s *Screen) Fini() {
if s.term != nil {
@@ -263,3 +280,4 @@ func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
func (g *RogueGame) clear() { g.scr.Std.Clear() }
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
func (g *RogueGame) refresh() { g.scr.Refresh() }
func (g *RogueGame) repaint() { g.scr.Repaint() }

View File

@@ -31,14 +31,30 @@ func (g *RogueGame) doZap() {
return
}
// A malformed Which yields no handler, which is exactly what C did in a
// non-MASTER build: its do_zap switch matched no case, fell out, and
// still ran o_charges--. (The MASTER-only "what a bizarre schtick!"
// message for that arm is issue #13, not this bounds fix.)
if h := g.data.zapHandler(obj); h != nil {
// C's switch has a case for every one of the 14 WS_ kinds, so its
// closing "otherwise: msg(...)" arm is reachable only for an o_which
// outside the table — the malformed objects hasValidWhich screens
// for, which is why no handler and a legal Which can only mean WS_NOP.
//
// The message is under #ifdef MASTER, not under a wizard test: C
// printed it for every player of a MASTER build, which is the build
// this port is (see the '+' command, issue #11). Do not gate it on
// g.Wizard.
//
// WS_NOP is a case of its own ("when WS_NOP: break;"): the wand that
// deliberately does nothing says nothing either. All three arms fall
// out of the switch into o_charges--, so even the bizarre schtick
// costs a charge.
h := g.data.zapHandler(obj)
switch {
case h != nil:
if !h(g, obj) {
return // the zap aborted; no charge is used
}
case obj.hasValidWhich(): // WS_NOP
default:
g.msg("what a bizarre schtick!")
}
obj.Charges--

874
game/sticks_test.go Normal file
View File

@@ -0,0 +1,874 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"slices"
"strings"
"testing"
)
// The zap and bolt tests need a map they can reason about: real levels
// put their rooms wherever rooms.c felt like, and sticks.c's geometry —
// which square a bolt bounces off, which room a drain reaches — only
// means anything against known walls, a known door, and a known passage
// number. mkCarvedGame lays out two rooms joined by one corridor, using
// the generator's own drawRoom so the wall characters (including the
// '-' corners horiz() paints over vert()'s '|') are what a real level
// would have:
//
// x: 1 20 40 59
// y=1 -------------------- ------------------
// |..................| |................|
// y=4 |..................+########+................|
// |..................| |................|
// y=8 -------------------- ------------------
const (
carvedWidth = 20 // room width, both walls included
carvedHeight = 8 // room height, both walls included
roomAX = 1 // left wall of the west room
roomBX = 40 // left wall of the east room
carvedTopY = 1 // top wall of both rooms
corridorY = 4 // row the corridor and doors run on
doorAX = roomAX + carvedWidth - 1 // east wall of the west room
carvedPass = 2 // passage number of the corridor
)
// saveProofLvl makes save_throw(VS_MAGIC) succeed on every roll, so a
// test can select the "it saved" arm without touching the RNG: C's
// threshold is 14 + VS_MAGIC - lvl/2, which at level 40 is -3, and
// roll(1,20) always clears that.
const saveProofLvl = 40
// mkCarvedGame builds a game on the hand-carved level drawn above, with
// the hero standing in the south-east corner of the west room — off
// every row and column the bolt tests fire along.
func mkCarvedGame(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := New(Params{Seed: seed, Term: &testTerm{}})
for i := range g.Level.Places {
g.Level.Places[i] = Place{Ch: ' ', Flags: FReal}
}
for i, x := range [...]int{roomAX, roomBX} {
rp := &g.Level.Rooms[i]
*rp = Room{
Pos: Coord{X: x, Y: carvedTopY},
Max: Coord{X: carvedWidth, Y: carvedHeight},
}
g.drawRoom(rp)
}
for i := 2; i < MaxRooms; i++ {
g.Level.Rooms[i].Flags = Gone // rooms that are not there
}
for x := doorAX + 1; x < roomBX; x++ {
pp := g.Level.At(corridorY, x)
pp.Ch = Passage
pp.Flags = FReal | FPassage | carvedPass
}
// Doors carry the passage number in their low bits but not F_PASS,
// exactly as passages.c numpass leaves them; roomin therefore reports
// the room a door belongs to, and drain's corp lookup finds the
// passage behind it.
for _, x := range [...]int{doorAX, roomBX} {
pp := g.Level.At(corridorY, x)
pp.Ch = Door
pp.Flags = FReal | carvedPass
}
placeHero(g, Coord{X: roomAX + 17, Y: carvedTopY + 6})
return g
}
// placeHero moves the hero and keeps proom, oldpos and oldrp in step,
// the way move.c and misc.c look do; a --More-- prompt redraws through
// look, which reads all three.
func placeHero(g *RogueGame, pos Coord) {
g.Player.Pos = pos
g.Player.Room = g.roomIn(pos)
g.Oldpos = pos
g.Oldrp = g.Player.Room
}
// putMonster drops a monster of the given letter on a carved-level spot.
func putMonster(g *RogueGame, typ byte, pos Coord) *Monster {
tp := &Monster{}
g.newMonster(tp, typ, pos)
return tp
}
// pinRng rewinds the generator to a state whose next draw is exactly
// want, so tests can choose a save-throw outcome or a polymorph letter
// without assuming anything about the generator itself: the wanted
// value is found by running the real Rng, not by predicting it.
func pinRng(t *testing.T, g *RogueGame, draw func(*Rng) int, want int) {
t.Helper()
for s := int32(1); s < 100000; s++ {
probe := Rng{Seed: s}
if draw(&probe) == want {
g.Rng.Seed = s
return
}
}
t.Fatalf("no seed found whose next draw is %d", want)
}
// d20 is the save_throw draw (monsters.c save_throw: roll(1, 20)).
func d20(r *Rng) int { return r.Roll(1, 20) }
// zapWand builds a wand of the given kind with charges to spare.
func zapWand(kind WandKind) *Object {
obj := newObject()
obj.Kind = KindWand
obj.Which = int(kind)
obj.Charges = 5
return obj
}
// TestZapLightLightsTheRoom covers the WS_LIGHT arm: the room loses
// ISDARK, the wand identifies itself, and the message is C's two-part
// one (sticks.c 71-89).
func TestZapLightLightsTheRoom(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 11)
g.Player.Room.Flags.Set(Dark)
g.zapLight(zapWand(WandLight))
if g.Player.Room.Flags.Has(Dark) {
t.Error("the room is still dark after a wand of light")
}
if !g.Items.Sticks[WandLight].Know {
t.Error("the wand of light did not identify itself")
}
const want = "the room is lit by a shimmering blue light"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
}
// TestZapLightInPassageFades covers the ISGONE arm: a corridor is not a
// room, so nothing is lit and the wand still becomes known.
func TestZapLightInPassageFades(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 12)
placeHero(g, Coord{X: doorAX + 3, Y: corridorY})
g.Level.Rooms[0].Flags.Set(Dark)
g.zapLight(zapWand(WandLight))
if !g.Player.Room.Flags.Has(Gone) {
t.Fatal("the hero is not in a passage; the test set-up is wrong")
}
const want = "the corridor glows and then fades"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if !g.Items.Sticks[WandLight].Know {
t.Error("the wand of light did not identify itself in a corridor")
}
if !g.Level.Rooms[0].Flags.Has(Dark) {
t.Error("zapping in a corridor lit a room anyway")
}
}
// TestZapDrainLifeTooWeakKeepsCharge covers C's early return: under two
// hit points the zap is refused, and because C returns before the
// switch falls out, o_charges-- never runs.
func TestZapDrainLifeTooWeakKeepsCharge(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 13)
g.Player.Stats.HP = 1
wand := zapWand(WandDrainLife)
ch := give(g, wand)
setInput(t, g, ch)
g.doZap()
const want = "you are too weak to use it"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if wand.Charges != 5 {
t.Errorf("charges = %d, want 5: the refused zap must not cost one",
wand.Charges)
}
if g.Player.Stats.HP != 1 {
t.Errorf("hit points = %d, want 1", g.Player.Stats.HP)
}
}
// TestDrainSplitsHitPoints covers sticks.c drain: the hero loses half
// his hit points and the drainees each lose that half divided by their
// number — monsters out of reach lose nothing.
func TestDrainSplitsHitPoints(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 14)
placeHero(g, Coord{X: 5, Y: 3})
g.Player.Stats.HP = 20
near := [2]*Monster{
putMonster(g, 'Z', Coord{X: 7, Y: 3}),
putMonster(g, 'Z', Coord{X: 9, Y: 5}),
}
far := putMonster(g, 'Z', Coord{X: roomBX + 5, Y: 3})
for _, tp := range []*Monster{near[0], near[1], far} {
tp.Stats.HP = 100
}
g.drain()
if g.Player.Stats.HP != 10 {
t.Errorf("hero hit points = %d, want 10", g.Player.Stats.HP)
}
// 10 hit points spread over two drainees is 5 apiece.
for i, tp := range near {
if tp.Stats.HP != 95 {
t.Errorf("drainee %d hit points = %d, want 95", i, tp.Stats.HP)
}
}
if far.Stats.HP != 100 {
t.Errorf("the monster in the other room lost %d hit points",
100-far.Stats.HP)
}
}
// TestDrainWithNoTargetsCostsNothing covers the cnt == 0 arm, which
// returns before pstats.s_hpt is halved.
func TestDrainWithNoTargetsCostsNothing(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 15)
placeHero(g, Coord{X: 5, Y: 3})
g.Player.Stats.HP = 20
g.drain()
const want = "you have a tingling feeling"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
if g.Player.Stats.HP != 20 {
t.Errorf("hero hit points = %d, want 20: a drain that found nobody "+
"returns before halving them", g.Player.Stats.HP)
}
}
// TestDrainKillsWeakMonster covers the other arm of drain's zot loop: a
// drainee whose share of the hit points finishes it is killed outright.
func TestDrainKillsWeakMonster(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 29)
placeHero(g, Coord{X: 5, Y: 3})
g.Player.Stats.HP = 20
tp := putMonster(g, 'Z', Coord{X: 7, Y: 3})
tp.Stats.HP = 3 // less than the ten points it is about to take
g.drain()
if len(g.Level.Monsters) != 0 {
t.Error("the drained monster is still on the level")
}
if g.Level.MonsterAt(7, 3) != nil {
t.Error("the drained monster is still on the map")
}
const want = "you have defeated the zombie"
if g.Msgs.Huh != want {
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
}
}
// TestZapSpeedTogglesHasteAndSlow covers both WS_HASTE_M and WS_SLOW_M
// in both directions: C cancels the opposite condition when it is
// already on, and only otherwise applies its own.
func TestZapSpeedTogglesHasteAndSlow(t *testing.T) {
t.Parallel()
tests := []struct {
name string
kind WandKind
start CreatureFlags
wantHasted bool
wantSlowed bool
wantTurn bool
}{
{name: "haste a monster", kind: WandHasteMonster, wantHasted: true},
{
name: "haste cancels a slow",
kind: WandHasteMonster,
start: Slowed,
},
{
name: "slow a monster",
kind: WandSlowMonster,
wantSlowed: true,
wantTurn: true,
},
{
name: "slow cancels a haste",
kind: WandSlowMonster,
start: Hasted,
wantTurn: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 30)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
tp.Flags.Clear(Hasted | Slowed)
tp.Flags.Set(tt.start)
tp.Turn = false // only the slow arm sets t_turn
g.zapSpeed(zapWand(tt.kind))
if tp.On(Hasted) != tt.wantHasted {
t.Errorf("hasted = %v, want %v", tp.On(Hasted), tt.wantHasted)
}
if tp.On(Slowed) != tt.wantSlowed {
t.Errorf("slowed = %v, want %v", tp.On(Slowed), tt.wantSlowed)
}
if tp.Turn != tt.wantTurn {
t.Errorf("turn = %v, want %v", tp.Turn, tt.wantTurn)
}
if !tp.On(Awake) {
t.Error("the zapped monster was not set running")
}
})
}
}
// TestDrainReaches pins the three-clause drainee test of sticks.c drain
// one clause at a time: the hero's own room, the passage behind the door
// he stands on (corp), and — only when he is in a passage — a door of
// that same passage.
func TestDrainReaches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
heroPos Coord
monstPos Coord
want bool
}{
{
name: "same room",
heroPos: Coord{X: 5, Y: 3},
monstPos: Coord{X: 9, Y: 6},
want: true,
},
{
name: "different room",
heroPos: Coord{X: 5, Y: 3},
monstPos: Coord{X: roomBX + 5, Y: 3},
want: false,
},
{
name: "hero on a door reaches into that passage",
heroPos: Coord{X: doorAX, Y: corridorY},
monstPos: Coord{X: doorAX + 4, Y: corridorY},
want: true,
},
{
name: "hero in the passage reaches its doors",
heroPos: Coord{X: doorAX + 4, Y: corridorY},
monstPos: Coord{X: roomBX, Y: corridorY},
want: true,
},
{
name: "hero in the passage does not reach into a room",
heroPos: Coord{X: doorAX + 4, Y: corridorY},
monstPos: Coord{X: roomBX + 5, Y: 3},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 16)
placeHero(g, tt.heroPos)
tp := putMonster(g, 'Z', tt.monstPos)
var corp *Room
if g.Level.Char(tt.heroPos.Y, tt.heroPos.X) == Door {
corp = &g.Level.Passages[*g.Level.FlagsAt(
tt.heroPos.Y, tt.heroPos.X)&FPassNum]
}
inpass := g.Player.Room.Flags.Has(Gone)
if got := g.drainReaches(tp, corp, inpass); got != tt.want {
t.Errorf("drainReaches = %v, want %v (inpass=%v corp=%v)",
got, tt.want, inpass, corp != nil)
}
})
}
}
// TestZapInvisibilityHidesMonster covers the WS_INVIS arm.
func TestZapInvisibilityHidesMonster(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 17)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
g.zapInvisibility(zapWand(WandInvisibility))
if !tp.On(Invisible) {
t.Error("the zapped monster is still visible")
}
}
// TestZapVictimReleasesFlytrap covers the shared preamble of C's
// invisibility family: the flytrap holding the hero lets go the moment
// the ray reaches it, whichever of those wands was zapped.
func TestZapVictimReleasesFlytrap(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 18)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
g.Player.Flags.Set(Held)
tp := putMonster(g, 'F', Coord{X: 6, Y: corridorY})
g.zapInvisibility(zapWand(WandInvisibility))
if g.Player.On(Held) {
t.Error("the flytrap still holds the hero after the zap")
}
if !tp.On(Invisible) {
t.Error("the flytrap was not made invisible")
}
}
// TestZapPolymorphReplacesMonster covers the WS_POLYMORPH arm and its
// detach/re-attach dance: the creature keeps its identity (the same
// THING, its pack, and the character it is standing on) but becomes a
// different monster, listed once and standing where it stood.
func TestZapPolymorphReplacesMonster(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 19)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
pos := Coord{X: 8, Y: corridorY}
tp := putMonster(g, 'K', pos)
loot := newObject()
loot.Kind = KindPotion
tp.Pack = []*Object{loot}
tp.OldCh = Stairs // it is standing on the staircase
const want = 'T'
pinRng(t, g, func(r *Rng) int { return r.Rnd(26) }, int(want-'A'))
g.zapPolymorph(zapWand(WandPolymorph))
if tp.Type != want || tp.Disguise != want {
t.Errorf("monster is %q/%q after polymorph, want %q",
tp.Type, tp.Disguise, want)
}
if tp.Stats.Lvl != g.Monsters[want-'A'].Stats.Lvl {
t.Errorf("level = %d, want the troll's %d: new_monster did not "+
"re-roll the stats", tp.Stats.Lvl, g.Monsters[want-'A'].Stats.Lvl)
}
if len(tp.Pack) != 1 || tp.Pack[0] != loot {
t.Error("polymorph lost the monster's pack")
}
if tp.OldCh != Stairs {
t.Errorf("under-character = %q, want %q", tp.OldCh, Stairs)
}
if g.Level.MonsterAt(pos.Y, pos.X) != tp || tp.Pos != pos {
t.Error("the polymorphed monster is not where it stood")
}
if n := len(g.Level.Monsters); n != 1 {
t.Errorf("monster list holds %d entries, want 1: detach and "+
"new_monster's attach must balance", n)
}
if !g.Items.Sticks[WandPolymorph].Know {
t.Error("a polymorph the hero watched did not identify the wand")
}
}
// TestZapPolymorphClobbersDelta pins a C quirk the port keeps: do_zap
// reuses the global delta as scratch for new_monster's coordinate, so
// the zap direction is gone by the time the arm returns.
func TestZapPolymorphClobbersDelta(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 20)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
pos := Coord{X: 8, Y: corridorY}
putMonster(g, 'K', pos)
g.zapPolymorph(zapWand(WandPolymorph))
if g.Delta != pos {
t.Errorf("delta = %v after polymorph, want the victim's %v",
g.Delta, pos)
}
}
// TestZapCancellationClearsSpecials covers the WS_CANCEL arm. CANHUH is
// set on the player and never on a monster in C (only scrolls.c sets
// it), so the test puts it on by hand: the clear is written to take both
// bits and the port must keep doing so.
func TestZapCancellationClearsSpecials(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 21)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
tp.Flags.Set(Invisible | CanConfuse)
g.zapCancellation(zapWand(WandCancellation))
if !tp.On(Cancelled) {
t.Error("the monster was not cancelled")
}
if tp.On(Invisible) {
t.Error("cancellation left the monster invisible")
}
if tp.On(CanConfuse) {
t.Error("cancellation left the monster able to confuse")
}
// t_disguise = t_type is an identity for every monster a zap ray can
// actually stop on: the one disguised kind, the xeroc, looks like an
// item, and step_ok is true for item characters, so the ray walks
// straight past it. Pinned anyway, because C assigns it.
if tp.Disguise != tp.Type {
t.Errorf("disguise = %q, want %q", tp.Disguise, tp.Type)
}
}
// TestZapTeleportToPullsMonsterIn covers WS_TELTO: the victim lands on
// hero + delta, which is the square next to the hero along the ray.
func TestZapTeleportToPullsMonsterIn(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 22)
hero := Coord{X: 5, Y: corridorY}
placeHero(g, hero)
g.Delta = Coord{X: 1, Y: 0}
from := Coord{X: 8, Y: corridorY}
tp := putMonster(g, 'Z', from)
g.zapTeleport(zapWand(WandTeleportTo))
want := Coord{X: hero.X + 1, Y: hero.Y}
if tp.Pos != want {
t.Errorf("monster at %v after teleport-to, want %v", tp.Pos, want)
}
if g.Level.MonsterAt(want.Y, want.X) != tp {
t.Error("the map does not have the monster at its new spot")
}
if g.Level.MonsterAt(from.Y, from.X) != nil {
t.Error("the monster is still on the map where it came from")
}
if tp.Dest != &g.Player.Pos {
t.Error("the teleported monster is not chasing the hero")
}
if !tp.On(Awake) {
t.Error("the teleported monster was not woken")
}
}
// TestZapTeleportAwayMovesMonsterOff covers WS_TELAWAY, whose C loop
// re-draws until the spot is not the hero's own.
func TestZapTeleportAwayMovesMonsterOff(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 23)
hero := Coord{X: 5, Y: corridorY}
placeHero(g, hero)
g.Delta = Coord{X: 1, Y: 0}
from := Coord{X: 8, Y: corridorY}
tp := putMonster(g, 'Z', from)
g.zapTeleport(zapWand(WandTeleportAway))
if tp.Pos == from {
t.Error("teleport away did not move the monster")
}
if tp.Pos == hero {
t.Error("teleport away dropped the monster onto the hero")
}
if g.Level.Char(tp.Pos.Y, tp.Pos.X) != Floor {
t.Errorf("monster landed on %q, want floor",
g.Level.Char(tp.Pos.Y, tp.Pos.X))
}
if g.Level.MonsterAt(from.Y, from.X) != nil {
t.Error("the monster is still on the map where it came from")
}
}
// vanishMsg is what C says when the missile finds nobody to hit, with
// the original spelling of "missile" intact (sticks.c 191).
//
//nolint:misspell // C's spelling, kept faithfully
const vanishMsg = "the missle vanishes with a puff of smoke"
// TestZapMagicMissile covers WS_MISSILE both ways: a victim that saves
// gets C's puff-of-smoke message and no damage, one that does not is
// hit by a bolt whose o_hplus of 100 cannot miss.
func TestZapMagicMissile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lvl int
wantMsg bool
}{
{name: "victim saves", lvl: saveProofLvl, wantMsg: true},
{name: "victim is hit", lvl: 1, wantMsg: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 24)
placeHero(g, Coord{X: 5, Y: corridorY})
g.Delta = Coord{X: 1, Y: 0}
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
tp.Stats.Lvl = tt.lvl
tp.Stats.HP = 500
pinRng(t, g, d20, 1) // the lowest save throw there is
g.zapMagicMissile(zapWand(WandMagicMissile))
if got := g.Msgs.Huh == vanishMsg; got != tt.wantMsg {
t.Errorf("message = %q, want vanish = %v", g.Msgs.Huh, tt.wantMsg)
}
if hurt := tp.Stats.HP < 500; hurt == tt.wantMsg {
t.Errorf("hit points = %d, want damage = %v",
tp.Stats.HP, !tt.wantMsg)
}
if !g.Items.Sticks[WandMagicMissile].Know {
t.Error("the magic missile wand did not identify itself")
}
})
}
}
// TestFixStickDamage covers the strcmp against ws_type: a staff swings
// for 2x3, everything else for 1x1, and both hurl for 1x1.
func TestFixStickDamage(t *testing.T) {
t.Parallel()
tests := []struct {
material string
want string
}{
{material: staffName, want: "2x3"},
{material: wandName, want: "1x1"},
}
for _, tt := range tests {
t.Run(tt.material, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 25)
g.Items.WandType[WandCold] = tt.material
cur := newObject()
cur.Kind = KindWand
cur.Which = int(WandCold)
g.fixStick(cur)
if !slices.Equal(cur.Damage, dice(tt.want)) {
t.Errorf("damage = %v, want %v", cur.Damage, tt.want)
}
if !slices.Equal(cur.HurlDmg, dice("1x1")) {
t.Errorf("hurl damage = %v, want 1x1", cur.HurlDmg)
}
})
}
}
// TestFixStickCharges covers the charge switch. C is rnd(10)+10 for the
// wand of light and rnd(5)+3 for everything else, so both ends of both
// ranges must show up over enough draws and nothing outside them ever.
func TestFixStickCharges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
kind WandKind
lo, hi int
}{
{name: "light", kind: WandLight, lo: 10, hi: 19},
{name: "other", kind: WandCold, lo: 3, hi: 7},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 26)
lo, hi := 1<<30, -1
for range 500 {
cur := newObject()
cur.Kind = KindWand
cur.Which = int(tt.kind)
g.fixStick(cur)
lo = min(lo, cur.Charges)
hi = max(hi, cur.Charges)
}
if lo != tt.lo || hi != tt.hi {
t.Errorf("charges ranged over %d..%d, want %d..%d",
lo, hi, tt.lo, tt.hi)
}
})
}
}
// TestChargeStr covers sticks.c charge_str: nothing at all until the
// stick is known, then the terse or verbose bracket.
func TestChargeStr(t *testing.T) {
t.Parallel()
tests := []struct {
name string
known bool
terse bool
want string
}{
{name: "unknown", known: false, terse: false, want: ""},
{name: "unknown and terse", known: false, terse: true, want: ""},
{name: "known", known: true, terse: false, want: " [7 charges]"},
{name: "known and terse", known: true, terse: true, want: " [7]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 27)
g.Options.Terse = tt.terse
obj := zapWand(WandCold)
obj.Charges = 7
if tt.known {
obj.Flags.Set(Known)
}
if got := chargeStr(g, obj); got != tt.want {
t.Errorf("chargeStr = %q, want %q", got, tt.want)
}
})
}
}
// TestZapBoltNames covers the name each of the three bolt wands fires
// under (sticks.c 225-231), read back out of the weapon table entry
// fire_bolt overwrites and out of the bounce message.
func TestZapBoltNames(t *testing.T) {
t.Parallel()
tests := []struct {
kind WandKind
want string
}{
{kind: WandLightning, want: boltName},
{kind: WandFire, want: flameName},
{kind: WandCold, want: iceName},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
t.Parallel()
g := mkCarvedGame(t, 28)
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
g.Player.Stats.Lvl = saveProofLvl // never hurt by the rebound
g.Delta = Coord{X: -1, Y: 0} // straight at the west wall
g.zapBolt(zapWand(tt.kind))
if got := g.Items.Weapons[WeaponFlame].Name; got != tt.want {
t.Errorf("weapon name = %q, want %q", got, tt.want)
}
if !strings.Contains(g.Msgs.Huh, tt.want) {
t.Errorf("message = %q, want it to name the %q",
g.Msgs.Huh, tt.want)
}
if !g.Items.Sticks[tt.kind].Know {
t.Error("the bolt wand did not identify itself")
}
})
}
}

View File

@@ -785,7 +785,10 @@ func newGameData() *gameData {
},
CTRL('R'): func(g *RogueGame) {
g.After = false
g.refresh()
// C forces a full repaint here (clearok + wrefresh on
// curscr), not the diffing refresh: the command exists
// for screens the game's own record no longer matches.
g.repaint()
},
'v': func(g *RogueGame) {
g.After = false
@@ -802,6 +805,7 @@ func newGameData() *gameData {
g.After = false // "legal" illegal command
},
'^': (*RogueGame).identifyTrapCommand,
'+': (*RogueGame).wizardToggleCommand,
Escape: func(g *RogueGame) {
g.DoorStop = false
g.Count = 0

View File

@@ -8,10 +8,18 @@ type testTerm struct {
input []byte
pos int
tick int
// repaints counts forced full redraws. Rendering is a no-op here, so
// counting is the only way a headless test can tell that CTRL-R asked
// for a repaint rather than an ordinary refresh — the two are
// indistinguishable in the window contents, which is the whole reason
// the bug this replaces went unnoticed.
repaints int
}
func (t *testTerm) Render(*Window) {}
func (t *testTerm) Repaint() { t.repaints++ }
func (t *testTerm) Fini() {}
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.

1140
game/traps_test.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

37
script/lint Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/sh
# script/lint: lint in docker. golangci-lint is never installed on the host.
#
# Traps, each of which yields a green run over an unlinted or partly linted
# tree:
#
# 1. --target and --no-cache-filter must both stay, and $stage must match
# the stage name in Dockerfile.lint. BuildKit ignores --no-cache-filter
# when no stage matches its argument, serving the lint layer from cache
# without a word; --target rejects a name that is not in the file, which
# is what makes the single $stage safe.
#
# 2. --target checks that the stage exists, not that it is the stage
# running golangci-lint, and it halts the build there. Moving the lint
# step to another stage, or adding a stage after it, is not caught.
#
# 3. .dockerignore decides what reaches the container, and only what
# reaches it is linted. Excluding a self-contained Go file drops it from
# the lint silently. Never exclude Go sources, go.mod/go.sum or
# .golangci.yml.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the stage name in Dockerfile.lint.
stage=lint
main() {
cd "$ROOT"
docker build \
--target "$stage" \
--no-cache-filter="$stage" \
--output=type=cacheonly \
-f Dockerfile.lint .
}
main "$@"

View File

@@ -78,6 +78,16 @@ func (t *Tcell) Render(w *game.Window) {
t.screen.Show()
}
// Repaint redraws the whole physical screen from tcell's content buffer
// — which holds what Render last blitted, so this is C's clearok(curscr,
// TRUE) + wrefresh(curscr) (command.c, the CTRL('R') arm) rather than a
// fresh draw of stdscr. Sync throws away tcell's record of what the
// terminal is showing, so unlike Show it repaints cells it believes are
// already correct, which is what makes it fix a corrupted screen.
func (t *Tcell) Repaint() {
t.screen.Sync()
}
// ReadChar blocks for the next key, translated to the byte codes the C
// game reads: arrows become hjkl, control keys their C0 codes. ok is
// false when Interrupt woke the read instead of a key arriving.