32 Commits

Author SHA1 Message Date
0bef53bd11 Merge next: make build and make cover targets (closes #19, closes #17)
Adds build/cover/cover-html targets writing to a gitignored build/, and removes the last raw go invocation from README.
2026-08-10 15:58:35 +02:00
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
8413c0c7ba Merge next: trim the lint-gate comments to the traps (closes #44)
Comment-only: executable content of script/lint, Dockerfile.lint and .dockerignore is byte-identical.
2026-08-10 15:41:16 +02: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
e1bf46b241 Merge sig-leave (restore the terminal on INT/QUIT) 2026-08-09 08:19:01 +02:00
dfb34be1c4 fix: restore the terminal on SIGINT/SIGQUIT (closes #12)
The port installed handlers for SIGHUP and SIGTERM only, so SIGINT and
SIGQUIT killed the process with tcell still holding the tty and dropped
the user into a shell with no echo and a scrambled screen. All four
signals now go to one os/signal channel read by one goroutine, and every
path calls Terminal.Fini before os.Exit(0) -- C's leave(), "leave
quickly but curteously" (main.c).

The handlers are installed immediately after term.New(), the call that
raises raw mode, rather than after the game exists. Everything between
those two points ran raw with no handler at all: the save-restore path,
and -d's DeathDemo(), which never returns -- death() blocks in
waitFor('\n') (game/rip.go) -- so a kill -INT during the death demo left
exactly the scrambled terminal this fixes. The game is handed to the
handler afterwards through pendingSaver, whose AutoSave is a no-op until
then: a signal before the game is built restores the terminal and exits
with nothing to save. SIGHUP/SIGTERM autosave on the play path is
unchanged.

The save decision, written into the savesOnSignal comment: SIGHUP and
SIGTERM keep autosaving; SIGINT and SIGQUIT restore and exit without
saving. No path in C saves on INT or QUIT (leave() is endwin-and-exit,
quit() confirms/scores/exits, endit() goes through fatal(), and
save.c auto_save is reserved for HUP/TERM), the semantics agree
(involuntary teardown is worth rescuing a game from; a deliberate "stop
now" must not become a one-keystroke checkpoint against an anti-save-scum
save discipline), and it is the safe choice, since AutoSave gob-encodes
live state the main goroutine is still mutating after removing the old
file.

One reader of one signal is also what closes the corruption window: a
second signal arriving while a SIGHUP's AutoSave is mid-write stays
unread in the buffer instead of exiting out from under the writer.

cmd/rogue/main_test.go pins the membership of handledSignals() itself --
the rest of the file iterates that set, so without that assertion the
suite would pass against a set that had lost SIGINT and SIGQUIT again,
which is the regression this issue exists to prevent -- and covers the
ordering per signal, the save/no-save split (driven from the expectation
table so every entry is read), the mid-save second-signal interleaving,
the pre-game pendingSaver window, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM
delivered to the test process through the same notifySignals wiring the
game uses.

Two premises behind the report were wrong and are recorded rather than
silently fixed: 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 and 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()
described above. Nothing is raw before term.New(), so there was never
anything to cover there.

ARCHITECTURE.md section 9 gains rows for SIGTSTP/tstp() (deliberately
dropped: raw mode means Ctrl-Z cannot reach the process, suspending the
screen from the signal goroutine is a logical race against the drawing
goroutine -- not a data race, since tcell guards Suspend/Resume and Fini
alike -- and C armed tstp only after a successful restore(); the ! shell
escape covers the need), for SIGINT not routing to the interactive
quit() prompt, and for auto_save on the fault signals. Section 5.3's
claim that tcell handles SIGTSTP was false -- tcell registers only
SIGWINCH -- and is corrected, and its "every path restores the terminal"
claim now holds because of the install ordering above.
2026-08-09 06:07:22 +00:00
30 changed files with 7463 additions and 115 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 *.log
*.out *.out
*.test *.test
/build/
/rogue /rogue

View File

@@ -1485,7 +1485,11 @@ needed function pointers for — so it is also what the save format stores.
// The real game uses term.Tcell; tests use a scripted testTerm. // The real game uses term.Tcell; tests use a scripted testTerm.
type Terminal interface { type Terminal interface {
Render(w *Window) // blit a window to the device Render(w *Window) // blit a window to the device
ReadChar() byte // event loop → C char codes (arrows→hjkl, ^C→quit) 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 Fini() // restore the device (curses endwin) on exit
} }
@@ -1505,16 +1509,97 @@ 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 (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) Refresh() // blit stdscr to the device
func (s *Screen) RefreshWin(w *Window) // blit any window 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 func (s *Screen) Fini() // tear the device down
``` ```
Ported drawing code keeps its structure: `mvaddch(y, x, ch)` Ported drawing code keeps its structure: `mvaddch(y, x, ch)`
`g.scr.Std.MvAddCh(y, x, ch)`. Curses `mvinch` reads come from the Window `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 buffer, preserving the "screen is a data structure" idiom without touching the
real terminal. `md_readchar`'s escape decoding is deleted; tcell's `EventKey` real terminal. `Repaint` is the one drawing call that is not a blit: it is the
provides decoded keys and we translate to the byte codes `command()` already `CTRL('R')` command's `clearok(curscr, TRUE)` plus `wrefresh(curscr)`,
handles (KeyUp → 'k', etc.). SIGTSTP/resume and resize are handled by tcell; implemented as tcell's `Screen.Sync`, and it exists because `Render` cannot do
SIGHUP/SIGTERM → `autoSave` via `os/signal`. 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
`os.Exit` out from under an in-flight save. SIGHUP and SIGTERM autosave on the
way out (save.c `auto_save`); SIGINT and SIGQUIT restore the terminal and exit
without saving, matching C — where `auto_save` is reserved for HUP/TERM and
neither `leave()` nor `quit()` nor `endit()` writes a save file — and keeping a
deliberate interrupt from becoming a free checkpoint.
The signal goroutine does not write the save itself. It calls
`RogueGame.AutoSaveOnSignal`, which posts a request, wakes the input read
through `Terminal.Interrupt`, and waits for the game goroutine to take it or for
a deadline to run out; the encode happens on the game goroutine, the only one
that touches game state. It answers between turns (`command`), on waking from a
blocked `readchar`, and while parked in the `!` shell escape (`runShellEscape`)
— the three places it can sit for any length of time. Before issue #24 the
handler gob-encoded the live game tree from its own goroutine after removing the
save file, which raced every mutation the game was making and left a window with
no save file at all; `saveFile` now writes a temporary file in the save's own
directory and renames it over the target, so the previous save survives any
failure, including the deadline expiring with nothing written.
What that guarantees precisely, and what it does not: the encode runs on the one
goroutine that owns the state, so the snapshot is internally consistent and
always restorable. It is not guaranteed to be a between-commands snapshot. Only
one of the three service points gives that: the check at the top of `command`,
which runs after the previous command returned and before this turn's
`DoDaemons(Before)`/`DoFuses(Before)`. The other two are both reached from
inside a `command` call already under way. `readchar` is reached from prompts
raised part-way through a command (`--More--`, `askOverwrite`, `getStr`, the
direction and pack prompts), and the command has already mutated state by then —
`fight` sets `Count`/`Quiet` and runs `runTo` before any message, `revealXeroc`
writes `Disguise` before emitting one; the ordinary top-of-turn key read is
inside `command` too, after that turn's BEFORE daemons and `turnUpkeep`.
`runShellEscape` is no safer: `shell` is an ordinary command handler (`'!'` in
the dispatch table), reached through `executeCommand`, so a goroutine parked in
the shell escape has already run this turn's `DoDaemons(Before)`,
`DoFuses(Before)`, `turnUpkeep` and the last-command bookkeeping, and has not
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`, 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
failed `Screen.Resume` is reported) is recovered there and re-raised on the game
goroutine. A panic reaching the top of a helper goroutine would kill the process
without running the main goroutine's `defer t.Fini()`, leaving the tty raw — the
failure issue #12 removed, on the one path where the terminal is already broken.
Re-raising keeps the invariant below true.
The handlers are installed immediately after `term.New()`, which is the call
that puts the tty in raw mode, and before the game exists — the saver is handed
over afterwards through `pendingSaver`. That ordering is what makes "every path
restores the terminal via `Terminal.Fini` before exiting" actually true: both
the save-restoring path and `-d`'s `DeathDemo()` run with the tty already raw,
and `DeathDemo()` never returns (it blocks in `waitFor` inside `death()`), so
handlers installed after them would leave that whole stretch unarmed. Restoring
the terminal is the part that must be armed the instant the tty goes raw, and it
needs no game; a signal arriving before the game is built restores and exits
with nothing to save. That is C's `leave()`, "leave quickly but curteously".
### 5.4 Messaging ### 5.4 Messaging
@@ -1593,7 +1678,7 @@ Tombstone/victory screens port verbatim from rip.c.
## 6. C construct → Go construct map ## 6. C construct → Go construct map
| C construct | Go translation | | C construct | Go translation |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| global variable | `RogueGame` field (or Player/Level/subsystem field) | | global variable | `RogueGame` field (or Player/Level/subsystem field) |
| file-scope static | unexported field on `RogueGame` or subsystem struct | | file-scope static | unexported field on `RogueGame` or subsystem struct |
| `THING` union | `Creature` / `Object` structs | | `THING` union | `Creature` / `Object` structs |
@@ -1606,7 +1691,7 @@ Tombstone/victory screens port verbatim from rip.c.
| damage strings `"3x4/1x2"` | parsed once into a `DiceSpec` (`[]DiceRoll`) by `ParseDice` at table time | | damage strings `"3x4/1x2"` | parsed once into a `DiceSpec` (`[]DiceRoll`) by `ParseDice` at table time |
| curses stdscr/hw windows | `Window` cell buffers over tcell | | curses stdscr/hw windows | `Window` cell buffers over tcell |
| `mvinch` screen reads | `Window.Inch` from the buffer | | `mvinch` screen reads | `Window.Inch` from the buffer |
| signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine → channel checked in ReadChar; tcell handles TSTP/resize | | signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine; the autosave is handed to the game goroutine and taken between turns or on waking a blocked `ReadChar`; tcell handles resize only |
| `setjmp`-free exits (`my_exit`, `exit()` everywhere) | `myExit` restores the terminal and calls `os.Exit(0)`; one game run is one process | | `setjmp`-free exits (`my_exit`, `exit()` everywhere) | `myExit` restores the terminal and calls `os.Exit(0)`; one game run is one process |
| `vsprintf` message building | `fmt.Sprintf` | | `vsprintf` message building | `fmt.Sprintf` |
| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) | | XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) |
@@ -1710,11 +1795,65 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7").
| `md_readchar` escape decoding | tcell decodes keys | key-event translation table | | `md_readchar` escape decoding | tcell decodes keys | key-event translation table |
| XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) | | XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) |
| save-file symlink/hardlink checks | single-user era anti-cheat | none | | 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 | | load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none |
| tty dsusp/ltc character juggling | tcell owns the tty | none | | tty dsusp/ltc character juggling | tcell owns the tty | none |
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell | | shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell |
| curses window save in save file | screen is derivable | redraw on restore | | curses window save in save file | screen is derivable | redraw on restore |
| `tstp()` SIGTSTP suspend/resume | see below | `!` shell escape |
| 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
as a signal in the first place: tcell puts the tty in raw mode (`term.MakeRaw`
clears `ISIG`, which is what makes `VSUSP` live), so Ctrl-Z arrives as key byte
`0x1a`, exactly as it did in C, whose `setup()` calls curses `raw()` for the
same effect. Only an explicit `kill -TSTP` can deliver it, which is not a player
action. Handling it properly would mean calling `Screen.Suspend`/`Resume` from
the signal goroutine while the game goroutine may be inside `Render` or
`PollEvent`. That is not a _data_ race — tcell guards `Suspend`/`Resume` and
`Fini` alike with the screen mutex, which is also why the `Fini` this port does
call from the signal goroutine is safe — it is a _logical_ race over the screen
state: the game goroutine can redraw into a screen the handler has just
suspended, or resume under a half-finished frame. Getting it right means
plumbing the signal through the input loop and handling it synchronously. Half
of that plumbing now exists — `Terminal.Interrupt` wakes a blocked `ReadChar` so
the game goroutine can act on a signal (§5.3, issue #24) — but the suspend and
the resume still have to be sequenced against the game's own drawing, which is
the part that remains a design change rather than a signal-safety fix. C's own
wiring here is vestigial: `tstp` is armed only by `md_tstpresume()`, which runs
after a successful `restore()`, so a freshly started C game never had a SIGTSTP
handler either. `term.Tcell.ShellEscape` (the `!` command) already covers
getting to a shell and back. Since issue #24 the shell itself runs on a helper
goroutine (`runShellEscape`), so a hangup arriving while the player is away in
the shell still rescues the game; the game goroutine waits there without
drawing, so the suspend/resume dance is as undisturbed as it was when the call
ran inline.
**SIGINT → `quit()`.** Under `md_onsignal_autosave` C routed SIGINT into the
interactive "really quit?" prompt. The port exits instead (after restoring the
terminal): the handler runs on another goroutine, and re-entering the message
and input machinery from there would race every screen and state access the main
goroutine makes. The `Q` command reaches the same prompt from inside the turn
loop, which is where the player actually quits.
**Fault signals.** `md_onsignal_autosave` also sent SIGILL, SIGTRAP, SIGFPE,
SIGBUS, SIGSEGV and SIGSYS to `auto_save`. In Go these are runtime panics with
their own diagnostics, and gob-encoding the state that just faulted would risk
replacing a good save file with a corrupt one, so they are left alone. SIGHUP
and SIGTERM still autosave.
## 10. Testing strategy ## 10. Testing strategy

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

@@ -10,7 +10,42 @@ unlikely error returns through game code — e.g. write-side Close/encode failur
where continuing would mean corrupt state. Return errors where a caller where continuing would mean corrupt state. Return errors where a caller
genuinely handles them (save-file prompts, restore validation). Reserve genuinely handles them (save-file prompts, restore validation). Reserve
deliberate `_ =` discards for true best-effort paths (scorefile writes, deliberate `_ =` discards for true best-effort paths (scorefile writes,
signal-time autosave), always with a comment saying why. `Terminal.Interrupt`'s post to a full event queue), always with a comment saying
why.
Signal-time autosave used to be on that list and no longer is (issue #24). It is
best effort in the sense that nothing can be reported to a player whose terminal
is already going away, but the outcome is a value, not a discard: the signal
goroutine calls `AutoSaveOnSignal`, which hands the save to the game goroutine —
the only one allowed to touch game state — and returns whether it was taken
before the deadline. The game answers between turns (`command`), while parked
waiting for a key (`readchar`), and while parked in the `!` shell escape
(`runShellEscape`). `saveFile` writes a temporary file and renames it over the
target, so a save that fails or never happens leaves the player's previous save
whole; never reintroduce a `Remove` before the write in `autoSave`, and never
encode game state from any goroutine but the game's.
Do not upgrade that into "the snapshot is always taken between commands" — it is
not. What is true is that the encode runs on the state-owning goroutine, so the
snapshot is internally consistent and restorable. Only the check at the top of
`command` is a between-commands snapshot; the other two service points both sit
inside a `command` call already under way. `readchar` is reached from
mid-command prompts (`--More--`, `askOverwrite`, `getStr`, direction and pack
prompts) with the command's mutations already applied, and `runShellEscape` is
reached from `shell`, an ordinary `'!'` command handler, with that turn's
`DoDaemons(Before)`/`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 already in
the snapshot. That is acceptable and documented; two successive false claims —
first that `readchar` was safe, then that two of the three service points were
between-commands — were caught in review of PR #26, and neither may come back.
Related, and easy to reintroduce: work moved onto a helper goroutine must not be
allowed to panic there. A panic at the top of any goroutine kills the process
without running the other goroutines' defers, including `cmd/rogue/main.go`'s
`defer t.Fini()`, which is what leaves a raw tty (issue #12). `runShellEscape`
recovers its helper's panic and re-raises it on the game goroutine for exactly
that reason.
C's exit() calls are not unwound: one game run is one process, so myExit C's exit() calls are not unwound: one game run is one process, so myExit
(game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and (game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and

View File

@@ -1,18 +1,47 @@
# Development convenience targets. This repo is exempt from the standard # Development convenience targets. This repo is exempt from the standard
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile # policy scaffold (no CI config, no REPO_POLICIES.md, no application
# is only a thin wrapper around the Go toolchain, golangci-lint, and # Dockerfile) except for the lint container: per sneak's 2026-08-09
# prettier so `make fmt` / `make check` behave the same as in sneak's # ruling, linting runs in docker only, so Dockerfile.lint and script/lint
# other repos. # 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 := ./... GO_PKGS := ./...
MD_FILES := $(shell git ls-files '*.md') MD_FILES := $(shell git ls-files '*.md')
PRETTIER := prettier --tab-width 4 --prose-wrap always 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 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. # Format Go and Markdown in place.
fmt: fmt:
gofmt -w . gofmt -w .
@@ -26,9 +55,11 @@ fmt-check:
fi fi
$(PRETTIER) --check $(MD_FILES) $(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: lint:
golangci-lint run $(GO_PKGS) ./script/lint
# Run the test suite. Quiet on success; on failure, rerun verbosely for the # 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 # 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. Requires Go 1.25 or later and a terminal at least 80x24.
```bash ```bash
go build ./cmd/rogue make build
./rogue ./build/rogue
``` ```
```bash ```bash
# Restore a saved game # Restore a saved game
./rogue ~/rogue.save ./build/rogue ~/rogue.save
# View high scores # View high scores
./rogue -s ./build/rogue -s
# Test the death screen (demo mode) # Test the death screen (demo mode)
./rogue -d ./build/rogue -d
``` ```
## In-game commands ## In-game commands
@@ -57,7 +57,7 @@ Press `?` in game for the full list.
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango" export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
# Wizard (debug) mode, with a reproducible dungeon # 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 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. against the original C generator.
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt + For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
detector with coverage and a timeout), and `make check` (all three). Use the pinned container built from `Dockerfile.lint` — it is never installed on the
targets rather than invoking `go test` directly — they carry the flags the host, so docker is required), `make test` (the suite, under the race detector
project relies on. 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 ## License

658
TODO.md
View File

@@ -29,11 +29,641 @@ Refactor ground rules:
# Next Step # Next Step
Broaden unit test coverage where playtesting finds thin spots (rings, sticks, Tag a release once a full game (Amulet retrieval and score entry) completes
wizard commands). without defects. Promoted from Future Steps now that the coverage step above it
is finished.
# Completed Steps # 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
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. `make test`
has run with `-race` since 2026-08-09 and was green, because no test had ever
driven the turn loop concurrently with a signal: evidence of untested, not of
safe. The handler now writes nothing itself. `AutoSaveOnSignal` posts a
request on a one-deep channel, wakes the input read, and waits up to
`signalSaveTimeout` (3s) for the game goroutine to take it; the encode happens
on the goroutine that owns the state. **The blocked-on-input case is the whole
point** — a dropped connection lands while the player is thinking, so a flag
checked only between turns would never be looked at — and it is handled by
making the read interruptible: `Terminal.ReadChar` returns `(byte, bool)` with
`ok == false` meaning "woken by `Interrupt`, no key", `term.Tcell.Interrupt`
posts a `tcell.EventInterrupt` onto tcell's own event queue to unpark
`PollEvent`, and `readchar` services the request and reads again, so no caller
sees the wake-up. The other unbounded park is the `!` shell escape, where a
hangup used to save and would otherwise have regressed to not saving: the
shell now runs on a helper goroutine and `runShellEscape` selects on {shell
finished, save request}, keeping the encode on the game goroutine while it
draws nothing. Between turns (`command`) covers a game that is busy rather
than parked. The wait is bounded so that a game goroutine wedged with no
service point can never stop a signal from getting the process out; giving up
costs nothing now that `saveFile` writes a temporary file in the save's own
directory, fsyncs it, and renames it over the target instead of truncating in
place — a failed or skipped save leaves the previous save whole. New
`game/autosave_test.go` drives the real turn loop while a second goroutine
asks for 25 saves (the interleaving that never existed before), plus the
parked-on-input case with a terminal fake that genuinely blocks, the shell
case, the deadline case (previous save byte-for-byte intact), the no-file-name
case, and the rename discipline — the last pinned by a handle opened before
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 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
claimed two of the three service points were between-commands. Both are false.
Only the check at the top of `command` is between commands — `readchar` is
reached from mid-command prompts, and `runShellEscape` is reached from
`shell`, an ordinary `'!'` command handler dispatched inside `command`, with
that turn's `DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER
pass and ring effects not yet. What is actually guaranteed is that the encode
runs on the state-owning goroutine, so the snapshot is internally consistent
and restorable, though it may freeze a command half applied. `Next Step`
deliberately not rotated: out-of-band issue work.
- 2026-08-09 Signal-time terminal restore (`sig-leave`, closes #12): the port
handled only SIGHUP and SIGTERM, so SIGINT and SIGQUIT killed the process with
tcell still holding the tty, leaving the user at a shell with no echo. All
four signals now go to one `os/signal` channel read by one goroutine in
`cmd/rogue/main.go`, and every path calls `Terminal.Fini` before `os.Exit(0)`
— C's `leave()`, "leave quickly but curteously". **The decision** (written
into the `savesOnSignal` comment): SIGHUP/SIGTERM keep autosaving,
SIGINT/SIGQUIT restore and exit **without** saving. C never saves on INT or
QUIT anywhere — `leave()` is endwin-and-exit, `quit()` confirms/scores/exits,
`endit()` goes through `fatal()`, and `save.c auto_save` is reserved for
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. 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): - 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 `createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against
@@ -124,7 +754,11 @@ wizard commands).
24 long lines wrapped or their comments tightened, control bytes in 24 long lines wrapped or their comments tightened, control bytes in
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The `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; 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 - 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 reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
@@ -268,13 +902,17 @@ wizard commands).
# Future Steps # Future Steps
1. Tag a release once a full game (Amulet retrieval and score entry) completes 1. Full-terminal-size support (deferred by explicit decision 2026-07-06):
without defects.
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
per-game dungeon dimensions instead of the 80x24 constants; open design per-game dungeon dimensions instead of the 80x24 constants; open design
questions are resize policy, gameplay tuning at larger sizes, and a --classic questions are resize policy, gameplay tuning at larger sizes, and a --classic
80x24 mode. 80x24 mode.
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev 2. Note: this repo is exempt from the standard policy scaffold, but the
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's exemption is narrower than it was. A minimal dev Makefile
2026-07-07 request, but do not add a Dockerfile, CI config, or (fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07
REPO_POLICIES.md. 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

@@ -10,6 +10,7 @@ import (
"os/signal" "os/signal"
"os/user" "os/user"
"strconv" "strconv"
"sync"
"syscall" "syscall"
"time" "time"
@@ -39,6 +40,15 @@ func run() int {
return 0 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() t, err := term.New()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
@@ -47,6 +57,13 @@ func run() int {
} }
defer t.Fini() defer t.Fini()
// Armed here, the instant the tty goes raw, not after the game is
// built: everything below this line — the restore, the death demo
// (which never returns), the game itself — would otherwise run raw
// with no handler installed. There is no game to save yet, so the
// saver is filled in below once there is one.
pending := installSignalHandlers(t)
params.Term = t params.Term = t
var g *game.RogueGame var g *game.RogueGame
@@ -64,18 +81,38 @@ func run() int {
} }
if *deathDemo { if *deathDemo {
// The demo is left without a saver on purpose: a signal still
// restores the terminal, but a throwaway demo game is not worth
// writing over the player's save file.
g.DeathDemo() // does not return: death exits the process g.DeathDemo() // does not return: death exits the process
return 0 return 0
} }
installAutosave(g, t) pending.set(g)
g.Run() // does not return: the game ends by exiting the process g.Run() // does not return: the game ends by exiting the process
return 0 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 // loadParams gathers the game parameters from the environment: home
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed // directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
// (main.c's startup). // (main.c's startup).
@@ -101,19 +138,184 @@ func loadParams() game.Params {
} }
} }
// installAutosave saves the game and exits on SIGHUP/SIGTERM (save.c // saver is the autosave half of *game.RogueGame that the signal handler
// auto_save). // needs; an interface so the handler is testable headlessly.
func installAutosave(g *game.RogueGame, t *term.Tcell) { type saver interface {
// AutoSaveOnSignal asks the game goroutine to write the save file and
// waits up to timeout for it, reporting whether the save ran (save.c
// auto_save). The handler never encodes anything itself; see
// signalSaveTimeout.
AutoSaveOnSignal(timeout time.Duration) bool
}
// signalSaveTimeout bounds how long the signal handler waits for the game
// goroutine to take its autosave.
//
// The handler cannot encode the game itself — that was issue #24's data
// race — so it has to hand the work to the goroutine that owns the state
// and wait. The game answers between turns, while parked waiting for a
// key, and while parked in the shell escape, which covers everywhere it
// can sit for any length of time; the deadline is the backstop for a game
// goroutine wedged somewhere with no service point, so that a signal can
// never fail to get the process out. It is generous next to the
// milliseconds a gob encode of one game takes, and invisible to a player
// whose connection has already dropped.
//
// Giving up costs nothing now that saveFile renames over the target
// (game/save.go): a save that does not happen leaves the previous save
// whole, where the old remove-then-encode could leave the player with
// neither.
const signalSaveTimeout = 3 * time.Second
// finisher is the terminal-restoring half of game.Terminal that the
// signal handler needs (curses endwin).
type finisher interface {
// Fini restores the terminal to its pre-game state.
Fini()
}
// pendingSaver is the saver the signal handler holds from the moment the
// terminal goes raw. The handler has to be armed before there is a game
// to save — restoring a save file and the death demo both run with the
// tty already raw — so AutoSaveOnSignal does nothing until set hands over
// the real game. The mutex is not decoration: set runs on the main
// goroutine and AutoSaveOnSignal on the signal goroutine.
type pendingSaver struct {
mu sync.Mutex
game saver
}
// AutoSaveOnSignal saves the game if there is one yet, and otherwise does
// nothing: a signal arriving before the game is built still restores the
// terminal, which is the part that matters.
//
// The lock is held only long enough to read the game, not across the
// delegated save. That changed with issue #24: the real
// AutoSaveOnSignal now blocks until the game goroutine takes the save or
// the deadline expires, and holding the mutex across a wait that long
// would stall a concurrent set — the case the PR #23 review flagged as
// safe only for as long as set is called exactly once. This shape does
// not depend on that.
func (p *pendingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
p.mu.Lock()
g := p.game
p.mu.Unlock()
if g == nil {
return false
}
return g.AutoSaveOnSignal(timeout)
}
// set hands the signal handler the game to autosave, once one exists.
func (p *pendingSaver) set(g saver) {
p.mu.Lock()
defer p.mu.Unlock()
p.game = g
}
// handledSignals returns the signals the game leaves on. They split into
// two groups with deliberately different save behavior; see
// savesOnSignal.
func handledSignals() []os.Signal {
return []os.Signal{
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
}
}
// savesOnSignal reports whether the game should autosave on its way out
// for this signal.
//
// THE DECISION (issue #12): SIGHUP and SIGTERM save; SIGINT and SIGQUIT
// restore the terminal and exit WITHOUT saving. This is deliberate, not
// an oversight, on three grounds.
//
// C: no path in the C game saves on INT or QUIT. The shipped build
// installs no handler at all during play (mach_dep.c setup calls
// md_onsignal_default), and the only INT handler it ever installs is
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
// discarding pending output. The build that does wire INT during play
// (md_onsignal_autosave, mdport.c — defined unconditionally, but with
// its only call site, mach_dep.c setup, inside #ifdef DUMP) sends it to
// quit(), which confirms, scores, and exits, again without saving, and
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
// therefore exactly C's split.
//
// Semantics: HUP and TERM mean involuntary teardown — the line dropped
// or the machine is going down — so rescuing the game is right. INT and
// QUIT are the player deliberately saying "stop now". Rogue scores a
// deliberate quit, and its save discipline is anti-save-scum by design
// (restoring consumes the file), so making Ctrl-C a free checkpoint
// would turn it into a one-keystroke undo for a bad turn: a gameplay
// change, not a robustness fix.
//
// Safety: this used to be the third ground, back when the handler
// gob-encoded live game state from its own goroutine after removing the
// save file — a data race with a window in which the player had no save
// at all, accepted on HUP/TERM because the process was dying anyway and
// avoided entirely on INT/QUIT. Issue #24 removed the window instead of
// living with it: the handler now hands the save to the game goroutine
// and waits (AutoSaveOnSignal), and the write goes to a temporary file
// renamed over the target. The split above stands on C and on semantics,
// which is where it always belonged; INT and QUIT do not save because
// the player asked to stop, not because saving is dangerous.
func savesOnSignal(sig os.Signal) bool {
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
}
// installSignalHandlers arranges for the game to leave the terminal
// usable when it is signalled: C's leave(), "leave quickly but
// curteously" (main.c), extended with save.c auto_save on the two
// signals that warrant it.
//
// Call it the instant the terminal goes raw, which is earlier than the
// game exists; the returned pendingSaver takes the game once it does.
// Restoring the terminal is what has to be armed the moment the tty
// stops being usable, and it does not need a game.
func installSignalHandlers(t finisher) *pendingSaver {
pending := &pendingSaver{}
go leaveOnSignal(notifySignals(), pending, t, os.Exit)
return pending
}
// notifySignals subscribes to the handled signals and returns the
// channel they arrive on. Split out from installSignalHandlers so tests
// can drive leaveOnSignal with real signal delivery.
func notifySignals() chan os.Signal {
// Buffered so signal delivery never blocks, and deliberately never
// drained past the first signal: see leaveOnSignal.
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM) signal.Notify(sig, handledSignals()...)
return sig
}
// leaveOnSignal waits for one signal and takes the game out.
//
// Exactly one goroutine reads exactly one signal, which is what makes
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's save
// is still being written, say) stays in the buffer unread and can never
// call exit out from under an in-flight save. The order within is the
// same one myExit uses (game/rip.go): save if this signal saves, then
// restore the terminal, then exit.
//
// AutoSaveOnSignal returns once the game goroutine has finished writing,
// or once signalSaveTimeout has run out, so the save is complete before
// the terminal is torn down and the process leaves — and the process
// leaves either way.
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
if savesOnSignal(<-sig) {
g.AutoSaveOnSignal(signalSaveTimeout)
}
go func() {
<-sig
g.AutoSave()
t.Fini() t.Fini()
os.Exit(0) exit(0)
}()
} }
// chooseSeed picks the dungeon number: SEED for reproducible dungeons // chooseSeed picks the dungeon number: SEED for reproducible dungeons

419
cmd/rogue/main_test.go Normal file
View File

@@ -0,0 +1,419 @@
package main
// White-box tests for the signal plumbing. Unlike the game package's test
// files this one carries no //nolint:testpackage directive: testpackage
// exempts package main, so nolintlint rejects the directive as unused.
import (
"os"
"os/signal"
"slices"
"sync"
"syscall"
"testing"
"time"
)
// The steps the signal handler can take, in the order signalRecorder
// records them.
const (
stepSave = "save"
stepFini = "fini"
stepExit = "exit"
)
// wantHandledSignals is the exact set of signals the game must leave on.
// This is the subject of issue #12: SIGHUP and SIGTERM were handled and
// SIGINT and SIGQUIT were not, so the latter two killed the process with
// the tty still raw. Every other test here iterates handledSignals(), so
// without this one the whole file would pass against a set that had
// silently lost SIGINT and SIGQUIT again.
func wantHandledSignals() []os.Signal {
return []os.Signal{
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
}
}
// wantSteps is the expected handler step sequence for each handled
// signal, and the single source of truth for the tests that check the
// save/no-save split.
func wantSteps() map[os.Signal][]string {
return map[os.Signal][]string{
syscall.SIGHUP: {stepSave, stepFini, stepExit},
syscall.SIGTERM: {stepSave, stepFini, stepExit},
syscall.SIGINT: {stepFini, stepExit},
syscall.SIGQUIT: {stepFini, stepExit},
}
}
// signalRecorder stands in for the game and the terminal in the signal
// handler, recording the order of the steps the handler takes. The mutex
// matters: the handler runs on its own goroutine, so an unguarded slice
// would be a data race under -race, which is exactly what these tests
// are meant to rule out.
type signalRecorder struct {
mu sync.Mutex
steps []string
code int
done chan struct{}
}
func newSignalRecorder() *signalRecorder {
return &signalRecorder{done: make(chan struct{})}
}
// AutoSaveOnSignal records a save attempt (the saver half). The real one
// hands the work to the game goroutine and waits; the recorder stands in
// for a game that takes it immediately.
func (r *signalRecorder) AutoSaveOnSignal(time.Duration) bool {
r.record(stepSave)
return true
}
// Fini records a terminal restore (the finisher half).
func (r *signalRecorder) Fini() {
r.record(stepFini)
}
// exit records the process exit that ends the handler and releases any
// waiter. It stands in for os.Exit, which cannot be called in a test.
func (r *signalRecorder) exit(code int) {
r.mu.Lock()
r.code = code
r.steps = append(r.steps, stepExit)
r.mu.Unlock()
close(r.done)
}
// record appends one step.
func (r *signalRecorder) record(step string) {
r.mu.Lock()
defer r.mu.Unlock()
r.steps = append(r.steps, step)
}
// taken returns the recorded steps and the exit code.
func (r *signalRecorder) taken() ([]string, int) {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.steps), r.code
}
// TestHandledSignalsSet pins the membership of handledSignals() itself.
// The regression issue #12 exists to prevent is a signal dropping out of
// that set — SIGINT and SIGQUIT reaching the process at SIG_DFL and
// killing it with the tty raw — and every other test in this file is
// driven by the set, so only this test can fail on it.
func TestHandledSignalsSet(t *testing.T) {
t.Parallel()
got := handledSignals()
want := wantHandledSignals()
if len(got) != len(want) {
t.Errorf("handledSignals() = %v, want exactly %v", got, want)
}
for _, sig := range want {
if !slices.Contains(got, sig) {
t.Errorf("handledSignals() = %v, missing %v", got, sig)
}
}
for _, sig := range got {
if !slices.Contains(want, sig) {
t.Errorf("handledSignals() = %v, unexpected %v", got, sig)
}
}
}
// TestLeaveOnSignalRestoresTerminalBeforeExit is the core of issue #12:
// whatever the signal, the terminal is restored before the process ends,
// so the player is never dropped into a shell with the tty still in raw
// mode.
func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
t.Parallel()
for _, sig := range handledSignals() {
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- sig
leaveOnSignal(ch, rec, rec, rec.exit)
steps, code := rec.taken()
fini := slices.Index(steps, stepFini)
exit := slices.Index(steps, stepExit)
if fini < 0 || exit < 0 || fini > exit {
t.Errorf("%v: want the terminal restored before exit, got %v",
sig, steps)
}
if code != 0 {
t.Errorf("%v: exit code = %d, want 0", sig, code)
}
}
}
// TestLeaveOnSignalSaveSplit pins the decision recorded on savesOnSignal:
// SIGHUP/SIGTERM (involuntary teardown) save on the way out, SIGINT and
// SIGQUIT (a deliberate "stop now" from the player) do not, matching C,
// where auto_save is reserved for HUP/TERM and neither leave() nor quit()
// nor endit() writes a save file.
//
// It is driven by the expectation table rather than by
// handledSignals(), so that every entry — including the SIGINT and
// SIGQUIT ones — is actually read, and a signal dropped from the handled
// set fails here as well as in TestHandledSignalsSet.
func TestLeaveOnSignalSaveSplit(t *testing.T) {
t.Parallel()
for sig, want := range wantSteps() {
if !slices.Contains(handledSignals(), sig) {
t.Errorf("%v is not handled at all, so it cannot exit cleanly", sig)
continue
}
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- sig
leaveOnSignal(ch, rec, rec, rec.exit)
steps, _ := rec.taken()
if !slices.Equal(steps, want) {
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
}
if saved := slices.Contains(steps, stepSave); saved != savesOnSignal(sig) {
t.Errorf("%v: saved = %v, savesOnSignal = %v",
sig, saved, savesOnSignal(sig))
}
}
}
// TestLeaveOnSignalIgnoresLaterSignals covers the ordering guarantee in
// leaveOnSignal's comment: only the first signal is read, so a second one
// arriving mid-save cannot exit out from under the save and truncate the
// player's file. The saver here blocks until a second signal has been
// queued, reproducing that window.
func TestLeaveOnSignalIgnoresLaterSignals(t *testing.T) {
t.Parallel()
rec := newSignalRecorder()
ch := make(chan os.Signal, 2)
ch <- syscall.SIGHUP
blocker := &blockingSaver{rec: rec, queue: ch, extra: syscall.SIGINT}
leaveOnSignal(ch, blocker, rec, rec.exit)
steps, _ := rec.taken()
if !slices.Equal(steps, []string{stepSave, stepFini, stepExit}) {
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
}
if len(ch) != 1 {
t.Errorf("queued signals left unread = %d, want 1", len(ch))
}
}
// blockingSaver queues another signal while the save is in flight, the
// race window leaveOnSignal is built to close.
type blockingSaver struct {
rec *signalRecorder
queue chan os.Signal
extra os.Signal
}
// AutoSaveOnSignal delivers the extra signal mid-save, then records the
// save.
func (b *blockingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
b.queue <- b.extra
return b.rec.AutoSaveOnSignal(timeout)
}
// TestLeaveOnRealSignal is the deepest headless check available: it
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
// os/signal, exactly as notifySignals wires them in the game, and
// verifies each one reaches the handler and produces the full expected
// step sequence — including the save/no-save split, which this test is
// the best placed to check end to end.
//
// What cannot be checked here is the tty itself coming back out of raw
// mode: that needs a controlling terminal and a live tcell screen, which
// a headless test run does not have. This test covers everything up to
// the Terminal.Fini call; term.Tcell.Fini is a direct pass-through to
// tcell's Screen.Fini, which is the same call myExit already relies on.
func TestLeaveOnRealSignal(t *testing.T) {
t.Parallel()
ch := notifySignals()
defer signal.Stop(ch)
for _, sig := range handledSignals() {
rec := newSignalRecorder()
go leaveOnSignal(ch, rec, rec, rec.exit)
unix, ok := sig.(syscall.Signal)
if !ok {
t.Fatalf("%v is not a unix signal", sig)
}
err := syscall.Kill(os.Getpid(), unix)
if err != nil {
t.Fatalf("kill(%v): %v", sig, err)
}
<-rec.done
steps, code := rec.taken()
if want := wantSteps()[sig]; !slices.Equal(steps, want) {
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
}
if code != 0 {
t.Errorf("%v: exit code = %d, want 0", sig, code)
}
}
}
// TestPendingSaverArmsBeforeTheGameExists covers what lets the handlers
// be installed the instant the terminal goes raw rather than after the
// game is built: a signal arriving before there is a game must still
// reach Fini, and must not save anything, while one arriving after the
// game is handed over saves it.
func TestPendingSaverArmsBeforeTheGameExists(t *testing.T) {
t.Parallel()
pending := &pendingSaver{}
rec := newSignalRecorder()
ch := make(chan os.Signal, 1)
ch <- syscall.SIGHUP
// No game yet: the SIGHUP still restores the terminal and exits, it
// just has nothing to write.
leaveOnSignal(ch, pending, rec, rec.exit)
steps, code := rec.taken()
if want := []string{stepFini, stepExit}; !slices.Equal(steps, want) {
t.Errorf("before the game exists: steps = %v, want %v", steps, want)
}
if code != 0 {
t.Errorf("before the game exists: exit code = %d, want 0", code)
}
// Once the game is handed over, the same saver writes it.
started := newSignalRecorder()
pending.set(started)
if !pending.AutoSaveOnSignal(signalSaveTimeout) {
t.Error("after set: the save was not reported as taken")
}
saved, _ := started.taken()
if want := []string{stepSave}; !slices.Equal(saved, want) {
t.Errorf("after set: steps = %v, want %v", saved, want)
}
}
// TestPendingSaverDoesNotHoldItsLockAcrossTheSave pins the reason
// pendingSaver reads the game out from under the mutex instead of
// delegating with it held: since issue #24 the delegated save blocks
// until the game goroutine takes it or the deadline expires, so a mutex
// held across it would stall whoever calls set. Nothing calls set twice
// today, which is why the PR #23 review recorded this as a future-proof
// note rather than a bug — this test is what stops it becoming one.
func TestPendingSaverDoesNotHoldItsLockAcrossTheSave(t *testing.T) {
t.Parallel()
pending := &pendingSaver{}
stuck := &stuckSaver{entered: make(chan struct{}), release: make(chan struct{})}
pending.set(stuck)
go pending.AutoSaveOnSignal(signalSaveTimeout)
<-stuck.entered // the delegated save is in flight
done := make(chan struct{})
go func() {
defer close(done)
pending.set(newSignalRecorder()) // must not block on the save
}()
select {
case <-done:
case <-time.After(time.Second):
t.Error("set blocked while a save was in flight: the lock is held across it")
}
close(stuck.release)
}
// stuckSaver blocks inside the delegated save until it is released,
// standing in for a game goroutine that is slow to answer.
type stuckSaver struct {
entered chan struct{}
release chan struct{}
}
// AutoSaveOnSignal blocks until the test releases it.
func (s *stuckSaver) AutoSaveOnSignal(time.Duration) bool {
close(s.entered)
<-s.release
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)
}
})
}
}

610
game/autosave_test.go Normal file
View File

@@ -0,0 +1,610 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
// Tests for the signal-triggered autosave handoff (issue #24): the signal
// goroutine must never encode game state itself, and the game goroutine
// must answer wherever it is parked.
import (
"io"
"os"
"path/filepath"
"testing"
"time"
)
// 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. 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
// drives the real turn loop on one goroutine while another asks for a
// signal-triggered autosave over and over, which is the interleaving no
// test in the suite used to produce. `make test` runs with -race, so a
// save that encodes the live game tree from the asking goroutine — what
// the old AutoSave did straight from the signal handler — is reported as
// a data race and fails this test.
//
// Non-vacuity: with AutoSaveOnSignal's body replaced by a direct
// g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails
// under -race with the encoder reading state that command() is writing.
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
t.Parallel()
// 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: term})
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
g.startLevel()
g.prePlay()
const wantSaves = 25
var taken int
done := make(chan struct{})
go func() {
defer close(done)
for range wantSaves {
if g.AutoSaveOnSignal(autoSaveWait) {
taken++
}
}
}()
driveUntilDone(t, g, done)
// The close of done orders that goroutine's writes before this read.
if taken != wantSaves {
t.Errorf("saves taken = %d, want %d", taken, wantSaves)
}
// Every request was answered by the turn loop, so the file is the
// work of the game goroutine and must be a whole save.
assertRestorable(t, g.FileName)
}
// driveUntilDone runs turns until the saving goroutine is finished,
// fortifying the hero each turn so no death exits the test binary. The
// 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()
for {
select {
case <-done:
return
default:
}
fortify(g)
g.command()
}
}
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
// for: the connection drops while the player is staring at the screen,
// so the game goroutine is parked in ReadChar and will not reach the
// between-turns check on its own. A flag checked only between turns would
// never be looked at here.
func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) {
t.Parallel()
bt := newBlockingTerm()
g := mkBlockedGame(t, bt)
read := make(chan byte)
go func() { read <- g.readchar() }()
// The wake is buffered, so this is correct whether or not the reader
// has reached ReadChar yet.
if !g.AutoSaveOnSignal(autoSaveWait) {
t.Fatal("the save was not taken while the game was blocked on input")
}
assertRestorable(t, g.FileName)
// The interrupt must not have been mistaken for a keystroke: the
// reader is still waiting, and still returns the real key.
bt.keys <- 'x'
if ch := <-read; ch != 'x' {
t.Errorf("readchar() = %q, want 'x'", ch)
}
}
// TestAutoSaveOnSignalWhileInShellEscape covers the other place the game
// goroutine parks for an unbounded time: the `!` shell escape, where it
// used to sit inside the shell call with no way to answer. A dropped line
// while the player is off in a shell is as much a hangup as any other.
func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) {
t.Parallel()
st := &shellTerm{
blockingTerm: newBlockingTerm(),
entered: make(chan struct{}),
release: make(chan struct{}),
}
g := mkBlockedGame(t, st)
left := make(chan struct{})
go func() {
defer close(left)
g.shell()
}()
<-st.entered
if !g.AutoSaveOnSignal(autoSaveWait) {
t.Fatal("the save was not taken while the game was in the shell escape")
}
assertRestorable(t, g.FileName)
close(st.release)
<-left
}
// TestShellEscapePanicUnwindsTheGameGoroutine pins the reason
// runShellEscape recovers its helper's panic.
//
// term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell
// now runs on a helper goroutine. A panic reaching the top of that helper
// would kill the process without running the deferred calls of any other
// goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is
// the only thing that takes the tty back out of raw mode. That is issue
// #12's failure, and it would land on the one path where the terminal is
// already broken.
//
// So the panic has to arrive on the goroutine that runs the game, with
// that goroutine's deferred restore still on the stack. This test stands
// in for main: a Fini deferred around the g.shell() call, and the panic
// caught after it, asserting both that the restore ran and that the
// original value came through. Against the unrecovered version there is
// nothing to assert — the panic escapes a helper goroutine and takes the
// whole test binary down, which is the failure being prevented.
func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) {
t.Parallel()
pt := &panickingShellTerm{blockingTerm: newBlockingTerm()}
g := mkBlockedGame(t, pt)
caught := make(chan any, 1)
go func() {
// Registered first, so it runs last: it sees the terminal
// already restored, exactly as the runtime would have printed
// the trace after main's Fini.
defer func() { caught <- recover() }()
// Stands in for cmd/rogue/main.go's `defer t.Fini()`.
defer pt.Fini()
g.shell()
}()
got := <-caught
if got == nil {
t.Fatal("the resume failure did not reach the game goroutine")
}
if msg, ok := got.(string); !ok || msg != errShellResume {
t.Errorf("recovered %v, want %q", got, errShellResume)
}
if !pt.restored {
t.Error("the terminal was not restored on the way out")
}
// shell() must not have resumed into its InShell reset and refresh:
// there is no screen left to draw into.
if !g.InShell {
t.Error("shell() carried on drawing after the resume failed")
}
}
// TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game
// goroutine that never reaches a service point must not hold the process
// open, and giving up must cost the player nothing. The old save is still
// there, byte for byte — which is the whole point of renaming over the
// target instead of removing it first.
func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
t.Parallel()
g := mkGame(t, 77)
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
const old = "an older save nobody is allowed to destroy"
writeErr := os.WriteFile(g.FileName, []byte(old), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
// Nothing drives the turn loop, so nothing will ever answer.
start := time.Now()
if g.AutoSaveOnSignal(100 * time.Millisecond) {
t.Error("AutoSaveOnSignal reported a save that nobody took")
}
if waited := time.Since(start); waited > time.Second {
t.Errorf("waited %v for an unanswered save, want the deadline to bound it",
waited)
}
got, readErr := os.ReadFile(g.FileName)
if readErr != nil {
t.Fatalf("the previous save was destroyed: %v", readErr)
}
if string(got) != old {
t.Error("the previous save was overwritten by a save that never ran")
}
}
// TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal
// case: a game with no file name has nothing to write, and must say so
// rather than reporting a save that did not happen.
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}})
g.FileName = ""
g.startLevel()
g.prePlay()
var answered bool
done := make(chan struct{})
go func() {
defer close(done)
answered = g.AutoSaveOnSignal(autoSaveWait)
}()
driveUntilDone(t, g, done)
if answered {
t.Error("AutoSaveOnSignal = true with no save file name")
}
}
// TestSaveFileReplacesTargetAtomically pins the write discipline: the new
// save arrives by rename, so the file the player already had is never
// written into, and the temporary file it came from is not left lying in
// the save directory.
//
// The load-bearing assertion is the handle opened before the save. A
// rename leaves the old file whole and merely stops it being reachable by
// name, so that handle still reads the old save; the truncate-in-place
// write this replaced would empty it under the reader — the same
// in-place write that, interrupted, left the player with a file that
// could no longer be restored.
func TestSaveFileReplacesTargetAtomically(t *testing.T) {
t.Parallel()
g := mkGame(t, 11)
dir := t.TempDir()
path := filepath.Join(dir, "rogue.save")
const old = "an older save"
writeErr := os.WriteFile(path, []byte(old), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
held, openErr := os.Open(path) //nolint:gosec // G304: test temp path
if openErr != nil {
t.Fatal(openErr)
}
defer func() { _ = held.Close() }()
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
kept, readErr := io.ReadAll(held)
if readErr != nil {
t.Fatalf("reading the file that was there before the save: %v", readErr)
}
if string(kept) != old {
t.Errorf("the previous save was written into rather than replaced: %q",
string(kept))
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 1 || entries[0].Name() != "rogue.save" {
t.Errorf("save directory = %v, want just the save file", names(entries))
}
info, statErr := os.Stat(path)
if statErr != nil {
t.Fatal(statErr)
}
if perm := info.Mode().Perm(); perm != 0o400 {
t.Errorf("save file mode = %v, want 0400", perm)
}
assertRestorable(t, path)
}
// TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the
// same discipline: a save that cannot be completed must leave what the
// player already had. The target here is a non-empty directory, which no
// rename can replace — the one write failure that can be forced without
// depending on file permissions, and therefore on not being root.
func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) {
t.Parallel()
g := mkGame(t, 12)
dir := t.TempDir()
path := filepath.Join(dir, "rogue.save")
mkErr := os.Mkdir(path, 0o700)
if mkErr != nil {
t.Fatal(mkErr)
}
keep := filepath.Join(path, "keep")
writeErr := os.WriteFile(keep, []byte("still here"), 0o600)
if writeErr != nil {
t.Fatal(writeErr)
}
saveErr := g.saveFile(path)
if saveErr == nil {
t.Error("saveFile over an unreplaceable target reported success")
}
_, statErr := os.Stat(keep)
if statErr != nil {
t.Errorf("the target was damaged by a failed save: %v", statErr)
}
entries, readErr := os.ReadDir(dir)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 1 {
t.Errorf("save directory = %v, want no temporary file left behind",
names(entries))
}
}
// names lists directory entry names for a failure message.
func names(entries []os.DirEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
// assertRestorable checks that path holds a save this program can load,
// which is what "the save was taken" has to mean: a file of the right
// size proves nothing about a torn encode.
func assertRestorable(t *testing.T, path string) {
t.Helper()
_, err := Restore(path, Params{Term: &testTerm{}})
if err != nil {
t.Errorf("the saved file does not restore: %v", err)
}
}
// mkBlockedGame builds a game with a save file name and a terminal whose
// reads block, for the tests that park the game goroutine.
func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
t.Helper()
g := New(Params{Seed: 4242, Term: term})
g.NewLevel()
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
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.
type blockingTerm struct {
keys chan byte
wake chan struct{}
}
func newBlockingTerm() *blockingTerm {
return &blockingTerm{
keys: make(chan byte),
// Buffered by one and posted to without blocking, the same
// contract term.Tcell.Interrupt has with tcell's event queue: an
// interrupt that arrives before the read still wakes it.
wake: make(chan struct{}, 1),
}
}
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.
func (t *blockingTerm) Interrupt() {
select {
case t.wake <- struct{}{}:
default:
}
}
// ReadChar blocks until a key arrives or Interrupt wakes it.
func (t *blockingTerm) ReadChar() (byte, bool) {
select {
case ch := <-t.keys:
return ch, true
case <-t.wake:
return 0, false
}
}
// shellTerm is a blockingTerm that also offers a shell escape which stays
// in the shell until the test lets it out.
type shellTerm struct {
*blockingTerm
entered chan struct{}
release chan struct{}
}
// ShellEscape parks the caller in the "shell" until released.
func (t *shellTerm) ShellEscape() {
close(t.entered)
<-t.release
}
// errShellResume is what panickingShellTerm panics with, standing in for
// the value term.Tcell.ShellEscape raises when Screen.Resume fails.
const errShellResume = "resume failed"
// panickingShellTerm is a blockingTerm whose shell escape panics on the
// way out, the way term.Tcell.ShellEscape does when the screen cannot be
// resumed. It records whether Fini ran, which is the thing that must
// still happen.
type panickingShellTerm struct {
*blockingTerm
restored bool
}
func (t *panickingShellTerm) Fini() { t.restored = true }
func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }

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

@@ -8,6 +8,13 @@ package game
func (g *RogueGame) command() { func (g *RogueGame) command() {
p := &g.Player p := &g.Player
// Between turns is the one point in the loop where the game state is
// whole, so it is where a signal-triggered autosave is answered when
// the game goroutine is busy rather than waiting for a key (issue
// #24). The other service points are readchar (io.c) and
// runShellEscape, covering the two ways this goroutine can be parked.
g.serviceAutoSaveRequest()
ntimes := 1 // number of player moves ntimes := 1 // number of player moves
if p.On(Hasted) { if p.On(Hasted) {
ntimes++ ntimes++
@@ -391,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). // wizardCommand handles the MASTER debug commands (command.c).
func (g *RogueGame) wizardCommand(ch byte) { func (g *RogueGame) wizardCommand(ch byte) {
p := &g.Player p := &g.Player
@@ -890,7 +927,7 @@ func (g *RogueGame) shell() {
if se, ok := g.scr.term.(interface{ ShellEscape() }); ok { if se, ok := g.scr.term.(interface{ ShellEscape() }); ok {
g.InShell = true g.InShell = true
se.ShellEscape() g.runShellEscape(se)
g.InShell = false g.InShell = false
g.refresh() g.refresh()
@@ -898,3 +935,58 @@ func (g *RogueGame) shell() {
g.msg("shell escape is not available") g.msg("shell escape is not available")
} }
} }
// runShellEscape runs the shell and returns when it exits, answering
// signal-triggered autosave requests in the meantime (issue #24).
//
// The shell blocks for as long as the player is away — minutes, or until
// they forget — and a line dropping while they are in it is exactly the
// case SIGHUP autosave exists for, so this goroutine cannot simply sit
// inside the call. The shell runs on a helper goroutine and the game
// goroutine waits here, still the only one that ever encodes game state.
// It draws nothing while it waits, so the suspend/resume dance is as
// undisturbed as it was when it ran inline (ARCHITECTURE.md section 9).
//
// A panic out of ShellEscape must not be allowed to unwind on the helper
// goroutine. term.Tcell.ShellEscape panics when Screen.Resume fails, and
// a panic reaching the top of any goroutine kills the process without
// running any *other* goroutine's deferred calls — which is where
// cmd/rogue/main.go's `defer t.Fini()` lives. Running the shell off the
// game goroutine would therefore have left the tty raw on exactly the
// path where the terminal is already broken, reintroducing issue #12 on a
// path this change created. So the helper recovers, and the value is
// re-raised below on the game goroutine, whose stack does have Fini in
// it. The recover deferral is registered after `defer close(done)` and so
// runs before it, which is what publishes panicVal to the reader.
func (g *RogueGame) runShellEscape(se interface{ ShellEscape() }) {
done := make(chan struct{})
var panicVal any
go func() {
defer close(done)
defer func() {
panicVal = recover()
}()
se.ShellEscape()
}()
for {
select {
case <-done:
if panicVal != nil {
// Re-raised here so the unwind passes through the game
// goroutine's deferred Fini. shell()'s InShell reset and
// refresh are skipped deliberately: there is no screen
// left to draw into.
panic(panicVal)
}
return
case req := <-g.sigSave:
g.runAutoSaveRequest(req)
}
}
}

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) { func TestParseOpts(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -1,6 +1,8 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07) //nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game package game
import "fmt"
// ItemLore is the per-game item identity state: the randomized appearance // ItemLore is the per-game item identity state: the randomized appearance
// names and the seven mutable ObjInfo tables (extern.c/init.c). // names and the seven mutable ObjInfo tables (extern.c/init.c).
type ItemLore struct { type ItemLore struct {
@@ -140,10 +142,65 @@ type RogueGame struct {
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
restored bool // game came from a save file; Run skips setup restored bool // game came from a save file; Run skips setup
// sigSave carries signal-triggered autosave requests from the signal
// goroutine to the game goroutine, which is the only one allowed to
// touch the state above (issue #24). Buffered by one: the handler
// reads exactly one signal, so there is never more than one request.
// See AutoSaveOnSignal and serviceAutoSaveRequest in save.go.
sigSave chan *autoSaveRequest
// data is the game's copy of the static tables (extern.c and friends). // data is the game's copy of the static tables (extern.c and friends).
data *gameData 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 // 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 // appearance tables (the front half of main.c main(); the player roll-up
// and first level arrive with later porting phases). // and first level arrive with later porting phases).
@@ -161,6 +218,7 @@ func New(params Params) *RogueGame {
Depth: 1, Depth: 1,
ScorePath: params.ScorePath, ScorePath: params.ScorePath,
LastScore: -1, LastScore: -1,
sigSave: make(chan *autoSaveRequest, 1),
} }
g.Options = Options{ g.Options = Options{
SeeFloor: true, SeeFloor: true,

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

@@ -166,8 +166,31 @@ func stepOk(ch byte) bool {
// readchar reads and returns a character, checking for gross input errors // readchar reads and returns a character, checking for gross input errors
// (io.c readchar). // (io.c readchar).
//
// Waiting for a key is where the game spends nearly all of its wall
// clock, so it is also where a signal-triggered autosave usually finds
// it: a dropped connection lands while the player is thinking, not
// mid-turn. Terminal.Interrupt wakes the read for exactly that, and the
// 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 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 { func (g *RogueGame) readchar() byte {
ch := g.scr.term.ReadChar() for {
ch, ok := g.scr.term.ReadChar()
if !ok {
g.serviceAutoSaveRequest()
continue
}
if ch == 3 { // ^C if ch == 3 { // ^C
g.quit(0) g.quit(0)
@@ -175,6 +198,7 @@ func (g *RogueGame) readchar() byte {
} }
return ch return ch
}
} }
// statusCache is the set of static shadow variables in io.c status() that // statusCache is the set of static shadow variables in io.c status() that

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

@@ -6,6 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"time"
) )
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C // save.c + state.c — game persistence. The hand-written, XOR-encrypted C
@@ -643,38 +645,208 @@ func (g *RogueGame) askOverwrite() saveAnswer {
} }
} }
// saveFile writes the saved game (save.c save_file). A failed write means // saveFile writes the saved game (save.c save_file).
// a corrupt save, so the file is removed before reporting the error. //
// The snapshot goes to a temporary file in the target's own directory and
// is renamed over the target, so there is no instant at which the player
// has no save file: until the rename the old file is whole, and after it
// the new one is. C wrote straight over the target, and this port did the
// same with a remove in front of it (AutoSave), so a write that failed —
// or a signal-time save cut short by the process dying — could leave the
// player with neither the old save nor a usable new one (issue #24).
//
// The temporary file is fsynced before the rename so its contents reach
// the disk ahead of the directory entry that will point at it. The
// directory itself is not fsynced: that would only matter for a machine
// that loses power in the same instant, and the old save survives that
// case anyway. A process killed mid-encode leaves its temporary file
// behind, which is litter next to a destroyed save file, and the dot
// prefix keeps it out of the way.
func (g *RogueGame) saveFile(path string) error { func (g *RogueGame) saveFile(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
if err != nil { if err != nil {
return err return err
} }
encErr := gob.NewEncoder(f).Encode(g.snapshot()) tmp := f.Name()
closeErr := f.Close()
if encErr != nil || closeErr != nil { writeErr := writeSnapshotFile(f, g.snapshot())
_ = os.Remove(path) // don't leave a corrupt save behind if writeErr != nil {
_ = os.Remove(tmp) // never leave a half-written file behind
return writeErr
}
renErr := os.Rename(tmp, path)
if renErr != nil {
_ = os.Remove(tmp)
return renErr
}
return nil
}
// 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()
}
if encErr == nil {
encErr = f.Chmod(0o400)
}
closeErr := f.Close()
if encErr != nil { if encErr != nil {
return encErr return encErr
} }
return closeErr return closeErr
}
return os.Chmod(path, 0o400)
} }
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM // autoSaveRequest is one signal-triggered autosave in flight: the signal
// (save.c auto_save). Best effort by design: it runs on the way out of a // goroutine posts it and waits, the game goroutine performs the save and
// dying process. // closes done. ok is written before done is closed and read only after,
func (g *RogueGame) AutoSave() { // so the close is the happens-before edge that publishes it.
if g.FileName != "" { type autoSaveRequest struct {
_ = os.Remove(g.FileName) done chan struct{}
_ = g.saveFile(g.FileName) ok bool
}
// AutoSaveOnSignal asks the game goroutine to autosave and waits up to
// timeout for it to finish, reporting whether the save actually ran
// (save.c auto_save, the SIGHUP/SIGTERM handler). It is the only entry
// point the signal goroutine may use, and it deliberately touches no game
// state: the gob encoder used to walk the live game tree from the signal
// goroutine while the game goroutine was mid-turn mutating it (issue
// #24).
//
// Blocked on input is the case that matters, since a dropped connection
// is the whole reason the handler exists: the request is posted first and
// the input read is then interrupted, so a game goroutine parked in
// ReadChar wakes, saves in readchar, and reads again. A game goroutine
// that is running turns instead picks the request up between turns, in
// command; one parked in the `!` shell escape picks it up in
// runShellEscape.
//
// The wait is bounded because the signal goroutine's job is to get the
// process out. If the game goroutine is somewhere with no service point
// at all, the deadline expires, this reports false, and the caller
// restores the terminal and exits — leaving the player's previous save
// file exactly as it was, which is the point of the rename in saveFile.
func (g *RogueGame) AutoSaveOnSignal(timeout time.Duration) bool {
req := &autoSaveRequest{done: make(chan struct{})}
select {
case g.sigSave <- req:
default:
// A request is already queued and unserviced, or there is no
// game loop to service one; either way this one would not be
// answered either.
return false
} }
g.scr.Interrupt()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-req.done:
return req.ok
case <-timer.C:
return false
}
}
// serviceAutoSaveRequest performs a pending signal-triggered autosave, if
// one is waiting, and otherwise returns at once. It runs on the game
// goroutine — that is the whole design — so it must only be called where
// that goroutine is not itself inside the encode: between turns, or while
// parked waiting for input or for the shell escape.
//
// What is guaranteed, exactly: the encode runs on the one goroutine that
// owns the state, so the snapshot is internally consistent and always
// restorable. It is *not* guaranteed to be a between-commands snapshot.
// Only one of the three service points gives that: the check at the top
// of command, which runs after the previous command returned and before
// this turn's DoDaemons(Before)/DoFuses(Before). The other two are both
// reached from inside a command call already under way, and both cost
// the same on restore.
//
// readchar is reached from prompts raised part-way through a command —
// --More-- on the second message of a turn, askOverwrite, getStr, the
// direction and pack prompts — and by then the command has already
// mutated state: fight sets g.Count and g.Quiet and runs runTo before
// any message, revealXeroc writes tp.Disguise before emitting one. The
// ordinary top-of-turn key read in readCommand is inside command too,
// after that turn's BEFORE daemons and turnUpkeep.
//
// runShellEscape is no safer. shell is an ordinary command handler ('!'
// in the tables.go dispatch table), reached through executeCommand, so a
// goroutine parked in the shell escape has already run this turn's
// DoDaemons(Before), DoFuses(Before), turnUpkeep and the last-command
// bookkeeping, and has not yet run DoDaemons(After), DoFuses(After) or
// ringTurnEffects.
//
// The cost, at both: restoring re-enters playit at the top of command,
// 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. 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.
//
// 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:
g.runAutoSaveRequest(req)
default:
}
}
// runAutoSaveRequest answers one request: save, then release the waiter.
func (g *RogueGame) runAutoSaveRequest(req *autoSaveRequest) {
req.ok = g.autoSave()
close(req.done)
}
// autoSave silently saves to the current file name (save.c auto_save),
// reporting whether it wrote a save. Game-goroutine only — reach it
// through AutoSaveOnSignal from anywhere else.
//
// The error is not surfaced: there is no player to tell, since the
// terminal is on its way out, and nothing sensible to do about it. It is
// reported to the waiting signal goroutine as a failed save rather than
// discarded outright, which is what the old `_ =` here used to do.
func (g *RogueGame) autoSave() bool {
if g.FileName == "" {
return false
}
return g.saveFile(g.FileName) == nil
} }
// ErrSaveOutOfDate reports a save file from an incompatible version. // ErrSaveOutOfDate reports a save file from an incompatible version.
@@ -746,6 +918,7 @@ func Restore(path string, params Params) (*RogueGame, error) {
FileName: path, FileName: path,
rogueOpts: params.RogueOpts, rogueOpts: params.RogueOpts,
restored: true, restored: true,
sigSave: make(chan *autoSaveRequest, 1),
} }
g.scr = NewScreen(params.Term) g.scr = NewScreen(params.Term)
g.Msgs.attach(g.scr, g.look, g.readchar) g.Msgs.attach(g.scr, g.look, g.readchar)

View File

@@ -14,8 +14,24 @@ type Terminal interface {
// Render blits the window to the device. // Render blits the window to the device.
Render(w *Window) Render(w *Window)
// ReadChar blocks for the next key, translated to Rogue's input bytes // ReadChar blocks for the next key, translated to Rogue's input bytes
// (arrows become hjkl, control keys their C0 codes). // (arrows become hjkl, control keys their C0 codes). ok is false when
ReadChar() byte // the read was woken by Interrupt instead of by a key, which is how a
// signal-triggered autosave reaches a game parked on input; the byte
// is meaningless then.
ReadChar() (ch byte, ok bool)
// Interrupt wakes a ReadChar that is blocked waiting for a key. It is
// 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 // 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. // game calls it on its way out, since one game run is one process.
Fini() Fini()
@@ -216,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). // Fini restores the terminal device, if there is one (curses endwin).
func (s *Screen) Fini() { func (s *Screen) Fini() {
if s.term != nil { if s.term != nil {
@@ -223,6 +247,15 @@ func (s *Screen) Fini() {
} }
} }
// Interrupt wakes a device read that is blocked waiting for a key, if
// there is a device. Called from the signal goroutine; everything else on
// Screen belongs to the game goroutine.
func (s *Screen) Interrupt() {
if s.term != nil {
s.term.Interrupt()
}
}
// RefreshWin pushes an arbitrary window to the device (curses wrefresh). // RefreshWin pushes an arbitrary window to the device (curses wrefresh).
func (s *Screen) RefreshWin(w *Window) { func (s *Screen) RefreshWin(w *Window) {
if s.term != nil { if s.term != nil {
@@ -247,3 +280,4 @@ func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
func (g *RogueGame) clear() { g.scr.Std.Clear() } func (g *RogueGame) clear() { g.scr.Std.Clear() }
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() } func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
func (g *RogueGame) refresh() { g.scr.Refresh() } 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 return
} }
// A malformed Which yields no handler, which is exactly what C did in a // C's switch has a case for every one of the 14 WS_ kinds, so its
// non-MASTER build: its do_zap switch matched no case, fell out, and // closing "otherwise: msg(...)" arm is reachable only for an o_which
// still ran o_charges--. (The MASTER-only "what a bizarre schtick!" // outside the table — the malformed objects hasValidWhich screens
// message for that arm is issue #13, not this bounds fix.) // for, which is why no handler and a legal Which can only mean WS_NOP.
if h := g.data.zapHandler(obj); h != nil { //
// 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) { if !h(g, obj) {
return // the zap aborted; no charge is used return // the zap aborted; no charge is used
} }
case obj.hasValidWhich(): // WS_NOP
default:
g.msg("what a bizarre schtick!")
} }
obj.Charges-- 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) { CTRL('R'): func(g *RogueGame) {
g.After = false 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) { 'v': func(g *RogueGame) {
g.After = false g.After = false
@@ -802,6 +805,7 @@ func newGameData() *gameData {
g.After = false // "legal" illegal command g.After = false // "legal" illegal command
}, },
'^': (*RogueGame).identifyTrapCommand, '^': (*RogueGame).identifyTrapCommand,
'+': (*RogueGame).wizardToggleCommand,
Escape: func(g *RogueGame) { Escape: func(g *RogueGame) {
g.DoorStop = false g.DoorStop = false
g.Count = 0 g.Count = 0

View File

@@ -8,24 +8,36 @@ type testTerm struct {
input []byte input []byte
pos int pos int
tick 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) Render(*Window) {}
func (t *testTerm) Repaint() { t.repaints++ }
func (t *testTerm) Fini() {} func (t *testTerm) Fini() {}
func (t *testTerm) ReadChar() byte { // Interrupt has nothing to wake: this terminal's ReadChar never blocks.
// The blocking case has its own fake, blockingTerm in autosave_test.go.
func (t *testTerm) Interrupt() {}
func (t *testTerm) ReadChar() (byte, bool) {
if t.pos < len(t.input) { if t.pos < len(t.input) {
c := t.input[t.pos] c := t.input[t.pos]
t.pos++ t.pos++
return c return c, true
} }
t.tick++ t.tick++
if t.tick%2 == 0 { if t.tick%2 == 0 {
return '\n' return '\n', true
} }
return ' ' return ' ', true
} }

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,9 +78,20 @@ func (t *Tcell) Render(w *game.Window) {
t.screen.Show() 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 // ReadChar blocks for the next key, translated to the byte codes the C
// game reads: arrows become hjkl, control keys their C0 codes. // game reads: arrows become hjkl, control keys their C0 codes. ok is
func (t *Tcell) ReadChar() byte { // false when Interrupt woke the read instead of a key arriving.
func (t *Tcell) ReadChar() (byte, bool) {
for { for {
ev := t.screen.PollEvent() ev := t.screen.PollEvent()
switch ev := ev.(type) { switch ev := ev.(type) {
@@ -88,14 +99,32 @@ func (t *Tcell) ReadChar() byte {
if t.last != nil { if t.last != nil {
t.Render(t.last) t.Render(t.last)
} }
case *tcell.EventInterrupt:
// Interrupt posted this from the signal goroutine: hand
// control back so the game goroutine can service a pending
// autosave, then it reads again.
return 0, false
case *tcell.EventKey: case *tcell.EventKey:
if b, ok := translateKey(ev); ok { if b, ok := translateKey(ev); ok {
return b return b, true
} }
} }
} }
} }
// Interrupt wakes a ReadChar parked in PollEvent by posting an interrupt
// event onto tcell's own event queue — the mechanism tcell provides for
// exactly this, and the only Tcell method called from another goroutine
// (Screen.PostEvent is a channel send, safe to call concurrently).
//
// Best effort by design: PostEvent fails only when the event queue is
// full, which means the game goroutine is not parked waiting for a key,
// and a game goroutine that is running turns reaches the between-turns
// check on its own.
func (t *Tcell) Interrupt() {
_ = t.screen.PostEvent(tcell.NewEventInterrupt(nil))
}
// translateKey converts a key event to a game input byte; ok is false // translateKey converts a key event to a game input byte; ok is false
// for keys the C game does not understand. // for keys the C game does not understand.
func translateKey(ev *tcell.EventKey) (byte, bool) { func translateKey(ev *tcell.EventKey) (byte, bool) {