91 Commits

Author SHA1 Message Date
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
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
4aa4babe40 Merge fix/wizard-which-bounds (bound wizard-created item kinds) 2026-08-09 07:34:54 +02:00
af3050b187 fix: bound wizard-created Which against its item table (closes #10)
createObj stored the raw 0-f nibble as Object.Which with no bounds
check, so wizard mode -> C -> / -> f made a wand numbered 15 against a
14-entry table and panicked in fixStick. Input outside 0-f overshoots
much further rather than going negative: readchar returns a byte, so
the int(ch-'a') + 10 branch is byte arithmetic and wraps, giving 234
for 'A' and 202 for '!', and panicked the same way. C's create_obj()
was equally unchecked, but its consumers were either switches (defined
for any value) or static-array reads past the end (undefined, and
survivable in practice). Since one game is now one process, the Go
panic kills the game outright and leaves the terminal in raw mode.

Reject at the two boundaries a bad Which can enter through. createObj
now refuses an out-of-range choice with a message drawn from C's own
type_name() vocabulary and adds nothing to the pack, a deliberate
divergence recorded in a comment because C had no defined behavior here
to be faithful to. Restore refuses a snapshot describing such an object
(ErrSaveCorrupt) rather than loading a game that would explode later. A
decoded snapshot is also the only source of a genuinely negative Which,
Which being a plain int off the wire, so it is what the Which >= 0 arm
of hasValidWhich defends against.

Behind those, whichLimit/hasValidWhich back defensive guards at every
dispatch the issue names: the quaffHandler/readHandler/zapHandler
accessors return no handler instead of indexing (for wands that is
exactly what non-MASTER C did, matching no case and still running
o_charges--), the callIt lore lookups, identifyType, armorClass for the
a_class[] reads, initWeapon against the missing init_dam[] row for
WeaponFlame, fixStick's ws_type[] read, and inventoryName and
objectWorth, hoisted so one check each covers the whole family of
per-kind name and appraisal tables. identifyType's bound is defensive
rather than live: readHandlers registers readIdentify only for the
identify scrolls, all of which sit inside the shorter idType table.

No in-range input changes behavior and no guard consumes a random
number: the rejection precedes every rnd() call. TestSeedCompatItemTables
stays green untouched.

New game/wizard_test.go covers the exact reproducer, a rejection sweep
over every indexed kind including the wrapped values from input outside
0-f, an acceptance sweep proving valid choices still build the right
item, one no-panic test per guarded family, the fixStick crash site, the
corrupt-save rejection over both the wrapped values and a negative
Which, and a check that whichLimit still agrees with the table sizes.
Each guard was confirmed load-bearing by reverting it and watching the
test fail.

TODO.md records the step; Next Step is deliberately left alone, since
this arrived out of band via an issue.
2026-08-09 05:24:40 +00:00
eb31473ef0 Merge docs-staleness (correct stale MEMORY/README/TODO claims) 2026-08-09 07:00:35 +02:00
56bcad9fc6 docs: correct stale claims in MEMORY.md, TODO.md, and README.md (closes #3)
Four documented claims had gone false and were actively misdirecting agents
working this repo; the independent reviewer on PR #9 repeated one of them
verbatim. Each claim was re-verified against the tree before rewriting.

MEMORY.md "Error handling" described C's exit() calls being unwound by a
gameEnd panic recovered in Run. Refactor step 8 removed that: gameEnd appears
nowhere in the sources, myExit (game/rip.go) restores the terminal via
Terminal.Fini and calls os.Exit(0), and Run() has no return values and never
returns. The section now states that model and its testing consequence -- a
death exits the test binary, which is why tests drive command() directly and
crash sweeps pin the hero with fortify() in game/run_test.go.

MEMORY.md "Linting" said approved exceptions are recorded in a "Repo-specific
exceptions" block in .golangci.yml. There is no such block: the config is
byte-identical to canonical (sha256 021cc83f...46bcb) and the approvals live in
in-code //nolint directives carrying their dates. Following the old text would
have meant editing the canonical config. The same paragraph listed paralleltest
as an approved disable when it was fixed instead -- no paralleltest token
exists in the tree and all 32 tests call t.Parallel().

MEMORY.md "Debugging" and README.md both told the reader to run go test
directly. Since PR #9 the test target carries -timeout 30s -race -cover, so a
raw invocation silently drops the race detector while appearing to verify the
change. Both now point at make test / make check.

TODO.md asserted the host golangci-lint is "currently v2.12.2". It is v2.10.1
and the repo pins nothing, so the claim documented an accident of one machine.
Only the false claim is removed; the pin question is tracked separately.

Documentation only: no code, Makefile, or config change. Next Step is
deliberately not rotated, per the precedent for out-of-band issue work.
2026-08-09 04:59:00 +00:00
c922a16781 Merge make-test-policy-pattern (mandated test target pattern) 2026-08-09 03:50:26 +02:00
e376b2bf11 build: adopt the mandated test target pattern (closes #2)
The test: target was a bare `go test $(GO_PKGS)`, diverging from the
mandated shape in four ways: no -timeout 30s, no -race, no -cover, and no
conditional verbose rerun. It now runs

    go test -timeout 30s -race -cover $(GO_PKGS)

and, on failure, reruns with -v and then exits 1 — so the build still
fails even if a flaky test happens to pass on the second attempt. The
repo's existing $(GO_PKGS) variable is kept rather than hardcoding ./...,
and the recipe is @-prefixed so the rerun banner is the only noise.

The substance here is -race, not the Makefile edit: this is the first
time the suite has run under the race detector. It is clean, across five
consecutive uncached runs, including the tcell terminal layer and the
os.Exit-path playthrough tests that were the suspected risk.

Timing against the 20-second budget: 5.1s cold (including the race
build), ~2.3s warm. The failure path was exercised with a throwaway
failing test to confirm the verbose rerun fires and make exits non-zero.

Build tooling only; no game behavior change. .golangci.yml is untouched.
2026-08-09 01:42:24 +00:00
d6cd418f38 Merge pull request 'Update golangci-lint to v2.12.2 with canonical config' (#1) from golangci-v2.12.2 into main
Reviewed-on: #1
2026-08-07 23:24:38 +02:00
63d1e797e2 chore(lint): adopt canonical golangci-lint config
Replace .golangci.yml with the shared canonical config. The old
config's top-level linters-settings block was silently ignored under
the v2 schema, so the lll/funlen/cyclop/dupl thresholds now actually
apply. The four repo-specific disables (mnd, exhaustive, paralleltest,
testpackage) move out of the config into targeted in-code nolint
directives carrying their original approval dates, keeping the config
byte-identical to the canonical one.

Fixes surfaced by the stricter settings: t.Parallel() added to all 32
tests, 24 overlong lines wrapped or their comments tightened, tcell
control-code returns rewritten as character literals, dupl markers on
the identically-shaped item data tables, and two wsl_v5 defer cuddles.

No behavior changes. The repo has no golangci-lint version pin (no
Dockerfile or CI; make lint runs the host binary, currently v2.12.2),
so there was nothing to bump.
2026-08-07 20:45:02 +00:00
8ce238dd62 Merge seed-compat (item-table cross-validation vs C reference) 2026-07-24 03:05:39 +07:00
c30da22e43 Rotate TODO to coverage-broadening step (seed-compat item tables done) 2026-07-24 03:05:39 +07:00
e595b87718 Cross-validate item appearance tables against the C reference
Instrumented the C game on modern-rogue with a DUMP mode (patch in
testdata/c_seedcompat.patch) that forces the RNG seed and prints the
per-seed item appearance tables — potion colors, scroll names, ring
stones, wand/staff materials — in the normal init order, before initscr
so no terminal is needed. Captured its output for four seeds as
testdata/item_tables.golden.

TestSeedCompatItemTables regenerates the same tables from the Go port
via New(Params{Seed, Wizard: true}) and checks they match the golden
byte for byte. They do, for all four seeds — proving the LCG and its
consumption order through the whole init sequence (init_probs →
init_player → init_names → init_colors → init_stones → init_materials,
including init_player's arrow rnd(8)+rnd(15)) agree with C exactly.

testdata/README.md documents how to regenerate the golden.
2026-07-24 03:05:01 +07:00
11223caa7c Merge playtest-hardening (deep playthrough + crash sweep tests) 2026-07-23 08:59:03 +07:00
e7e1bc3c40 Rotate TODO to seed-verification step (playtest hardening done) 2026-07-23 08:59:03 +07:00
061da11877 Add deep-playthrough and turn-loop crash-sweep tests (playtest hardening)
Two death-safe regression drives that exercise the full turn loop within the
step-8 os.Exit constraint (a fortify() helper pins HP/food/exp and clears the
freeze/stuck counters each turn, so no death exits the test binary; fixed seeds
keep them deterministic):

- TestDeepPlaythrough: quaff/read/zap through command dispatch, then descend the
  staircase to depth 8 with a save/restore at depth 4 — a crash sweep of deep
  level generation, item effects, and mid-game save/restore. It asserts the
  consumables identify themselves (the commands really ran) and the descent and
  restore land where expected.
- TestTurnLoopCrashSweep: mash movement/search/rest for 200 turns on four seeds,
  exercising combat, monster AI, and traps.

Neither surfaced a panic. Space-separated command scripts answer the --More--
prompts, as wait_for consumes input up to a space.
2026-07-23 08:58:05 +07:00
0e6ed41351 Merge docs-refresh (ARCHITECTURE.md Part 2 + rename table) 2026-07-23 08:40:55 +07:00
b431af8b74 Rotate TODO to playtest step (docs refresh done) 2026-07-23 08:40:55 +07:00
cb1e302102 Refresh ARCHITECTURE.md Part 2 for the post-refactor design
Part 2 was written as a design sketch before the port was implemented and
refactored, so much of it described the planned code rather than the final
code. Updated the RogueGame/Stats/Object/Flags/Level sketches to the current
names and types (typed ObjectKind, DiceSpec, split o_arm fields, step-1 flag
names, TrapCount, Level list methods); rewrote §4.7 to say the static tables
now live on the per-game gameData struct (no package globals); noted the
daemon and effect handler tables (step 7), the MessageLine extraction (step 6),
the Terminal interface, the flat gob SaveState, and the New(Params) +
os.Exit-on-game-over design (step 8). Added §7.1, a C-name → Go-name rename
table, and a README note about the make targets. Docs only.
2026-07-23 08:40:15 +07:00
bcdfaf4ab4 Merge refactor/constructor-style (refactor step 8: New/Params + os.Exit) 2026-07-23 08:02:40 +07:00
a7d27ef65f Rotate TODO to docs-refresh step (step 8 done)
Step 8 complete: New(Params) constructor and os.Exit game-over. The
77-column wrap sweep was dropped per sneak. Docs refresh is now Next
Step.
2026-07-23 08:02:35 +07:00
194ce1dd16 Exit the process on game-over instead of unwinding a panic
One game run is one process, so game-over ends the process directly,
as the C game did with exit(). myExit now restores the terminal
(via the new Terminal.Fini) and calls os.Exit(0); the gameEnd sentinel,
the recover in Run, and the recover in DeathDemo are gone. Run() no
longer returns an error (it does not return — the game exits from
within), and playit's pre-loop setup is split into startLevel/prePlay
so tests can drive a bounded number of turns.

Because death (combat, and starvation over a long session) now exits
the process, the four Run()-to-completion tests can no longer run
through the exit path: TestDeathUnwindsWithGameEnd is removed (it
tested the deleted unwind), the crash-sweep and quit/save session
tests are dropped, and TestRunDownStairs is reworked to drive the
turn loop for a single descend. Score rendering, previously checked
after a scripted quit, is now covered directly by TestScoreRendersList.
Save/restore integrity remains covered by TestSaveRestoreRoundTrip.
2026-07-23 06:44:39 +07:00
cd0ba6c8ee Rename constructor to game.New(game.Params) per styleguide
NewGame(Config) becomes New(Params), and Restore takes Params too, so
the package's primary type gets the canonical New() constructor with a
named-field Params struct (styleguide points 139, 159). cmd/rogue and
all tests updated; ARCHITECTURE.md constructor references corrected.
Pure rename, suite green.
2026-07-23 05:39:31 +07:00
8241cf4bee Merge add-make-targets (dev Makefile + markdown formatting) 2026-07-22 22:20:45 +07:00
35b538e888 Add dev Makefile; format all markdown with prettier
Adds a minimal Makefile wrapping the toolchain the way sneak's other
repos expose it:

- fmt        gofmt -w plus prettier (4-space tabs, proseWrap: always)
- fmt-check  fail if any Go or Markdown file is unformatted
- lint       golangci-lint run ./...
- test       go test ./...
- check      fmt-check + lint + test (the local pre-commit gate)

Running make fmt normalizes the four existing Markdown docs to the
shared prettier style (80-column proseWrap: always, aligned tables) —
a one-time reflow with no content change. The repo remains exempt from
the rest of the policy scaffold (no Dockerfile, CI, or REPO_POLICIES).
2026-07-22 22:20:34 +07:00
88f18fc635 Merge refactor/effects-dispatch (refactor step 7) 2026-07-22 22:18:11 +07:00
ad098f9d99 Rotate TODO to step 8; note complexity linters clean (step 7 done)
Refactor step 7 is complete: the whole golangci-lint run is 0 issues.
MEMORY.md updated to reflect the complexity linters are enabled and
clean.
2026-07-22 22:18:11 +07:00
5b7e258195 Decompose test functions to clear complexity findings (step 7)
TestSaveRestoreRoundTrip, TestNewGameRandomizesAppearances, and
TestNewLevelInvariants split their assertion blocks into t.Helper()
sub-checks. The lint run is now completely clean (0 issues).
2026-07-22 22:11:10 +07:00
5c14a829aa Decompose tcell ReadChar key translation (refactor step 7)
ReadChar's key switch moves into translateKey with namedKey split into
motionKey/editingKey halves. term is complexity-clean. Behavior
unchanged.
2026-07-22 22:08:59 +07:00
b68836dde0 Decompose wizard.go createObj and whatis (refactor step 7)
createObj gains createWeaponArmor/createRing; whatis gains whatisPick
for its prompt loop. wizard.go is complexity-clean. Behavior unchanged.
2026-07-22 22:07:31 +07:00
730d91d160 Decompose ObjectKind.String and doMotion (refactor step 7)
String's tail moves into stringRest; doMotion's erase step moves into
eraseFlight and the inverted loop drops a nesting level. Behavior
unchanged.
2026-07-22 22:06:48 +07:00
71713d68b7 Decompose io.go End and status (refactor step 7)
End's --More-- handling moves into promptMore; status's redraw-skip
check moves into statusUnchanged. io.go is complexity-clean. Behavior
unchanged.
2026-07-22 22:05:06 +07:00
444bc30f2c Decompose ringOn and totalWinner (refactor step 7)
ringOn gains pickRingHand and chooseTerse messages; totalWinner's
appraisal switch becomes objectWorth with loreWorth/ringWorth/
wandWorth. Behavior unchanged.
2026-07-22 22:03:31 +07:00
ff7ee95395 Finish score/wakeMonster tidy (scoreSlot; drop unused return) 2026-07-22 22:02:02 +07:00
8895db530d Decompose score and wakeMonster (refactor step 7)
score splits into scoreInsert/scoreLines/showScores; wakeMonster gains
meanWakes/medusaCatches/medusaGaze predicates and effect. Behavior and
RNG call order unchanged.
2026-07-22 22:01:03 +07:00
3e1c30c787 Flatten digWallGap 2026-07-22 21:59:44 +07:00
432ea4f019 Decompose rooms.go (refactor step 7)
digRooms splits into digRoom/placeGoneRoom/placeMazeRoom/
placeNormalRoom/roomGold/roomMonster; dig gains digPick/digWallGap;
findFloorImpl gains floorChar; enterRoom and leaveRoom gain per-cell
helpers. rooms.go is complexity-clean. Behavior and RNG call order
unchanged.
2026-07-22 21:59:24 +07:00
bac9e361bc Decompose daemons.go; daemon dispatch becomes a table (step 7)
runDaemon's switch becomes gameData.daemonHandlers (the C d_func
function pointers restored as method expressions); stomach splits into
stomachFaint/stomachDigest; visuals gains visualMonsters. daemons.go
is complexity-clean. Behavior and RNG call order unchanged.
2026-07-22 21:57:22 +07:00
9083967ed3 Split applyHeader turn-state half; drop inline stat err 2026-07-22 21:55:04 +07:00
80484bcd31 Finish save.go split: header/player halves, drop inline err 2026-07-22 21:54:41 +07:00
43b4fbe746 Decompose save.go (refactor step 7)
saveGame splits into askDefaultSave/saveFileName/saveCheckOverwrite/
askOverwrite around a saveAnswer tri-state; snapshot gains destRefFor;
applySnapshot gains applyMonsters/applyDests. save.go is
complexity-clean. Behavior unchanged.
2026-07-22 21:53:02 +07:00
389db14bbf Finish addLine split (addLinePageBreak) 2026-07-22 21:51:06 +07:00
e1f065e783 Decompose things.go (refactor step 7)
inventoryName splits into nameScroll/nameFood/nameWeapon/nameArmor/
describeWorn/fixNameCase; newThing gains newFoodThing/newWeaponThing/
newArmorThing/newRingThing; dropCheck gains dropRing; addLine splits
into addLineSlow/addLinePaged/addLineOverlay. things.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-22 21:50:44 +07:00
0b798c9c82 Tidy pack.go split: drop unused lp param and named returns 2026-07-07 03:31:53 +02:00
0274460e62 Decompose pack.go (refactor step 7)
addPack splits into pickupScareScroll and packInsert with
packScanKind/packScanWhich/packMatch/packMatchGroup for the C
linked-list walk; promptPackItem gains repeatLastItem and
promptItemPurpose; inventory's empty-handed messages flatten via
chooseTerse. pack.go is complexity-clean. Behavior unchanged.
2026-07-07 03:31:23 +02:00
c6dae3cf3d Finish getStr split (getStrResult) 2026-07-07 03:28:30 +02:00
5849dddcf0 Decompose options.go (refactor step 7)
ParseOpts splits into parseOptName/parseOptValue/parseInvType; getStr
gains endsInput/getStrErase/getStrEdit. options.go is complexity-clean.
Behavior unchanged.
2026-07-07 03:27:56 +02:00
cc2efb86e8 Decompose passages.go (refactor step 7)
digPassages gains pickNeighbor; connectRooms splits into
connOrient/connPlanDown/connPlanRight/connEnd/digCorridor around a
corridorPlan struct; addPass gains addPassSpot; the shared door/
secret-door predicate becomes hiddenExit. passages.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-07 03:25:46 +02:00
ea68df32f0 Decompose move.go; traps become a handler table (refactor step 7)
The be_trapped switch becomes gameData.trapHandlers with one trap*
method per trap kind (mystery messages split in two); moveHero splits
into moveTarget/moveResolve/moveEnter/moveOnto/offMap; passageTurn
gains per-axis passageTurnVertical/Horizontal. move.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-07 03:22:54 +02:00
fec79b939a Decompose chase.go (refactor step 7)
chase splits into chaseBestSpot/chaseTry/scareScrollAt with a
chaseSearch state struct; chaseStep gains chaseRooms, chaseGoal,
dragonBreath/dragonShoots, and chaseTakeObject; runners gains
runnerTurn; findDest gains objectClaimed. chase.go is complexity-clean.
Behavior and RNG call order unchanged.
2026-07-07 03:19:31 +02:00
aa57349c34 Decompose command.go; command keys become a handler table (step 7)
The ordinary command keys move into gameData.commandHandlers (method
expressions and small literals); dispatchKey keeps only re-dispatching
prefixes (runCommand/fightCommand/repeatCommand/moveOnCommand) and the
wizard fallthrough. command() splits into playTurn/turnUpkeep/
readCommand/executeCommand/countPrefix/ringTurnEffects; search gains
searchSpot/searchFloor; help gains helpOne/helpAll/helpLines; call
gains callTarget/callPrelude; wizardCommand splits in two plus
wizardKit; uLevel and current flatten to early returns. command.go is
complexity-clean. Behavior and RNG call order unchanged.
2026-07-07 03:15:27 +02:00
4a248eb392 Decompose look and promptDirection (refactor step 7)
look's nine-square scan splits into lookAround/lookCell with a
lookScan state struct and guard helpers (lookSkips,
lookForeignPassage, lookDiagonalBlocked, lookCellChar, lookShow,
lookRunCheck, atRunEdge); promptDirection gains deltaFor and
confuseDirection. misc.go is complexity-clean. Behavior and RNG call
order unchanged.
2026-07-07 03:03:50 +02:00
a20f500655 Decompose fight.go: hit-handler table, weapon/armor helpers (step 7)
The monster special-power switch in attack becomes
gameData.hitHandlers (indexed by monster letter); attack splits into
monsterHit/monsterMiss; fight gains revealXeroc and heroHits;
rollAttacks gains weaponAttack, wieldedRingBonus, and defenderArmor;
killed gains killedSpecial. fight.go is complexity-clean. Behavior and
RNG call order unchanged.
2026-07-07 02:58:44 +02:00
ebe477ba28 Decompose remaining effects-file hot spots (refactor step 7)
drain gains drainReaches; zapSpeed gains hasteTarget/slowTarget;
readHoldMonster gains holdMonstersNear; readCreateMonster gains
createMonsterSpot; revealSpot splits into revealChar/revealWall/
revealSolid/revealFloor; turnSee gains showSensed. potions.go,
scrolls.go, and sticks.go are complexity-clean. Behavior and RNG call
order unchanged.
2026-07-07 02:53:09 +02:00
1a25beead8 Decompose fireBolt (refactor step 7)
The fire_bolt loop splits into boltDirChar, boltBounces,
boltStrikesMonster, and boltStrikesHero; loop state (hitHero/changed/
used) stays in fireBolt. Effect order and RNG calls unchanged.
2026-07-07 02:49:37 +02:00
8e2915f60d Convert doZap to a per-wand handler table (refactor step 7)
The do_zap switch becomes gameData.zapHandlers, indexed by WandKind;
the shared monster-ray preamble is zapRayMonster/zapVictim, teleport
away/to and the three bolt wands share handlers, and a false return
aborts the zap without spending a charge (drain life on a too-weak
hero, as in C). Effect order and RNG call sequence unchanged.
2026-07-07 02:48:01 +02:00
3047f729aa Convert readScroll to a per-scroll handler table (refactor step 7)
The read_scroll switch becomes gameData.readHandlers, indexed by
ScrollKind; the five identify scrolls share one handler. The magic
mapping cell logic is extracted into revealSpot. Effect order and RNG
call sequence unchanged.
2026-07-07 02:46:09 +02:00
cc025eb808 Convert quaff to a per-potion handler table (refactor step 7)
The quaff switch becomes gameData.quaffHandlers, a method-expression
table indexed by PotionKind; each case body moved verbatim into a
quaff* method. Effect order and RNG call sequence unchanged.
2026-07-07 02:44:33 +02:00
acef593288 Merge refactor/god-object-extraction (refactor step 6) 2026-07-07 02:42:18 +02:00
0b56ac8019 Extract MessageLine, Player pack ops, and Level list management
Refactor step 6. MessageLine (was MsgLine) owns the msg/addmsg/endmsg
machinery, wired to its screen, pre---More-- redraw, and input via
attach(); RogueGame keeps one-line msg/addmsgf/endmsg shorthands so
the ~400 call sites are unchanged. Player gains nextPackChar and
removeFromPack (the state half of pack.c leave_pack); leavePack keeps
only the LastPick repeat-command tracking. Level gains ObjectAt
(misc.c find_obj) and AddObject/RemoveObject/AddMonster/RemoveMonster,
replacing direct attach/detach calls on the level lists. Inventory and
pickup UI flows stay on RogueGame: display and orchestration, not
state surgery. Behavior and RNG order unchanged; suite green.
2026-07-07 02:42:18 +02:00
a094f7c6c3 Merge refactor/fix-nonamedreturns 2026-07-07 02:35:18 +02:00
0caaa14198 Drop unused named return on moveMonster (nonamedreturns) 2026-07-07 02:35:18 +02:00
d3ef07cfa7 Merge refactor/item-combat-ui-renames (refactor step 5) 2026-07-07 02:34:32 +02:00
f432c8718c Rename getDir to promptDirection; rotate TODO (step 5 done) 2026-07-07 02:34:32 +02:00
ae79fd5e84 Rename combat methods; -1 status codes become named bool results
Refactor step 5, combat: rollEm→rollAttacks; attack, moveMonster, and
chaseStep return (removed bool) instead of the C -1/0 int codes.
Behavior unchanged; suite green.
2026-07-07 02:33:15 +02:00
6d798c56ed Rename item-subsystem methods (refactor step 5, items)
getItem→promptPackItem now returns (obj, ok) instead of a nil-signaling
pointer; invName→inventoryName; doPot→applyPotionFuse. C breadcrumbs
kept. Behavior unchanged; suite green.
2026-07-07 02:31:38 +02:00
0554f5d4f1 Merge refactor/movement-renames (refactor step 4) 2026-07-07 02:29:06 +02:00
6850c87ae7 Rename movement/world methods to idiomatic Go; remove all gotos
Refactor step 4. Renames (C breadcrumbs kept in doc comments):
doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
doRooms→digRooms, doPassages→digPassages, doMaze→digMaze,
chgStr→changeStrength, doRun→startRun, moveStuff→finishMove,
turnref→turnRefresh, moveMonst→moveMonster, doChase→chaseStep,
setOldch→setOldChar, cansee→canSee, roomin→roomIn, runto→runTo,
conn→connectRooms, putpass→putPassage, passnum→numberPassages,
numpass→numberPassage, rndPos→randomPos, rndRoom→randomRoom,
treasRoom→treasureRoom, accntMaze→accountMaze.

All goto/label flows are gone: moveHero uses a retry loop with the
PASSGO corner logic extracted into passageTurn; dispatch re-dispatches
via a loop; chaseStep re-checks via a loop; saveGame uses a labeled
prompt loop. Control flow and RNG call order are unchanged; suite
green.
2026-07-07 02:29:06 +02:00
65a1cd68b8 Merge refactor/drop-unused-nolints 2026-07-07 02:17:25 +02:00
525465a68b Drop two unused gosec nolint directives in chooseSeed
The 0x7fffffff mask makes both int32 conversions provably safe, so
G115 never fired; nolintlint flags the directives as unused.
2026-07-07 02:17:25 +02:00
a49d857970 Merge refactor/lint-mnd-disable (mnd disabled per approval) 2026-07-07 02:16:38 +02:00
32067eb318 Disable mnd with sneak's approval (2026-07-07)
The 289 findings are C-faithful gameplay literals (probability rolls,
damage spreads, screen coordinates); naming them would invent constants
the C never had and hurt greppability against the reference sources.
2026-07-07 02:16:38 +02:00
d6aa74d9f1 Merge refactor/no-package-globals (all globals into gameData, lint adoption done) 2026-07-07 02:11:04 +02:00
a8feb6c05d Move all package-level vars into gameData; finish lint adoption
gochecknoglobals: all 37 package-level tables consolidated into the
gameData struct (game/tables.go), built by newGameData() and carried
on RogueGame as g.data (set in NewGame and Restore). ObjectKind
Glyph()/objectKindForGlyph are now switches; the table-reading subtype
Stringer methods are gone; isMagic is a RogueGame method.

goconst: repeated words named (potionName/scrollName/ringName/goldName
in object.go, wandName/staffName in sticks.go, ripWall in tables.go).

exhaustive, testpackage: disabled in .golangci.yml with sneak's
approval (2026-07-07).

Also reverts misspell's silent corruption of the "ther" scroll-name
syllable (it had become "there", changing generated scroll names vs C).

Remaining red: cyclop/gocognit/nestif until refactor step 7; mnd
awaits a ruling. TODO.md rotated; MEMORY.md lint notes updated.
2026-07-07 02:10:58 +02:00
50afbec8e3 Merge refactor/lint-adoption (house lint config, findings fixed) 2026-07-07 00:03:45 +02:00
5ba9fe8f66 Adopt house golangci-lint config; fix all approved-linter findings
.golangci.yml is the prompts-repo standard verbatim plus one approved
exception (paralleltest, sneak 2026-07-06). Roughly 1,500 findings
fixed:

- autofix sweep (wsl_v5/nlreturn/intrange/modernize formatting), with
  the misspell autofix REVERTED where it rewrote authentic C game text
  ("missle vanishes" stays, with nolint and a comment)
- error handling: errcheck sites get real handling — saveFile now
  closes explicitly and removes corrupt saves, scoreboard writes are
  documented best-effort, err113 sentinel errors (ErrSaveOutOfDate,
  ErrScreenTooSmall); panics allowed for unrecoverable states per
  MEMORY.md policy
- gosec: real fixes (ParseInt for SEED, 0600 scorefile) and justified
  per-line nolints for provably-bounded conversions (randomMonsterLetter
  helper collapses eight rnd(26)+'A' sites)
- API tidying from linters: pointer receivers on flag types
  (recvcheck), msg helpers renamed addmsgf/doaddf/Printwf/MvPrintwf
  (goprintffuncname), myExit()/findFloor(monst)/mkGameInput(t) drop
  always-constant params (unparam), gameEnd carries no status
- gocritic/staticcheck: if-else chains to switches, the pack.c
  inventory filter untangled into matchesFilter, main.go split so
  defers run before exit (exitAfterDefer, funlen)
- revive doc comments on all exported flag types/consts/methods

Remaining findings are confined to linters pending sneak's exception
decision (mnd, gochecknoglobals, cyclop, nestif, gocognit, exhaustive,
goconst, testpackage); TODO.md records the state. MEMORY.md added at
repo root as the project's agent working notes.
2026-07-07 00:03:45 +02:00
66 changed files with 16229 additions and 5249 deletions

34
.golangci.yml Normal file
View File

@@ -0,0 +1,34 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
max-issues-per-linter: 0
max-same-issues: 0

File diff suppressed because it is too large Load Diff

88
MEMORY.md Normal file
View File

@@ -0,0 +1,88 @@
# Project Memory
Working notes for agents on this repo. Read this alongside TODO.md (which holds
the step queue and workflow) before starting work.
## Error handling
Panicking on bad/unexpected errors is allowed and preferred over threading
unlikely error returns through game code — e.g. write-side Close/encode failures
where continuing would mean corrupt state. Return errors where a caller
genuinely handles them (save-file prompts, restore validation). Reserve
deliberate `_ =` discards for true best-effort paths (scorefile writes,
`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
(game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and
Run() never returns. There is nothing to recover — do not write code that
expects to regain control after game-over. The testing consequence is that any
death (combat, starvation, level drain, freezing) exits the _test binary_, so
tests drive command() directly rather than Run(), and crash-sweep drives pin the
hero each turn with the fortify() helper in game/run_test.go.
## Linting
The .golangci.yml is byte-identical to the canonical shared config and must not
be edited — not even to add an exception. To disable a linter, ask sneak,
explaining what the linter does; approved exceptions are recorded as in-code
//nolint directives (file-level where a whole file is affected) carrying the
approval date, which is what keeps the config canonical. Approved so far:
testpackage, exhaustive, and mnd (2026-07-07). paralleltest was approved on
2026-07-06 but the exception is no longer in force — it was fixed instead, with
t.Parallel() in all 32 tests. The complexity linters (cyclop, gocognit, nestif)
are enabled and clean as of refactor step 7 (2026-07-07): the whole
golangci-lint run is 0 issues, so keep it that way — decompose new hot spots
rather than reaching for a nolint. Line-level //nolint with a reason is used
sparingly for C-faithfulness (e.g. the authentic "missle" message spellings) and
provably-safe gosec conversions; each needs a justifying comment.
## Faithfulness
Behavior must not change during the idiomatic-Go refactor unless a TODO step
says so. The 80x24 seed-compatible gameplay, message text (including original
typos), RNG call order, and C quirks (documented in tests like
TestHoldScrollGreedyMonsterQuirk) are contract. Doc comments keep their "(file.c
func_name)" breadcrumbs.
## Debugging
Write real, committed test files with t.Logf output and run them with the make
targets — `make test` (or `make check` for the full gate); never raw `go test`.
The target carries `-timeout 30s -race -cover` and reruns verbosely on failure,
so a raw invocation silently drops the race detector. No throwaway scratch
scripts. Successful debug probes become regression tests.

39
Makefile Normal file
View File

@@ -0,0 +1,39 @@
# Development convenience targets. This repo is exempt from the standard
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
# is only a thin wrapper around the Go toolchain, golangci-lint, and
# prettier so `make fmt` / `make check` behave the same as in sneak's
# other repos.
GO_PKGS := ./...
MD_FILES := $(shell git ls-files '*.md')
PRETTIER := prettier --tab-width 4 --prose-wrap always
.PHONY: check fmt fmt-check lint test
# Format, lint, and test — the full local pre-commit gate.
check: fmt-check lint test
# Format Go and Markdown in place.
fmt:
gofmt -w .
$(PRETTIER) --write $(MD_FILES)
# Fail if any Go or Markdown file is not formatted.
fmt-check:
@unformatted="$$(gofmt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; \
fi
$(PRETTIER) --check $(MD_FILES)
# Run the house linter (config in .golangci.yml).
lint:
golangci-lint run $(GO_PKGS)
# 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
# tests are broken, so a flaky pass on the rerun must not rescue the build).
test:
@go test -timeout 30s -race -cover $(GO_PKGS) || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v $(GO_PKGS); exit 1; }

View File

@@ -2,19 +2,19 @@
[![License](https://img.shields.io/badge/license-BSD-blue.svg)](LICENSE.TXT)
**Rogue** is the original dungeon-crawling adventure game that spawned an
entire genre. This branch is a faithful Go port of Rogue 5.4.4: explore
procedurally generated dungeons, fight monsters, collect treasure, and
attempt to retrieve the Amulet of Yendor.
**Rogue** is the original dungeon-crawling adventure game that spawned an entire
genre. This branch is a faithful Go port of Rogue 5.4.4: explore procedurally
generated dungeons, fight monsters, collect treasure, and attempt to retrieve
the Amulet of Yendor.
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman
(19801983, 1985, 1999).
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman (19801983,
1985, 1999).
The port is function-by-function faithful to the classic C sources — same
dungeon generation (seed-compatible RNG), same combat math, same item
tables, same messages. The C reference implementation lives on the
`master` and `modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md)
documents both the original program structure and the design of this port.
dungeon generation (seed-compatible RNG), same combat math, same item tables,
same messages. The C reference implementation lives on the `master` and
`modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md) documents both the
original program structure and the design of this port.
## Building and running
@@ -40,13 +40,12 @@ go build ./cmd/rogue
Press `?` in game for the full list.
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run
until adjacent)
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run until
adjacent)
- **`.`** rest, **`s`** search for hidden doors and traps
- **`i`** inventory, **`,`** pick up, **`d`** drop
- **`q`** quaff potion, **`r`** read scroll, **`e`** eat food
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on /
remove ring
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on / remove ring
- **`t`** throw, **`z`** zap a wand, **`f`**/**`F`** fight
- **`>`**/**`<`** take the stairs
- **`S`** save, **`Q`** quit
@@ -61,8 +60,8 @@ export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
ROGUE_WIZARD=1 SEED=12345 ./rogue
```
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob
snapshots and, as in the original, are deleted when restored.
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
and, as in the original, are deleted when restored.
## Code layout
@@ -73,13 +72,19 @@ term/ tcell-backed terminal, replacing curses
cmd/rogue/ the executable
```
The engine package is fully headless-testable: `go test ./game/` runs
scripted game sessions, dungeon-generation golden checks, and an RNG
compatibility test against the original C generator.
The engine package is fully headless-testable: `make test` runs scripted command
sequences, dungeon-generation golden checks, and an RNG compatibility test
against the original C generator.
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race
detector with coverage and a timeout), and `make check` (all three). Use the
targets rather than invoking `go test` directly — they carry the flags the
project relies on.
## License
BSD-style; see [LICENSE.TXT](LICENSE.TXT).
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn
Wichman. All rights reserved.
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman.
All rights reserved.

816
TODO.md
View File

@@ -1,120 +1,736 @@
# Workflow
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0
The port on main is complete and faithful (function-by-function from
Rogue 5.4.4 C; reference sources on c-master/modern-rogue). Current
phase: refactor from a transliterated port into idiomatic Go — one
feature branch per step below, descriptive naming, real types, house
style per ~/dev/prompts/prompts/CODE_STYLEGUIDE_GO.md.
The port on main is complete and faithful (function-by-function from Rogue 5.4.4
C; reference sources on c-master/modern-rogue). Current phase: refactor from a
transliterated port into idiomatic Go — one feature branch per step below,
descriptive naming, real types, house style per
~/dev/prompts/prompts/CODE_STYLEGUIDE_GO.md.
Refactor ground rules:
- Behavior must not change unless a step says so. The full test suite
(scripted sessions, generation invariants, C-compatible RNG goldens)
gates every step; 80x24 seed-compatible gameplay stays intact.
- Renames keep the C lineage greppable: doc comments retain their
"(file.c func_name)" breadcrumbs, and the docs refresh step adds a
C-name → Go-name table to ARCHITECTURE.md.
- Behavior must not change unless a step says so. The full test suite (scripted
sessions, generation invariants, C-compatible RNG goldens) gates every step;
80x24 seed-compatible gameplay stays intact.
- Renames keep the C lineage greppable: doc comments retain their "(file.c
func_name)" breadcrumbs, and the docs refresh step adds a C-name → Go-name
table to ARCHITECTURE.md.
# Next Step
Adopt the house Go linting standards: copy .golangci.yml from the
prompts repo and bring game/, term/, and cmd/ lint-clean (the port is
greenfield code, so no exemptions apply).
Broaden unit test coverage where playtesting finds thin spots — wizard commands
(#7). Rings and sticks, the first two thirds of this step, are done; see the top
of Completed Steps.
# Completed Steps
- 2026-07-06 Module base path updated to git.eeqj.de/sneak/rgoue
(go.mod, term/ and cmd/ imports, ARCHITECTURE.md, version string).
- 2026-07-06 Refactor step 3 (refactor/object-fields): Object.Arm split
into ArmorClass/Charges/GoldValue/Bonus (rings); Stats.Arm →
ArmorClass; damage strings parsed once into DiceSpec at table
definition (ParseDice keeps C roll_em parse semantics, incl. "%%%x0"
and "000x0" edge cases, regression-tested); save format 5.4.4-go3.
- 2026-07-06 Refactor step 2 (refactor/typed-kinds, b940cfc):
ObjectKind separates item category from map glyph (Object.Type byte
→ Kind ObjectKind with Glyph()); PotionKind/ScrollKind/RingKind/
WandKind/WeaponKind/ArmorKind/TrapKind typed iota enums with
Stringer; typed accessors on Object; getItem/inventory/whatis
filters take ObjectKind (KindCallable/KindRingOrStick replace
CALLABLE/R_OR_S); save format bumped to 5.4.4-go2. Suite green.
- 2026-07-06 Refactor step 1 (refactor/descriptive-constants): renamed
all flag bits, trap types, item subtype constants, and Max* counts to
descriptive names (IsHuh→Confused, SeeMonst→SenseMonsters,
WsHasteM→WandHasteMonster, MaxSticks→NumWandTypes, ...);
Level.NTraps→TrapCount; C names kept as comment breadcrumbs. Pure
rename, suite green.
- 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):
`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
a 14-entry table and panicked in `fixStick`. Input outside `0-f` overshoots
much further rather than going negative: `readchar` returns a `byte`, so the
`int(ch-'a') + 10` branch is byte arithmetic and wraps — `'A'` gives 234 and
`'!'` gives 202 — and panicked the same way. C's `create_obj()` was equally
unchecked, but every C consumer was either a `switch` (defined for any value)
or a static-array read past the end (undefined, and survivable in practice),
whereas since refactor step 8 one game is one process, so the Go panic kills
the game with the terminal still in raw mode. Fixed at the two boundaries a
bad `Which` can enter through: `createObj` now rejects an out-of-range choice
with a message built from C's own `type_name()` vocabulary and adds nothing to
the pack (a deliberate, commented divergence, since C had no defined behavior
here to be faithful to), and `Restore` refuses a snapshot describing such an
object (`ErrSaveCorrupt`) instead of loading a game that would explode later.
Behind those, `whichLimit`/`hasValidWhich` back defensive guards at every
dispatch named in the issue: the three effect tables (the new `quaffHandler`,
`readHandler`, and `zapHandler` accessors return no handler rather than
indexing — for wands that is exactly non-`MASTER` C, which matched no case and
still ran `o_charges--`), the `callIt` lore lookups, `identifyType` (whose
table is shorter than the scroll table keying it, though no scroll that can
reach `readIdentify` overshoots it, so that one is defensive rather than a
live bound), `armorClass` for the four `a_class[]` reads, `initWeapon` against
the missing `init_dam[]` row for `WeaponFlame`, `fixStick`'s `ws_type[]` read,
and `inventoryName`, hoisted so one check covers the scroll-title read the
issue listed plus its potion-color, ring-stone, wand-material, weapon and
armor siblings. `objectWorth` got the same hoisted guard, since the
death-screen appraisal reads the identical per-kind tables. No in-range input
changes behavior and no guard consumes a random number — the rejection
precedes every `rnd()` call, verified both by an explicit seed-unchanged test
and by `TestSeedCompatItemTables` staying green untouched. New
`game/wizard_test.go`: the exact reproducer, a rejection sweep over every
indexed kind including both wrapping-input forms, an acceptance sweep proving
valid choices still build the right item, one no-panic test per guarded family
(wand/potion/scroll/armor/weapon), the `fixStick` crash site, the corrupt-save
rejection over the wrapped values and a negative `Which` (a decoded snapshot
is the only source of one, so it is what exercises the `Which >= 0` arm of
`hasValidWhich`), and a check that `whichLimit` still agrees with the table
sizes. Each guard was confirmed load-bearing by reverting it and watching the
test panic. `Next Step` deliberately not rotated: this was out-of-band issue
work.
- 2026-08-09 Stale-docs correction (`docs-staleness`, closes #3): four claims in
`MEMORY.md`/`TODO.md`/`README.md` had gone false and were misdirecting agents
— the reviewer on PR #9 repeated one of them verbatim. Each was re-verified
against the tree before rewriting. (1) `MEMORY.md` described C's `exit()`
being unwound by a `gameEnd` panic recovered in `Run`; refactor step 8 deleted
that, `gameEnd` appears nowhere in the sources, and `myExit` (`game/rip.go`)
now calls `Terminal.Fini` then `os.Exit(0)` while `Run()` never returns — so
the section states the exit model and its testing consequence (a death exits
the test binary; hence `fortify()` in `game/run_test.go`). (2) `MEMORY.md`
said approved lint exceptions live in a "Repo-specific exceptions" block in
`.golangci.yml`; no such block exists and the config is byte-identical to
canonical (sha256 `021cc83f…46bcb`), the approvals having moved to in-code
`//nolint` directives carrying their dates — and `paralleltest` was listed as
an approved disable when it was in fact fixed (no `paralleltest` token in the
tree; 32 `t.Parallel()` calls against 32 tests). (3) `MEMORY.md` "Debugging"
and (4) `README.md` both told the reader to run `go test` directly, which
since PR #9 silently drops `-timeout 30s -race -cover`; both now point at
`make test`/`make check`. Also dropped the false "currently v2.12.2" host
linter claim from the 2026-08-07 entry (the host is v2.10.1 and nothing is
pinned; the pin question is tracked separately). Documentation only — no code,
`Makefile`, or config change; `Next Step` deliberately not rotated, since this
was out-of-band issue work.
- 2026-08-09 Policy-shaped `make test` (`make-test-policy-pattern`): the `test:`
target was a bare `go test $(GO_PKGS)` and now runs
`-timeout 30s -race -cover` with the mandated conditional verbose rerun (on
failure it reruns with `-v` and then `exit 1`, so a flaky pass on the second
attempt cannot rescue the build). `$(GO_PKGS)` is kept rather than hardcoding
`./...`. The substance was `-race`, not the Makefile edit: this is the first
time the suite has run under the race detector, and it is clean — no data
races across five consecutive uncached runs, including the tcell terminal
layer and the `os.Exit`-path playthrough tests. Wall clock 5.1s cold
(including the race build) and ~2.3s warm, against the 20s policy budget. The
failure path was exercised with a throwaway failing test to confirm the rerun
fires and `make` exits non-zero. Build tooling only; no game behavior change.
- 2026-08-07 Canonical linter config (`golangci-v2.12.2`): replaced
`.golangci.yml` with the shared canonical config (v2 schema; settings now live
under `linters.settings`, so the `lll`/`funlen`/`cyclop`/`dupl` thresholds
actually apply — the old top-level `linters-settings` block was silently
ignored). The four repo-specific disables (`mnd`, `exhaustive`,
`paralleltest`, `testpackage`) moved out of the config into targeted in-code
`//nolint` directives carrying the original approval dates, so the config
stays byte-identical to canonical. Real fixes: `t.Parallel()` in all 32 tests,
24 long lines wrapped or their comments tightened, control bytes in
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
`make lint` runs whatever `golangci-lint` is on the host).
- 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
forces the RNG seed and prints the per-seed item appearance tables (potion
colors, scroll names, ring stones, wand/staff materials) before initscr, and
captured its output for four seeds as testdata/item_tables.golden.
TestSeedCompatItemTables regenerates the same tables from the Go port and they
match byte for byte — proving the LCG and its consumption order through the
whole init sequence agree with C. The remaining "same dungeon (map)" half
would need the harder headless-curses C dump (new_level draws to curses);
deferred — the item-table match already validates RNG-order faithfulness
through init, and the Go generation goldens guard determinism thereafter.
- 2026-07-23 Playtest hardening (playtest-hardening): added two death-safe
crash-sweep drives through the real turn loop, within the step-8 os.Exit
constraint (a fortify() helper pins HP/food/exp and clears the freeze/stuck
counters each turn so no death exits the test binary; fixed seeds keep them
deterministic). TestDeepPlaythrough uses quaff/read/zap through command
dispatch, then descends to depth 8 with a save/restore at depth 4;
TestTurnLoopCrashSweep mashes movement/search/rest for 200 turns on four
seeds. Neither surfaced a panic. The interactive "play several games at a real
tcell terminal" portion needs a human at an 80x24 terminal and is left to the
maintainer; the binary's non-interactive paths (`-s` scores) were
smoke-tested.
- 2026-07-23 Docs refresh (docs-refresh): rewrote ARCHITECTURE.md Part 2 (the
pre-implementation design sketch) to match the final code — current type/field
names (ObjectKind, DiceSpec, split o_arm, step-1 flag names, TrapCount, Level
list methods), the static tables now on the per-game gameData struct, the
daemon/effect handler tables, the MessageLine extraction, the Terminal
interface, the flat gob SaveState, and the New(Params) + os.Exit design. Added
§7.1, a C-name → Go-name rename table, and a README note on the make targets.
- 2026-07-23 Refactor step 8 (refactor/constructor-style): constructor and exit
pass. NewGame(Config) → New(Params) and Restore takes Params, so the package's
primary type gets the canonical New() constructor with a named-field Params
struct (styleguide 139/159). The gameEnd panic unwind is gone: one game run is
one process, so myExit restores the terminal (new Terminal.Fini) and calls
os.Exit(0), and Run() no longer returns; the four Run()-to-completion tests
were reworked/dropped since death (combat or starvation) now exits the process
(TestScoreRendersList and TestRunDownStairs preserve what is still drivable;
save/restore stays covered by TestSaveRestoreRoundTrip). The 77-column wrap
sweep was dropped per sneak (2026-07-23): line lengths left as-is (lll caps at
88 and passes).
- 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects dispatch
tables plus a full decomposition sweep — the quaff / readScroll / doZap
switches, the attack monster-power switch, the be_trapped switch, the daemon
d_func switch, and the command-key switch all became handler tables on
gameData (quaffHandlers, readHandlers, zapHandlers, hitHandlers, trapHandlers,
daemonHandlers, commandHandlers), one small named method per case. Every
remaining cyclop/gocognit/nestif hot spot was split into named helpers across
fight, misc (look), command, chase, move, passages, options, pack, things,
save, daemons, rooms, score, monsters, rings, rip, io, object, weapons,
wizard, and term/tcell, plus three test functions. Effect order and RNG call
sequence preserved throughout; the whole golangci-lint run is now 0 issues.
- 2026-07-07 Refactor step 6 (refactor/god-object-extraction): MessageLine (was
MsgLine) owns the msg/addmsg/endmsg machinery, wired to its screen/look/input
needs via attach(); RogueGame keeps one-line msg/addmsgf/endmsg shorthands so
call sites are unchanged. Player owns pack bookkeeping (nextPackChar,
removeFromPack — the state half of leave_pack; leavePack keeps only LastPick
tracking). Level owns object/monster list management and lookup (ObjectAt
replaces findObj; AddObject/RemoveObject/AddMonster/RemoveMonster replace
direct attachObj/detachObj/attachMon/detachMon on level lists).
Inventory/pickup UI flows stay on RogueGame deliberately: they are display and
turn orchestration, not state surgery.
- 2026-07-07 Refactor step 5 (refactor/item-combat-ui-renames, three commits,
one subsystem each): items — getItem→promptPackItem now returning (obj, ok),
invName→inventoryName, doPot→applyPotionFuse; combat — rollEm→rollAttacks,
attack/moveMonster/chaseStep return (removed bool) instead of C -1/0 int
codes; UI — getDir→promptDirection. C breadcrumbs kept; suite green.
- 2026-07-07 Refactor step 4 (refactor/movement-renames): movement/world renames
(doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
doRooms/doPassages/doMaze→digRooms/digPassages/digMaze, chgStr→changeStrength,
doRun→startRun, moveStuff→finishMove, turnref→turnRefresh,
moveMonst→moveMonster, doChase→chaseStep, setOldch→setOldChar, cansee→canSee,
roomin→roomIn, runto→runTo, conn→connectRooms, putpass→putPassage,
passnum→numberPassages, numpass→numberPassage, rndPos→randomPos,
rndRoom→randomRoom, treasRoom→treasureRoom, accntMaze→accountMaze); all
goto/label flows replaced with loops (moveHero retry loop + extracted
passageTurn, dispatch re-dispatch loop, chaseStep passage loop, saveGame
labeled prompt loop); C breadcrumbs kept in doc comments.
- 2026-07-07 Lint adoption finished (refactor/no-package-globals): all 37
package-level vars moved into `gameData` (built by `newGameData`, hung on
RogueGame as `g.data`, set in NewGame and Restore); ObjectKind
Glyph()/objectKindForGlyph became switches; the table-reading subtype
Stringers were removed; isMagic became a RogueGame method; goconst fixed with
named word constants (potionName, goldName, staffName, ripWall, ...);
testpackage and exhaustive disabled in .golangci.yml with sneak's approval
(2026-07-07); misspell's corruption of the "ther" scroll syllable reverted.
mnd disabled with sneak's approval (2026-07-07, follow-up commit). Remaining
red: cyclop (36), nestif (30), gocognit (23) stay until step 7 fixes them per
sneak's ruling.
- 2026-07-06 Lint adoption bulk (refactor/lint-adoption, 5ba9fe8): .golangci.yml
copied verbatim from the prompts repo (plus the sneak-approved paralleltest
exception, 2026-07-06); ~1,500 findings fixed (autofix formatting sweep,
errcheck/err113/noinlineerr error handling, forbidigo, funcorder, recvcheck
pointer receivers, goprintffuncname renames msg helpers to *f, revive doc
comments, gocritic switch rewrites, gosec real fixes plus justified nolints,
unparam signature tightening, C-faithful "missle" spellings restored after
misspell autofix changed game text).
- 2026-07-06 Module base path updated to git.eeqj.de/sneak/rgoue (go.mod, term/
and cmd/ imports, ARCHITECTURE.md, version string).
- 2026-07-06 Refactor step 3 (refactor/object-fields): Object.Arm split into
ArmorClass/Charges/GoldValue/Bonus (rings); Stats.Arm → ArmorClass; damage
strings parsed once into DiceSpec at table definition (ParseDice keeps C
roll_em parse semantics, incl. "%%%x0" and "000x0" edge cases,
regression-tested); save format 5.4.4-go3.
- 2026-07-06 Refactor step 2 (refactor/typed-kinds, b940cfc): ObjectKind
separates item category from map glyph (Object.Type byte → Kind ObjectKind
with Glyph()); PotionKind/ScrollKind/RingKind/
WandKind/WeaponKind/ArmorKind/TrapKind typed iota enums with Stringer; typed
accessors on Object; getItem/inventory/whatis filters take ObjectKind
(KindCallable/KindRingOrStick replace CALLABLE/R_OR_S); save format bumped to
5.4.4-go2. Suite green.
- 2026-07-06 Refactor step 1 (refactor/descriptive-constants): renamed all flag
bits, trap types, item subtype constants, and Max* counts to descriptive names
(IsHuh→Confused, SeeMonst→SenseMonsters, WsHasteM→WandHasteMonster,
MaxSticks→NumWandTypes, ...); Level.NTraps→TrapCount; C names kept as comment
breadcrumbs. Pure rename, suite green.
- 2026-07-06 Made the rgoue branch Go-only: removed C sources and the
autoconf/VS build system (they remain on master and modern-rogue),
ported the last wizard command (item-probability listing), rewrote
README.md for the Go port (c0b533e)
- 2026-07-06 Ported the command loop, save/restore, the tcell terminal
layer, and the playable binary at cmd/rogue (41fc104)
- 2026-07-06 Ported item effects: potions, scrolls, options, call_it
(cdf9bf7)
- 2026-07-06 Ported combat, the chase driver, traps, zapping, death and
scores (3c5add8)
- 2026-07-06 Ported dungeon generation, base items, the pack, and
monster creation (a69ef7d)
- 2026-07-06 Ported the foundation: types, seed-compatible RNG, item
tables, daemon scheduler (7fa2048)
- 2026-07-06 Wrote ARCHITECTURE.md Parts 1 and 2: complete map of the C
program and the Go port design (91eeee0, 45dba95)
- Fork base: Davidslv/rogue C 5.4.4 with modernization fixes (C23
prototypes, ncurses compat), preserved on master/modern-rogue
autoconf/VS build system (they remain on master and modern-rogue), ported the
last wizard command (item-probability listing), rewrote README.md for the Go
port (c0b533e)
- 2026-07-06 Ported the command loop, save/restore, the tcell terminal layer,
and the playable binary at cmd/rogue (41fc104)
- 2026-07-06 Ported item effects: potions, scrolls, options, call_it (cdf9bf7)
- 2026-07-06 Ported combat, the chase driver, traps, zapping, death and scores
(3c5add8)
- 2026-07-06 Ported dungeon generation, base items, the pack, and monster
creation (a69ef7d)
- 2026-07-06 Ported the foundation: types, seed-compatible RNG, item tables,
daemon scheduler (7fa2048)
- 2026-07-06 Wrote ARCHITECTURE.md Parts 1 and 2: complete map of the C program
and the Go port design (91eeee0, 45dba95)
- Fork base: Davidslv/rogue C 5.4.4 with modernization fixes (C23 prototypes,
ncurses compat), preserved on master/modern-rogue
# Future Steps
1. Refactor step 4: method renames, movement/world subsystem
(doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
doRooms/doPassages/doMaze→digRooms/digPassages/digMaze,
chgStr→changeStrength, ...); remove the goto/label flows in doMove,
dispatch, and saveGame in favor of loops and helpers.
2. Refactor step 5: method renames, items/combat/UI subsystems
(invName→inventoryName, rollEm→rollAttacks, doPot→applyPotionFuse,
getItem→promptPackItem returning (obj, ok), getDir→promptDirection);
int status codes (attack returning -1) become named results. Two or
three commits, one subsystem each.
3. Refactor step 6: extract types from the god object — MessageLine
owns the msg/addmsg/endmsg machinery; pack/inventory operations move
onto *Player; monster/object list management and map queries
consolidate onto *Level; RogueGame keeps turn orchestration and
cross-system effects only.
4. Refactor step 7: effects dispatch — the giant quaff/readScroll/doZap
switches become per-kind handler tables of small named methods,
keeping effect order and RNG call sequence identical.
5. Refactor step 8: constructor and style pass per the house
styleguide — game.New(game.Params{...}) replacing NewGame(Config);
replace the gameEnd panic unwind with error-based turn results where
feasible; 77-column wrap sweep.
6. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the
post-refactor names; add the C name → Go name rename table.
7. Playtest hardening pass: play several full games with the tcell
binary and extend run_test.go to script a deeper multi-level
playthrough (descend past level 5, use potions, scrolls, zapping,
save/restore). Fix any panics, message mismatches, or divergences
from the C behavior that this uncovers, with regression tests.
8. Verify the seed-compatibility claim against the C reference on
c-master: same seed, same dungeon, same item tables, for several
seeds.
9. Broaden unit test coverage where playtesting finds thin spots
(rings, sticks, wizard commands).
10. Tag a release once a full game (Amulet retrieval and score entry)
completes without defects.
11. Full-terminal-size support (deferred by explicit decision
2026-07-06): per-game dungeon dimensions instead of the 80x24
constants; open design questions are resize policy, gameplay
tuning at larger sizes, and a --classic 80x24 mode.
12. Note: this repo is exempt from the standard policy scaffold. Do not
add Makefile, Dockerfile, or REPO_POLICIES.md.
1. Tag a release once a full game (Amulet retrieval and score entry) completes
without defects.
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
per-game dungeon dimensions instead of the 80x24 constants; open design
questions are resize policy, gameplay tuning at larger sizes, and a --classic
80x24 mode.
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
2026-07-07 request, but do not add a Dockerfile, CI config, or
REPO_POLICIES.md.

View File

@@ -10,6 +10,7 @@ import (
"os/signal"
"os/user"
"strconv"
"sync"
"syscall"
"time"
@@ -18,84 +19,318 @@ import (
)
func main() {
os.Exit(run())
}
// run does the real work and returns an exit code. It only returns on a
// startup error; once the game starts, it ends by exiting the process
// from within (game.myExit restores the terminal first). The deferred
// Fini covers the early-return paths.
func run() int {
scores := flag.Bool("s", false, "print the scoreboard and exit")
deathDemo := flag.Bool("d", false, "die a random death (demo)")
flag.Parse()
home, _ := os.UserHomeDir()
// get options from environment (main.c)
rogueOpts := os.Getenv("ROGUEOPTS")
name := ""
if u, err := user.Current(); err == nil {
name = u.Username
}
wizard := os.Getenv("ROGUE_WIZARD") != ""
// dungeon number: SEED for reproducible dungeons (wizard mode in C),
// else time+pid
var seed int32
if env := os.Getenv("SEED"); env != "" && wizard {
n, _ := strconv.Atoi(env)
seed = int32(n)
} else {
seed = int32(time.Now().Unix()) + int32(os.Getpid())
}
cfg := game.Config{
Seed: seed,
Name: name,
RogueOpts: rogueOpts,
Home: home,
ScorePath: home + "/.rogue.scores",
Wizard: wizard,
}
params := loadParams()
if *scores {
g := game.NewGame(cfg)
g.ShowScores()
return
game.New(params).ShowScores()
return 0
}
// C printed its greeting just before initscr(); here that means just
// before the tcell screen takes the terminal, and on stdout, exactly
// as C did (main.c main). C followed the printf with fflush because
// its stdout was buffered; os.Stdout is not, so the write is the
// flush.
if digsNewDungeon(*deathDemo, flag.Args()) {
_, _ = fmt.Fprint(os.Stdout, game.Greeting(params)) // CLI output
}
t, err := term.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
return 1
}
defer t.Fini()
cfg.Term = t
// 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
var g *game.RogueGame
if args := flag.Args(); len(args) == 1 && !*deathDemo {
// restore a saved game
g, err = game.Restore(args[0], cfg)
g, err = game.Restore(args[0], params)
if err != nil {
t.Fini()
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
return 1
}
} else {
g = game.NewGame(cfg)
g = game.New(params)
}
if *deathDemo {
g.DeathDemo()
return
// 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
return 0
}
// SIGHUP/SIGTERM autosave (save.c auto_save)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM)
go func() {
<-sig
g.AutoSave()
t.Fini()
os.Exit(0)
}()
pending.set(g)
if err := g.Run(); err != nil {
t.Fini()
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
g.Run() // does not return: the game ends by exiting the process
return 0
}
// digsNewDungeon reports whether this invocation is the one that digs a
// fresh dungeon, and so the only one that greets.
//
// C's printf is the last statement before initscr(), and everything that
// does something else has already left by then: -s scores and exits, -d
// runs the death demo and exits, and restore() — the argc == 2 case that
// is neither — never returns. So a saved game resumes without a greeting,
// which is right: nothing is being dug.
//
// The restore test is duplicated from run's own, deliberately. Keeping
// them as one predicate would mean deciding the startup path before the
// terminal exists and carrying it past the error returns, which is more
// rearrangement of run than a greeting is worth.
func digsNewDungeon(deathDemo bool, args []string) bool {
return !deathDemo && len(args) != 1
}
// loadParams gathers the game parameters from the environment: home
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
// (main.c's startup).
func loadParams() game.Params {
home, _ := os.UserHomeDir()
name := ""
u, userErr := user.Current()
if userErr == nil {
name = u.Username
}
wizard := os.Getenv("ROGUE_WIZARD") != ""
return game.Params{
Seed: chooseSeed(wizard),
Name: name,
RogueOpts: os.Getenv("ROGUEOPTS"),
Home: home,
ScorePath: home + "/.rogue.scores",
Wizard: wizard,
}
}
// saver is the autosave half of *game.RogueGame that the signal handler
// needs; an interface so the handler is testable headlessly.
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)
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)
}
t.Fini()
exit(0)
}
// chooseSeed picks the dungeon number: SEED for reproducible dungeons
// (wizard mode, as in the C game), else time+pid (main.c).
func chooseSeed(wizard bool) int32 {
if env := os.Getenv("SEED"); env != "" && wizard {
n, err := strconv.ParseInt(env, 10, 32)
if err == nil {
return int32(n)
}
}
// The C game computed `lowtime + getpid()` in int; the truncation to
// 32 bits is the same wraparound the C int arithmetic performed.
//nolint:mnd // C-faithful: the C int wraparound mask
return int32(time.Now().Unix()&0x7fffffff) +
int32(os.Getpid()&0x7fffffff)
}

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)
}
})
}
}

View File

@@ -5,36 +5,47 @@ package game
// wear lets the player put armor on (armor.c wear).
func (g *RogueGame) wear() {
p := &g.Player
obj := g.getItem("wear", KindArmor)
if obj == nil {
obj, ok := g.promptPackItem("wear", KindArmor)
if !ok {
return
}
if p.CurArmor != nil {
g.addmsg("you are already wearing some")
g.addmsgf("you are already wearing some")
if !g.Options.Terse {
g.addmsg(". You'll have to take it off first")
g.addmsgf(". You'll have to take it off first")
}
g.endmsg()
g.After = false
return
}
if obj.Kind != KindArmor {
g.msg("you can't wear that")
return
}
g.wasteTime()
obj.Flags.Set(Known)
sp := g.invName(obj, true)
sp := g.inventoryName(obj, true)
p.CurArmor = obj
if !g.Options.Terse {
g.addmsg("you are now ")
g.addmsgf("you are now ")
}
g.msg("wearing %s", sp)
}
// takeOff gets the armor off of the player's back (armor.c take_off).
func (g *RogueGame) takeOff() {
p := &g.Player
obj := p.CurArmor
if obj == nil {
g.After = false
@@ -43,18 +54,23 @@ func (g *RogueGame) takeOff() {
} else {
g.msg("you aren't wearing any armor")
}
return
}
if !g.dropCheck(p.CurArmor) {
return
}
p.CurArmor = nil
if g.Options.Terse {
g.addmsg("was")
g.addmsgf("was")
} else {
g.addmsg("you used to be")
g.addmsgf("you used to be")
}
g.msg(" wearing %c) %s", obj.PackCh, g.invName(obj, true))
g.msg(" wearing %c) %s", obj.PackCh, g.inventoryName(obj, true))
}
// wasteTime does nothing but let other things happen (armor.c waste_time).

515
game/autosave_test.go Normal file
View File

@@ -0,0 +1,515 @@
//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"
"strings"
"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.
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, and the script is long enough that the drive never runs it
// out.
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
g := New(Params{Seed: 20260809, Term: &testTerm{input: script}})
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
// turn cap keeps a broken handoff from hanging the suite instead of
// failing it.
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
t.Helper()
const maxTurns = 1000
for range maxTurns {
select {
case <-done:
return
default:
}
fortify(g)
g.command()
}
t.Fatal("the turn loop ran out of turns before the saves were taken")
}
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
// 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: &testTerm{
input: []byte(strings.Repeat("s . ", 200)),
}})
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
}
// 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

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// chase.c — code for one creature to chase another.
@@ -9,44 +10,61 @@ const dragonShot = 5
func (g *RogueGame) runners(int) {
list := append([]*Monster(nil), g.Level.Monsters...)
for _, tp := range list {
if !tp.On(Held) && tp.On(Awake) {
origPos := tp.Pos
wastarget := tp.On(Targeted)
if g.moveMonst(tp) == -1 {
continue
}
if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 {
if g.moveMonst(tp) == -1 {
continue
}
}
if wastarget && origPos != tp.Pos {
tp.Flags.Clear(Targeted)
g.ToDeath = false
}
}
g.runnerTurn(tp)
}
if g.HasHit {
g.endmsg()
g.HasHit = false
}
}
// moveMonst executes a single turn of running for a monster (chase.c
// move_monst). Returns -1 if the monster died or left the level.
func (g *RogueGame) moveMonst(tp *Monster) int {
// runnerTurn gives one monster its motion for the turn; flying monsters
// far from the hero move twice (the loop body of chase.c runners).
func (g *RogueGame) runnerTurn(tp *Monster) {
if tp.On(Held) || !tp.On(Awake) {
return
}
origPos := tp.Pos
wastarget := tp.On(Targeted)
if removed := g.moveMonster(tp); removed {
return
}
if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 {
if removed := g.moveMonster(tp); removed {
return
}
}
if wastarget && origPos != tp.Pos {
tp.Flags.Clear(Targeted)
g.ToDeath = false
}
}
// moveMonster executes a single turn of running for a monster (chase.c
// move_monst). The result reports that the monster died or left the
// level (the C -1 return).
func (g *RogueGame) moveMonster(tp *Monster) bool {
if !tp.On(Slowed) || tp.Turn {
if g.doChase(tp) == -1 {
return -1
if g.chaseStep(tp) {
return true
}
}
if tp.On(Hasted) {
if g.doChase(tp) == -1 {
return -1
if g.chaseStep(tp) {
return true
}
}
tp.Turn = !tp.Turn
return 0
return false
}
// relocate makes the monster's new location be the specified one, updating
@@ -54,18 +72,21 @@ func (g *RogueGame) moveMonst(tp *Monster) int {
func (g *RogueGame) relocate(th *Monster, newLoc Coord) {
if newLoc != th.Pos {
g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh)
th.Room = g.roomin(newLoc)
g.setOldch(th, newLoc)
th.Room = g.roomIn(newLoc)
g.setOldChar(th, newLoc)
oroom := th.Room
g.Level.SetMonsterAt(th.Pos.Y, th.Pos.X, nil)
if oroom != th.Room {
th.Dest = g.findDest(th)
}
th.Pos = newLoc
g.Level.SetMonsterAt(newLoc.Y, newLoc.X, th)
}
g.move(newLoc.Y, newLoc.X)
if g.seeMonst(th) {
g.addch(th.Disguise)
} else if g.Player.On(SenseMonsters) {
@@ -75,31 +96,82 @@ func (g *RogueGame) relocate(th *Monster, newLoc Coord) {
}
}
// doChase makes one thing chase another (chase.c do_chase). Returns -1 if
// the chaser died in the attempt.
func (g *RogueGame) doChase(th *Monster) int {
p := &g.Player
// chaseStep makes one thing chase another (chase.c do_chase). The
// result reports that the chaser died or left the level in the attempt
// (the C -1 return).
func (g *RogueGame) chaseStep(th *Monster) bool {
stoprun := false // true means we are there
mindist := 32767
rer, ree, door := g.chaseRooms(th)
this, shot := g.chaseGoal(th, rer, ree, door, 32767)
if shot {
return false
}
// This now contains what we want to run to this time so we run to it.
// If we hit it we either want to fight it or stop running
if g.chase(th, this) {
if th.Type == 'F' {
return false
}
} else {
switch this {
case g.Player.Pos:
return g.attack(th)
case *th.Dest:
g.chaseTakeObject(th)
stoprun = th.Type != 'F'
}
}
g.relocate(th, g.chRet)
// And stop running if need be
if stoprun && th.Pos == *th.Dest {
th.Flags.Clear(Awake)
}
return false
}
// chaseRooms finds the rooms of the chaser and its desire; doors do not
// count as inside rooms here (the setup of chase.c do_chase).
func (g *RogueGame) chaseRooms(th *Monster) (*Room, *Room, bool) {
p := &g.Player
rer := th.Room // find room of chaser
if th.On(Greedy) && rer.GoldVal == 0 {
th.Dest = &p.Pos // if gold has been taken, run after hero
}
var ree *Room // find room of chasee
if th.Dest == &p.Pos {
ree = p.Room
} else {
ree = g.roomin(*th.Dest)
ree = g.roomIn(*th.Dest)
}
// We don't count doors as inside rooms for this routine
door := g.Level.Char(th.Pos.Y, th.Pos.X) == Door
return rer, ree, g.Level.Char(th.Pos.Y, th.Pos.X) == Door
}
// chaseGoal picks the spot the chaser runs toward this turn: the
// nearest exit toward its desire when it is in a different room, or the
// desire itself. shot means a dragon breathed flame instead of moving
// (the goal loop of chase.c do_chase).
func (g *RogueGame) chaseGoal(
th *Monster, rer, ree *Room, door bool, mindist int,
) (Coord, bool) {
var this Coord
over:
// If the object of our desire is in a different room, and we are not
// in a corridor, run to the door nearest to our goal.
if rer != ree {
for {
// If the object of our desire is in a different room, and we are
// not in a corridor, run to the door nearest to our goal.
if rer == ree {
this = *th.Dest
return this, g.dragonBreath(th)
}
for i := range rer.Exits {
curdist := distCp(*th.Dest, rer.Exits[i])
if curdist < mindist {
@@ -107,79 +179,86 @@ over:
mindist = curdist
}
}
if door {
rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum]
door = false
goto over
if !door {
return this, false
}
} else {
this = *th.Dest
// For dragons check and see if (a) the hero is on a straight line
// from it, and (b) that it is within shooting distance, but
// outside of striking range.
if th.Type == 'D' && (th.Pos.Y == p.Pos.Y || th.Pos.X == p.Pos.X ||
abs(th.Pos.Y-p.Pos.Y) == abs(th.Pos.X-p.Pos.X)) &&
distCp(th.Pos, p.Pos) <= BoltLength*BoltLength &&
!th.On(Cancelled) && g.rnd(dragonShot) == 0 {
g.Delta.Y = sign(p.Pos.Y - th.Pos.Y)
g.Delta.X = sign(p.Pos.X - th.Pos.X)
if g.HasHit {
g.endmsg()
rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum]
door = false
// the C goto over: redo with the passage as room
}
}
// dragonBreath checks whether a dragon shoots flame at the hero instead
// of moving, and shoots it (the D block of chase.c do_chase).
func (g *RogueGame) dragonBreath(th *Monster) bool {
if th.Type != 'D' || !g.dragonShoots(th) {
return false
}
p := &g.Player
g.Delta.Y = sign(p.Pos.Y - th.Pos.Y)
g.Delta.X = sign(p.Pos.X - th.Pos.X)
if g.HasHit {
g.endmsg()
}
g.fireBolt(th.Pos, &g.Delta, "flame")
g.Running = false
g.Count = 0
g.Quiet = 0
if g.ToDeath && !th.On(Targeted) {
g.ToDeath = false
g.Kamikaze = false
}
return true
}
// dragonShoots decides whether the dragon takes the shot: the hero is
// on a straight line from it, within shooting distance but outside
// striking range, it is not cancelled, and the shot roll comes up
// (chase.c do_chase).
func (g *RogueGame) dragonShoots(th *Monster) bool {
p := &g.Player
if th.Pos.Y != p.Pos.Y && th.Pos.X != p.Pos.X &&
abs(th.Pos.Y-p.Pos.Y) != abs(th.Pos.X-p.Pos.X) {
return false
}
return distCp(th.Pos, p.Pos) <= BoltLength*BoltLength &&
!th.On(Cancelled) && g.rnd(dragonShot) == 0
}
// chaseTakeObject has the monster pick up the object it was running to
// (the dest arm of chase.c do_chase).
func (g *RogueGame) chaseTakeObject(th *Monster) {
for _, obj := range g.Level.Objects {
if th.Dest == &obj.Pos {
g.Level.RemoveObject(obj)
attachObj(&th.Pack, obj)
if th.Room.Flags.Has(Gone) {
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Passage)
} else {
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Floor)
}
g.fireBolt(th.Pos, &g.Delta, "flame")
g.Running = false
g.Count = 0
g.Quiet = 0
if g.ToDeath && !th.On(Targeted) {
g.ToDeath = false
g.Kamikaze = false
}
return 0
th.Dest = g.findDest(th)
break
}
}
// This now contains what we want to run to this time so we run to it.
// If we hit it we either want to fight it or stop running
if !g.chase(th, this) {
if this == p.Pos {
return g.attack(th)
} else if this == *th.Dest {
for _, obj := range g.Level.Objects {
if th.Dest == &obj.Pos {
detachObj(&g.Level.Objects, obj)
attachObj(&th.Pack, obj)
if th.Room.Flags.Has(Gone) {
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Passage)
} else {
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Floor)
}
th.Dest = g.findDest(th)
break
}
}
if th.Type != 'F' {
stoprun = true
}
}
} else {
if th.Type == 'F' {
return 0
}
}
g.relocate(th, g.chRet)
// And stop running if need be
if stoprun && th.Pos == *th.Dest {
th.Flags.Clear(Awake)
}
return 0
}
// chase finds the spot for the chaser to move closer to the chasee
// (chase.c chase). Returns true if we want to keep on chasing later, false
// if we reach the goal. The chosen spot lands in g.chRet.
func (g *RogueGame) chase(tp *Monster, ee Coord) bool {
p := &g.Player
er := tp.Pos
plcnt := 1
var curdist int
// If the thing is confused, let it move randomly. Invisible Stalkers
@@ -188,85 +267,115 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool {
if (tp.On(Confused) && g.rnd(5) != 0) || (tp.Type == 'P' && g.rnd(5) == 0) ||
(tp.Type == 'B' && g.rnd(2) == 0) {
// get a valid random move
g.chRet = g.rndmove(&tp.Creature)
g.chRet = g.randomStep(&tp.Creature)
curdist = distCp(g.chRet, ee)
// Small chance that it will become un-confused
if g.rnd(20) == 0 {
tp.Flags.Clear(Confused)
}
} else {
// Otherwise, find the empty spot next to the chaser that is
// closest to the chasee. This will eventually hold where we move
// to get closer. If we can't find an empty spot, we stay where we
// are.
curdist = distCp(er, ee)
g.chRet = er
ey := er.Y + 1
if ey >= NumLines-1 {
ey = NumLines - 2
}
ex := er.X + 1
if ex >= NumCols {
ex = NumCols - 1
}
for x := er.X - 1; x <= ex; x++ {
if x < 0 {
continue
}
for y := er.Y - 1; y <= ey; y++ {
tryp := Coord{X: x, Y: y}
if !g.diagOk(er, tryp) {
continue
}
ch := g.Level.VisibleChar(y, x)
if stepOk(ch) {
// If it is a scroll, it might be a scare monster
// scroll so we need to look it up to see what type
// it is.
if ch == Scroll {
var found *Object
for _, obj := range g.Level.Objects {
if y == obj.Pos.Y && x == obj.Pos.X {
found = obj
break
}
}
if found != nil && found.ScrollKind() == ScrollScareMonster {
continue
}
}
// It can also be a Xeroc, which we shouldn't step on
if m := g.Level.MonsterAt(y, x); m != nil && m.Type == 'X' {
continue
}
// If we didn't find any scrolls at this place or it
// wasn't a scare scroll, then this place counts
thisdist := distance(y, x, ee.Y, ee.X)
if thisdist < curdist {
plcnt = 1
g.chRet = tryp
curdist = thisdist
} else if thisdist == curdist {
if plcnt++; g.rnd(plcnt) == 0 {
g.chRet = tryp
curdist = thisdist
}
}
}
}
}
curdist = g.chaseBestSpot(tp, ee)
}
return curdist != 0 && g.chRet != p.Pos
return curdist != 0 && g.chRet != g.Player.Pos
}
// setOldch sets the oldch character for the monster (chase.c set_oldch).
func (g *RogueGame) setOldch(tp *Monster, cp Coord) {
// chaseSearch is the scan state while chase looks for the step that
// gets a monster closest to its chasee.
type chaseSearch struct {
er Coord // where the chaser is
ee Coord // where it wants to go
curdist int
plcnt int
}
// chaseBestSpot finds the empty spot next to the chaser that is closest
// to the chasee, leaving it in g.chRet; if there is none, the chaser
// stays where it is (the search half of chase.c chase).
func (g *RogueGame) chaseBestSpot(tp *Monster, ee Coord) int {
er := tp.Pos
s := chaseSearch{er: er, ee: ee, curdist: distCp(er, ee), plcnt: 1}
g.chRet = er
ey := er.Y + 1
if ey >= NumLines-1 {
ey = NumLines - 2
}
ex := er.X + 1
if ex >= NumCols {
ex = NumCols - 1
}
for x := er.X - 1; x <= ex; x++ {
if x < 0 {
continue
}
for y := er.Y - 1; y <= ey; y++ {
g.chaseTry(&s, y, x)
}
}
return s.curdist
}
// chaseTry scores one candidate square, reservoir-sampling among ties
// (the scan body of chase.c chase).
func (g *RogueGame) chaseTry(s *chaseSearch, y, x int) {
tryp := Coord{X: x, Y: y}
if !g.diagOk(s.er, tryp) {
return
}
ch := g.Level.VisibleChar(y, x)
if !stepOk(ch) {
return
}
// If it is a scroll, it might be a scare monster scroll so we need
// to look it up to see what type it is.
if ch == Scroll && g.scareScrollAt(y, x) {
return
}
// It can also be a Xeroc, which we shouldn't step on
if m := g.Level.MonsterAt(y, x); m != nil && m.Type == 'X' {
return
}
// If we didn't find any scrolls at this place or it wasn't a scare
// scroll, then this place counts
thisdist := distance(y, x, s.ee.Y, s.ee.X)
if thisdist < s.curdist {
s.plcnt = 1
g.chRet = tryp
s.curdist = thisdist
} else if thisdist == s.curdist {
if s.plcnt++; g.rnd(s.plcnt) == 0 {
g.chRet = tryp
s.curdist = thisdist
}
}
}
// scareScrollAt reports whether the object lying at (y, x) is a scare
// monster scroll (chase.c chase).
func (g *RogueGame) scareScrollAt(y, x int) bool {
for _, obj := range g.Level.Objects {
if y == obj.Pos.Y && x == obj.Pos.X {
return obj.ScrollKind() == ScrollScareMonster
}
}
return false
}
// setOldChar sets the oldch character for the monster (chase.c set_oldch).
func (g *RogueGame) setOldChar(tp *Monster, cp Coord) {
if tp.Pos == cp {
return
}
sch := tp.OldCh
tp.OldCh = g.mvinch(cp.Y, cp.X)
if !g.Player.On(Blind) {
if (sch == Floor || tp.OldCh == Floor) && tp.Room.Flags.Has(Dark) {
@@ -284,25 +393,30 @@ func (g *RogueGame) seeMonst(mp *Monster) bool {
if p.On(Blind) {
return false
}
if mp.On(Invisible) && !p.On(CanSeeInvisible) {
return false
}
y, x := mp.Pos.Y, mp.Pos.X
if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
if y != p.Pos.Y && x != p.Pos.X &&
!stepOk(g.Level.Char(y, p.Pos.X)) && !stepOk(g.Level.Char(p.Pos.Y, x)) {
return false
}
return true
}
if mp.Room != p.Room {
return false
}
return !mp.Room.Flags.Has(Dark)
}
// runto sets a monster running after the hero (chase.c runto).
func (g *RogueGame) runto(runner Coord) {
// runTo sets a monster running after the hero (chase.c runto).
func (g *RogueGame) runTo(runner Coord) {
tp := g.Level.MonsterAt(runner.Y, runner.X)
if tp == nil {
return
@@ -313,9 +427,9 @@ func (g *RogueGame) runto(runner Coord) {
tp.Dest = g.findDest(tp)
}
// roomin finds what room some coordinates are in; nil means they aren't in
// roomIn finds what room some coordinates are in; nil means they aren't in
// any room (chase.c roomin).
func (g *RogueGame) roomin(cp Coord) *Room {
func (g *RogueGame) roomIn(cp Coord) *Room {
fp := *g.Level.FlagsAt(cp.Y, cp.X)
if fp.Has(FPassage) {
return &g.Level.Passages[fp&FPassNum]
@@ -330,6 +444,7 @@ func (g *RogueGame) roomin(cp Coord) *Room {
}
g.msg("in some bizarre place (%d, %d)", cp.Y, cp.X)
return nil
}
@@ -338,19 +453,22 @@ func (g *RogueGame) diagOk(sp, ep Coord) bool {
if ep.X < 0 || ep.X >= NumCols || ep.Y <= 0 || ep.Y >= NumLines-1 {
return false
}
if ep.X == sp.X || ep.Y == sp.Y {
return true
}
return stepOk(g.Level.Char(ep.Y, sp.X)) && stepOk(g.Level.Char(sp.Y, ep.X))
}
// cansee returns true if the hero can see a certain coordinate (chase.c
// canSee returns true if the hero can see a certain coordinate (chase.c
// cansee).
func (g *RogueGame) cansee(y, x int) bool {
func (g *RogueGame) canSee(y, x int) bool {
p := &g.Player
if p.On(Blind) {
return false
}
if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
if g.Level.FlagsAt(y, x).Has(FPassage) {
if y != p.Pos.Y && x != p.Pos.X &&
@@ -359,11 +477,13 @@ func (g *RogueGame) cansee(y, x int) bool {
return false
}
}
return true
}
// We can only see if the hero is in the same room as the coordinate
// and the room is lit, or if it is close.
rer := g.roomin(Coord{X: x, Y: y})
rer := g.roomIn(Coord{X: x, Y: y})
return rer == p.Room && !rer.Flags.Has(Dark)
}
@@ -374,22 +494,29 @@ func (g *RogueGame) findDest(tp *Monster) *Coord {
if prob <= 0 || tp.Room == g.Player.Room || g.seeMonst(tp) {
return &g.Player.Pos
}
for _, obj := range g.Level.Objects {
if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster {
continue
}
if g.roomin(obj.Pos) == tp.Room && g.rnd(100) < prob {
claimed := false
for _, other := range g.Level.Monsters {
if other.Dest == &obj.Pos {
claimed = true
break
}
}
if !claimed {
return &obj.Pos
}
if g.roomIn(obj.Pos) == tp.Room && g.rnd(100) < prob &&
!g.objectClaimed(obj) {
return &obj.Pos
}
}
return &g.Player.Pos
}
// objectClaimed reports whether some monster already runs toward this
// object (chase.c find_dest).
func (g *RogueGame) objectClaimed(obj *Object) bool {
for _, other := range g.Level.Monsters {
if other.Dest == &obj.Pos {
return true
}
}
return false
}

File diff suppressed because it is too large Load Diff

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")
}
}

View File

@@ -2,16 +2,18 @@ package game
// Creature is the _t arm of the C THING union: the player or a monster.
type Creature struct {
Pos Coord // position
Turn bool // if slowed, is it a turn to move
Type byte // what it is: 'A'..'Z' for monsters, '@' for the player
Disguise byte // what mimic looks like
OldCh byte // character that was where it was
Dest *Coord // where it is running to — aliases live coords (hero pos, room gold, another monster's pos)
Flags CreatureFlags
Stats Stats
Room *Room // current room for thing
Pack []*Object // what the thing is carrying
Pos Coord // position
Turn bool // if slowed, is it a turn to move
Type byte // what it is: 'A'..'Z' for monsters, '@' for the player
Disguise byte // what mimic looks like
OldCh byte // character that was where it was
// Dest is where it is running to — aliases live coords (hero pos,
// room gold, another monster's pos).
Dest *Coord
Flags CreatureFlags
Stats Stats
Room *Room // current room for thing
Pack []*Object // what the thing is carrying
}
// Monster is a hostile creature on the level.
@@ -23,6 +25,7 @@ type Monster struct {
// in the C sources (cur_armor, purse, food_left, ...).
type Player struct {
Creature
CurArmor *Object // what he is wearing
CurWeapon *Object // which weapon he is wielding
CurRing [2]*Object // which rings are being worn (Left/Right)
@@ -49,6 +52,47 @@ func (p *Player) IsWearing(ring RingKind) bool {
return p.IsRing(Left, ring) || p.IsRing(Right, ring)
}
// nextPackChar claims and returns the next unused pack character (pack.c
// pack_char).
func (p *Player) nextPackChar() byte {
for i := range p.PackUsed {
if !p.PackUsed[i] {
p.PackUsed[i] = true
return byte(i) + 'a'
}
}
return byte(len(p.PackUsed)) + 'a' // C would walk off the array here
}
// removeFromPack takes an item out of the pack: the whole entry, or one
// of a stack when all is false (the bookkeeping half of pack.c
// leave_pack). It returns the object that left the pack — a copy when
// newobj asks for a split.
func (p *Player) removeFromPack(obj *Object, newobj, all bool) *Object {
p.Inpack--
nobj := obj
if obj.Count > 1 && !all {
obj.Count--
if obj.Group != 0 {
p.Inpack++
}
if newobj {
copied := *obj
nobj = &copied
nobj.Count = 1
}
} else {
p.PackUsed[obj.PackCh-'a'] = false
detachObj(&p.Pack, obj)
}
return nobj
}
// attachMon pushes a monster onto the front of a list (list.c attach).
func attachMon(list *[]*Monster, item *Monster) {
*list = append([]*Monster{item}, *list...)
@@ -59,6 +103,7 @@ func detachMon(list *[]*Monster, item *Monster) {
for i, m := range *list {
if m == item {
*list = append((*list)[:i], (*list)[i+1:]...)
return
}
}

View File

@@ -10,6 +10,10 @@ package game
// function-pointer-to-int mapping in state.c rs_write_daemons.
type DaemonID int
// Daemon and fuse callback identifiers. The first block's numeric values
// match the function-pointer-to-int mapping in state.c rs_write_daemons;
// the second block covers fuses state.c never saved (the Go save format
// handles them all uniformly).
const (
DNone DaemonID = 0
DRollwand DaemonID = 1
@@ -21,12 +25,10 @@ const (
DUnconfuse DaemonID = 7
DUnsee DaemonID = 8
DSight DaemonID = 9
// Fuses beyond the C save map (state.c never saved these; the Go save
// format handles them uniformly).
DVisuals DaemonID = 10
DComeDown DaemonID = 11
DLand DaemonID = 12
DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback
DVisuals DaemonID = 10
DComeDown DaemonID = 11
DLand DaemonID = 12
DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback
)
// Scheduling phases and slot states (daemon.c).
@@ -59,6 +61,7 @@ func (g *RogueGame) dSlot() *delayedAction {
return &g.Daemons.List[i]
}
}
panic("ran out of fuse slots") // C: debug message in MASTER, NULL deref otherwise
}
@@ -70,6 +73,7 @@ func (g *RogueGame) findSlot(f DaemonID) *delayedAction {
return d
}
}
return nil
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// daemons.c — the daemon and fuse callbacks, dispatched by DaemonID.
@@ -5,38 +6,14 @@ package game
// runDaemon invokes the callback named by id (the call through d_func in C).
func (g *RogueGame) runDaemon(id DaemonID, arg int) {
switch id {
case DRollwand:
g.rollwand(arg)
case DDoctor:
g.doctor(arg)
case DStomach:
g.stomach(arg)
case DRunners:
g.runners(arg)
case DSwander:
g.swander(arg)
case DNohaste:
g.nohaste(arg)
case DUnconfuse:
g.unconfuse(arg)
case DUnsee:
g.unsee(arg)
case DSight:
g.sight(arg)
case DVisuals:
g.visuals(arg)
case DComeDown:
g.comeDown(arg)
case DLand:
g.land(arg)
case DTurnSee:
g.turnSee(arg != 0)
default:
// Callbacks are added to this switch as their subsystems are
// ported; reaching one that isn't here is a porting bug.
h := g.data.daemonHandlers[id]
if h == nil {
// Handlers are added to the table as their subsystems are
// ported; reaching one that isn't there is a porting bug.
panic("daemon not yet ported")
}
h(g, arg)
}
// doctor is the healing daemon that restores hit points after rest
@@ -45,6 +22,7 @@ func (g *RogueGame) doctor(int) {
p := &g.Player
lv := p.Stats.Lvl
ohp := p.Stats.HP
g.Quiet++
if lv < 8 {
if g.Quiet+(lv<<1) > 20 {
@@ -53,16 +31,20 @@ func (g *RogueGame) doctor(int) {
} else if g.Quiet >= 3 {
p.Stats.HP += g.rnd(lv-7) + 1
}
if p.IsRing(Left, RingRegeneration) {
p.Stats.HP++
}
if p.IsRing(Right, RingRegeneration) {
p.Stats.HP++
}
if ohp != p.Stats.HP {
if p.Stats.HP > p.Stats.MaxHP {
p.Stats.HP = p.Stats.MaxHP
}
g.Quiet = 0
}
}
@@ -82,6 +64,7 @@ func (g *RogueGame) rollwand(int) {
g.KillDaemon(DRollwand)
g.Fuse(DSwander, 0, wanderTime(g), Before)
}
g.Daemons.Between = 0
}
}
@@ -103,6 +86,7 @@ func (g *RogueGame) unsee(int) {
g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh)
}
}
g.Player.Flags.Clear(CanSeeInvisible)
}
@@ -112,9 +96,11 @@ func (g *RogueGame) sight(int) {
if p.On(Blind) {
g.Extinguish(DSight)
p.Flags.Clear(Blind)
if !p.Room.Flags.Has(Gone) {
g.enterRoom(p.Pos)
}
g.msg("%s", g.chooseStr("far out! Everything is all cosmic again",
"the veil of darkness lifts"))
}
@@ -129,54 +115,78 @@ func (g *RogueGame) nohaste(int) {
// stomach digests the hero's food (daemons.c stomach).
func (g *RogueGame) stomach(int) {
p := &g.Player
origHungry := p.HungryState
if p.FoodLeft <= 0 {
if p.FoodLeft--; p.FoodLeft < -StarveTime {
g.death('s')
}
// the hero is fainting
if g.NoCommand != 0 || g.rnd(5) != 0 {
return
}
g.NoCommand += g.rnd(8) + 4
p.HungryState = 3
if !g.Options.Terse {
g.addmsg("%s", g.chooseStr(
"the munchies overpower your motor capabilities. ",
"you feel too weak from lack of food. "))
}
g.msg("%s", g.chooseStr("You freak out", "You faint"))
g.stomachFaint()
} else {
oldfood := p.FoodLeft
amulet := 0
if g.HasAmulet {
amulet = 1
}
p.FoodLeft -= g.ringEat(Left) + g.ringEat(Right) + 1 - amulet
if p.FoodLeft < MoreTime && oldfood >= MoreTime {
p.HungryState = 2
g.msg("%s", g.chooseStr(
"the munchies are interfering with your motor capabilites",
"you are starting to feel weak"))
} else if p.FoodLeft < 2*MoreTime && oldfood >= 2*MoreTime {
p.HungryState = 1
if g.Options.Terse {
g.msg("%s", g.chooseStr("getting the munchies", "getting hungry"))
} else {
g.msg("%s", g.chooseStr("you are getting the munchies",
"you are starting to get hungry"))
}
}
g.stomachDigest()
}
if p.HungryState != origHungry {
p.Flags.Clear(Awake)
g.Running = false
g.ToDeath = false
g.Count = 0
}
}
// stomachFaint starves and possibly faints an empty-stomached hero (the
// no-food arm of daemons.c stomach).
func (g *RogueGame) stomachFaint() {
p := &g.Player
if p.FoodLeft--; p.FoodLeft < -StarveTime {
g.death('s')
}
// the hero is fainting
if g.NoCommand != 0 || g.rnd(5) != 0 {
return
}
g.NoCommand += g.rnd(8) + 4
p.HungryState = 3
if !g.Options.Terse {
g.addmsgf("%s", g.chooseStr(
"the munchies overpower your motor capabilities. ",
"you feel too weak from lack of food. "))
}
g.msg("%s", g.chooseStr("You freak out", "You faint"))
}
// stomachDigest burns food and reports growing hunger (the fed arm of
// daemons.c stomach).
func (g *RogueGame) stomachDigest() {
p := &g.Player
oldfood := p.FoodLeft
amulet := 0
if g.HasAmulet {
amulet = 1
}
p.FoodLeft -= g.ringEat(Left) + g.ringEat(Right) + 1 - amulet
if p.FoodLeft < MoreTime && oldfood >= MoreTime {
p.HungryState = 2
g.msg("%s", g.chooseStr(
"the munchies are interfering with your motor capabilities",
"you are starting to feel weak"))
} else if p.FoodLeft < 2*MoreTime && oldfood >= 2*MoreTime {
p.HungryState = 1
if g.Options.Terse {
g.msg("%s", g.chooseStr("getting the munchies", "getting hungry"))
} else {
g.msg("%s", g.chooseStr("you are getting the munchies",
"you are starting to get hungry"))
}
}
}
// comeDown takes the hero down off her acid trip (daemons.c come_down).
func (g *RogueGame) comeDown(int) {
p := &g.Player
@@ -193,16 +203,18 @@ func (g *RogueGame) comeDown(int) {
// undo the things
for _, tp := range g.Level.Objects {
if g.cansee(tp.Pos.Y, tp.Pos.X) {
if g.canSee(tp.Pos.Y, tp.Pos.X) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.Kind.Glyph())
}
}
// undo the monsters
seemonst := p.On(SenseMonsters)
for _, tp := range g.Level.Monsters {
g.move(tp.Pos.Y, tp.Pos.X)
if g.cansee(tp.Pos.Y, tp.Pos.X) {
if g.canSee(tp.Pos.Y, tp.Pos.X) {
if !tp.On(Invisible) || p.On(CanSeeInvisible) {
g.addch(tp.Disguise)
} else {
@@ -214,41 +226,49 @@ func (g *RogueGame) comeDown(int) {
g.standend()
}
}
g.msg("Everything looks SO boring now.")
}
// visuals changes the characters for the player while hallucinating
// (daemons.c visuals).
func (g *RogueGame) visuals(int) {
p := &g.Player
if !g.After || (g.Running && g.Options.Jump) {
return
}
// change the things
for _, tp := range g.Level.Objects {
if g.cansee(tp.Pos.Y, tp.Pos.X) {
if g.canSee(tp.Pos.Y, tp.Pos.X) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, g.rndThing())
}
}
// change the stairs
if !g.SeenStairs && g.cansee(g.Level.Stairs.Y, g.Level.Stairs.X) {
if !g.SeenStairs && g.canSee(g.Level.Stairs.Y, g.Level.Stairs.X) {
g.mvaddch(g.Level.Stairs.Y, g.Level.Stairs.X, g.rndThing())
}
// change the monsters
seemonst := p.On(SenseMonsters)
g.visualMonsters()
}
// visualMonsters redraws the monsters through the hallucination (the
// monster loop of daemons.c visuals).
func (g *RogueGame) visualMonsters() {
seemonst := g.Player.On(SenseMonsters)
for _, tp := range g.Level.Monsters {
g.move(tp.Pos.Y, tp.Pos.X)
if g.seeMonst(tp) {
if tp.Type == 'X' && tp.Disguise != 'X' {
g.addch(g.rndThing())
} else {
g.addch(byte(g.rnd(26) + 'A'))
g.addch(g.randomMonsterLetter())
}
} else if seemonst {
g.standout()
g.addch(byte(g.rnd(26) + 'A'))
g.addch(g.randomMonsterLetter())
g.standend()
}
}

View File

@@ -25,20 +25,26 @@ type DiceSpec []DiceRoll
// a single 0x0 attack, as it did in C.
func ParseDice(s string) DiceSpec {
var spec DiceSpec
for s != "" {
count := cAtoi(s)
xi := strings.IndexByte(s, 'x')
if xi < 0 {
break
}
s = s[xi+1:]
spec = append(spec, DiceRoll{Count: count, Sides: cAtoi(s)})
si := strings.IndexByte(s, '/')
if si < 0 {
break
}
s = s[si+1:]
}
return spec
}
@@ -48,11 +54,14 @@ func dice(s string) DiceSpec { return ParseDice(s) }
// String renders the spec back in the classic "NxM/NxM" form.
func (d DiceSpec) String() string {
var sb strings.Builder
for i, r := range d {
if i > 0 {
sb.WriteByte('/')
}
fmt.Fprintf(&sb, "%dx%d", r.Count, r.Sides)
}
return sb.String()
}

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
@@ -6,6 +7,8 @@ import "testing"
// including its junk-tolerant edges: the bestiary placeholder "%%%x0" and
// the flytrap reset "000x0" both mean a single 0x0 attack.
func TestParseDice(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
@@ -32,14 +35,18 @@ func TestParseDice(t *testing.T) {
// The bestiary and weapon tables must parse to at least one attack each so
// every creature and weapon actually swings.
func TestTablesHaveDice(t *testing.T) {
for i, m := range monsterTable {
t.Parallel()
data := newGameData()
for i, m := range data.monsterTable {
if len(m.Stats.Dmg) == 0 {
t.Errorf("monster %c (%s) has no attacks", 'A'+i, m.Name)
}
}
for w, iw := range initWeaps {
for w, iw := range data.initWeaps {
if len(iw.dam) == 0 || len(iw.hrl) == 0 {
t.Errorf("weapon %v has empty dice", WeaponKind(w))
t.Errorf("weapon %d has empty dice", w)
}
}
}

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

@@ -1,14 +1,18 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// mkGameInput builds a headless game whose input plays the given script.
func mkGameInput(t *testing.T, seed int32, input string) *RogueGame {
// mkGameInput builds a headless game; tests script it via setInput. The
// fixed seed keeps the scripted item/monster interactions stable.
func mkGameInput(t *testing.T) *RogueGame {
t.Helper()
g := NewGame(Config{Seed: seed, Term: &testTerm{input: []byte(input)}})
g := New(Params{Seed: 5, Term: &testTerm{}})
g.NewLevel()
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomin(g.Player.Pos)
g.Oldrp = g.roomIn(g.Player.Pos)
return g
}
@@ -16,42 +20,65 @@ func mkGameInput(t *testing.T, seed int32, input string) *RogueGame {
func give(g *RogueGame, obj *Object) byte {
obj.Count = 1
g.addPack(obj, true)
return obj.PackCh
}
// setInput replaces the scripted terminal input.
func setInput(t *testing.T, g *RogueGame, input ...byte) {
t.Helper()
tt, ok := g.scr.term.(*testTerm)
if !ok {
t.Fatal("game terminal is not a testTerm")
}
tt.input = input
tt.pos = 0
}
func TestQuaffHealingPotion(t *testing.T) {
g := mkGameInput(t, 5, "")
t.Parallel()
g := mkGameInput(t)
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionHealing)
ch := give(g, pot)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.Player.Stats.HP = 1
g.quaff()
if g.Player.Stats.HP <= 1 {
t.Error("healing potion did not heal")
}
if !g.Items.Potions[PotionHealing].Know {
t.Error("healing potion not identified after drinking")
}
if len(g.Player.Pack) != 5 {
t.Errorf("potion not consumed: %d items", len(g.Player.Pack))
}
}
func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
g := mkGameInput(t, 5, "")
t.Parallel()
g := mkGameInput(t)
pot := newObject()
pot.Kind = KindPotion
pot.Which = int(PotionConfusion)
ch := give(g, pot)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.quaff()
if !g.Player.On(Confused) {
t.Error("confusion potion did not confuse")
}
if g.findSlot(DUnconfuse) == nil {
t.Error("no unconfuse fuse pending")
}
@@ -59,21 +86,25 @@ func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
for range 30 {
g.DoFuses(After)
}
if g.Player.On(Confused) {
t.Error("confusion never wore off")
}
}
func TestReadEnchantArmor(t *testing.T) {
g := mkGameInput(t, 5, "")
t.Parallel()
g := mkGameInput(t)
scr := newObject()
scr.Kind = KindScroll
scr.Which = int(ScrollEnchantArmor)
ch := give(g, scr)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
before := g.Player.CurArmor.ArmorClass
g.readScroll()
if g.Player.CurArmor.ArmorClass != before-1 {
t.Errorf("enchant armor: AC %d -> %d, want %d",
before, g.Player.CurArmor.ArmorClass, before-1)
@@ -81,22 +112,25 @@ func TestReadEnchantArmor(t *testing.T) {
}
func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
t.Parallel()
// Note: this must not use a greedy monster ('O' orc, ISGREED): the C
// wake_monster gold-guarding check has no ISHELD guard, so the
// look(TRUE) at the end of read_scroll immediately re-wakes greedy
// monsters. The port reproduces that quirk faithfully — see
// TestHoldScrollGreedyMonsterQuirk.
g := mkGameInput(t, 5, "")
g := mkGameInput(t)
tp := spawnAdjacent(g, 'Z')
tp.Flags.Set(Awake)
scr := newObject()
scr.Kind = KindScroll
scr.Which = int(ScrollHoldMonster)
ch := give(g, scr)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.readScroll()
t.Logf("after scroll: flags=%o huh=%q", tp.Flags, g.Msgs.Huh)
if tp.On(Awake) || !tp.On(Held) {
t.Error("hold monster scroll did not hold the adjacent monster")
}
@@ -107,21 +141,26 @@ func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
// (orc) held by a scroll is re-woken by the look(TRUE) that read_scroll
// performs, ending up both held and running again.
func TestHoldScrollGreedyMonsterQuirk(t *testing.T) {
g := mkGameInput(t, 5, "")
t.Parallel()
g := mkGameInput(t)
tp := spawnAdjacent(g, 'O')
tp.Flags.Set(Awake)
scr := newObject()
scr.Kind = KindScroll
scr.Which = int(ScrollHoldMonster)
ch := give(g, scr)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.readScroll()
t.Logf("orc after scroll: flags=%o (Awake=%v Held=%v)",
tp.Flags, tp.On(Awake), tp.On(Held))
if !tp.On(Held) {
t.Error("orc lost Held entirely")
}
if !tp.On(Awake) {
t.Error("quirk changed: greedy monster stayed held; if this is a " +
"deliberate fix, update this test and ARCHITECTURE.md")
@@ -129,41 +168,122 @@ func TestHoldScrollGreedyMonsterQuirk(t *testing.T) {
}
func TestZapSlowMonster(t *testing.T) {
g := mkGameInput(t, 5, "")
t.Parallel()
g := mkGameInput(t)
tp := spawnAdjacent(g, 'Z')
stick := newObject()
stick.Kind = KindWand
stick.Which = int(WandSlowMonster)
g.fixStick(stick)
ch := give(g, stick)
g.scr.term.(*testTerm).input = []byte{ch}
setInput(t, g, ch)
g.Delta = Coord{X: 1, Y: 0} // aim at the monster
charges := stick.Charges
g.doZap()
if !tp.On(Slowed) {
t.Error("slow monster wand did not slow")
}
if stick.Charges != charges-1 {
t.Error("zap did not use a charge")
}
}
// 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) {
g := NewGame(Config{Seed: 1})
t.Parallel()
g := New(Params{Seed: 1})
g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow")
if !g.Options.Terse {
t.Error("terse not set")
}
if g.Options.Jump {
t.Error("nojump not honored")
}
if g.Whoami != "Conan" {
t.Errorf("name = %q", g.Whoami)
}
if g.Fruit != "mango" {
t.Errorf("fruit = %q", g.Fruit)
}
if g.Options.InvType != InvSlow {
t.Errorf("inven = %d", g.Options.InvType)
}

View File

@@ -1,46 +1,10 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "strconv"
// fight.c — all the fighting gets done here.
// hNames are the strings for hitting; the first four are used when the
// player strikes, the second four for monsters (fight.c h_names).
var hNames = [8]string{
" scored an excellent hit on ",
" hit ",
" have injured ",
" swing and hit ",
" scored an excellent hit on ",
" hit ",
" has injured ",
" swings and hits ",
}
// mNames are the strings for missing (fight.c m_names).
var mNames = [8]string{
" miss",
" swing and miss",
" barely miss",
" don't hit",
" misses",
" swings and misses",
" barely misses",
" doesn't hit",
}
// strPlus adjusts hit probabilities due to strength (fight.c str_plus).
var strPlus = [32]int{
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3,
}
// addDam adjusts damage done due to strength (fight.c add_dam).
var addDam = [32]int{
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 3,
3, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6,
}
// setMname returns the monster name for the given monster (fight.c
// set_mname).
func (g *RogueGame) setMname(tp *Monster) string {
@@ -48,20 +12,27 @@ func (g *RogueGame) setMname(tp *Monster) string {
if g.Options.Terse {
return "it"
}
return "something"
}
var mname string
if g.Player.On(Hallucinating) {
ch := int(g.mvinch(tp.Pos.Y, tp.Pos.X))
if !isUpper(byte(ch)) {
ch = g.rnd(26)
var idx int
ch := g.mvinch(tp.Pos.Y, tp.Pos.X)
if isUpper(ch) {
idx = int(ch - 'A')
} else {
ch -= 'A'
idx = g.rnd(26)
}
mname = g.Monsters[ch].Name
mname = g.Monsters[idx].Name
} else {
mname = g.Monsters[tp.Type-'A'].Name
}
return "the " + mname
}
@@ -77,307 +48,468 @@ func (g *RogueGame) fight(mp Coord, weap *Object, thrown bool) bool {
// place.
g.Count = 0
g.Quiet = 0
g.runto(mp)
// Let him know it was really a xeroc (if it was one).
if tp.Type == 'X' && tp.Disguise != 'X' && !p.On(Blind) {
tp.Disguise = 'X'
if p.On(Hallucinating) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, byte(g.rnd(26)+'A'))
}
g.msg("%s", g.chooseStr("heavy! That's a nasty critter!",
"wait! That's a xeroc!"))
if !thrown {
return false
}
g.runTo(mp)
if g.revealXeroc(tp) && !thrown {
return false
}
mname := g.setMname(tp)
didHit := false
g.HasHit = g.Options.Terse && !g.ToDeath
if g.rollEm(&p.Creature, &tp.Creature, weap, thrown) {
didHit = false
if thrown {
g.thunk(weap, mname, g.Options.Terse)
} else {
g.hit("", mname, g.Options.Terse)
}
if p.On(CanConfuse) {
didHit = true
tp.Flags.Set(Confused)
p.Flags.Clear(CanConfuse)
g.endmsg()
g.HasHit = false
g.msg("your hands stop glowing %s", g.pickColor("red"))
}
if tp.Stats.HP <= 0 {
g.killed(tp, true)
} else if didHit && !p.On(Blind) {
g.msg("%s appears confused", mname)
}
didHit = true
} else {
if thrown {
g.bounce(weap, mname, g.Options.Terse)
} else {
g.miss("", mname, g.Options.Terse)
}
if g.rollAttacks(&p.Creature, &tp.Creature, weap, thrown) {
g.heroHits(tp, mname, weap, thrown)
return true
}
return didHit
if thrown {
g.bounce(weap, mname, g.Options.Terse)
} else {
g.miss("", mname, g.Options.Terse)
}
return false
}
// attack has the monster attack the player (fight.c attack). Returns -1 if
// the monster removed itself from the level during its own attack.
func (g *RogueGame) attack(mp *Monster) int {
// revealXeroc lets him know it was really a xeroc (if it was one); it
// reports whether one was unmasked (the X block of fight.c fight).
func (g *RogueGame) revealXeroc(tp *Monster) bool {
p := &g.Player
if tp.Type != 'X' || tp.Disguise == 'X' || p.On(Blind) {
return false
}
tp.Disguise = 'X'
if p.On(Hallucinating) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, g.randomMonsterLetter())
}
g.msg("%s", g.chooseStr("heavy! That's a nasty critter!",
"wait! That's a xeroc!"))
return true
}
// heroHits lands the hero's blow on a monster: messages, the confusing
// touch, and the kill check (the hit arm of fight.c fight).
func (g *RogueGame) heroHits(tp *Monster, mname string, weap *Object, thrown bool) {
p := &g.Player
confused := false
if thrown {
g.thunk(weap, mname, g.Options.Terse)
} else {
g.hit("", mname, g.Options.Terse)
}
if p.On(CanConfuse) {
confused = true
tp.Flags.Set(Confused)
p.Flags.Clear(CanConfuse)
g.endmsg()
g.HasHit = false
g.msg("your hands stop glowing %s", g.pickColor("red"))
}
if tp.Stats.HP <= 0 {
g.killed(tp, true)
} else if confused && !p.On(Blind) {
g.msg("%s appears confused", mname)
}
}
// attack has the monster attack the player (fight.c attack). The result
// reports that the monster took itself off the level during its own
// attack (the C -1 return).
func (g *RogueGame) attack(mp *Monster) bool {
p := &g.Player
// Since this is an attack, stop running and any healing that was
// going on at the time.
g.Running = false
g.Count = 0
g.Quiet = 0
if g.ToDeath && !mp.On(Targeted) {
g.ToDeath = false
g.Kamikaze = false
}
if mp.Type == 'X' && mp.Disguise != 'X' && !p.On(Blind) {
mp.Disguise = 'X'
if p.On(Hallucinating) {
g.mvaddch(mp.Pos.Y, mp.Pos.X, byte(g.rnd(26)+'A'))
g.mvaddch(mp.Pos.Y, mp.Pos.X, g.randomMonsterLetter())
}
}
mname := g.setMname(mp)
oldhp := p.Stats.HP
removed := false
if g.rollEm(&mp.Creature, &p.Creature, nil, false) {
if mp.Type != 'I' {
if g.HasHit {
g.addmsg(". ")
}
g.hit(mname, "", false)
} else if g.HasHit {
g.endmsg()
}
g.HasHit = false
if p.Stats.HP <= 0 {
g.death(mp.Type) // Bye bye life ...
} else if !g.Kamikaze {
oldhp -= p.Stats.HP
if oldhp > g.MaxHit {
g.MaxHit = oldhp
}
if p.Stats.HP <= g.MaxHit {
g.ToDeath = false
}
}
if !mp.On(Cancelled) {
switch mp.Type {
case 'A':
// If an aquator hits, you can lose armor class.
g.rustArmor(p.CurArmor)
case 'I':
// The ice monster freezes you
p.Flags.Clear(Awake)
if g.NoCommand == 0 {
g.addmsg("you are frozen")
if !g.Options.Terse {
g.addmsg(" by the %s", mname)
}
g.endmsg()
}
g.NoCommand += g.rnd(2) + 2
if g.NoCommand > BoreLevel {
g.death('h')
}
case 'R':
// Rattlesnakes have poisonous bites
if !g.save(VsPoison) {
if !p.IsWearing(RingSustainStrength) {
g.chgStr(-1)
if !g.Options.Terse {
g.msg("you feel a bite in your leg and now feel weaker")
} else {
g.msg("a bite has weakened you")
}
} else if !g.ToDeath {
if !g.Options.Terse {
g.msg("a bite momentarily weakens you")
} else {
g.msg("bite has no effect")
}
}
}
case 'W', 'V':
// Wraiths might drain energy levels, and Vampires can
// steal max_hp
chance := 30
if mp.Type == 'W' {
chance = 15
}
if g.rnd(100) < chance {
var fewer int
if mp.Type == 'W' {
if p.Stats.Exp == 0 {
g.death('W') // All levels gone
}
if p.Stats.Lvl--; p.Stats.Lvl == 0 {
p.Stats.Exp = 0
p.Stats.Lvl = 1
} else {
p.Stats.Exp = eLevels[p.Stats.Lvl-1] + 1
}
fewer = g.roll(1, 10)
} else {
fewer = g.roll(1, 3)
}
p.Stats.HP -= fewer
p.Stats.MaxHP -= fewer
if p.Stats.HP <= 0 {
p.Stats.HP = 1
}
if p.Stats.MaxHP <= 0 {
g.death(mp.Type)
}
g.msg("you suddenly feel weaker")
}
case 'F':
// Venus Flytrap stops the poor guy from moving
p.Flags.Set(Held)
p.VfHit++
g.Monsters['F'-'A'].Stats.Dmg = DiceSpec{{Count: p.VfHit, Sides: 1}}
if p.Stats.HP--; p.Stats.HP <= 0 {
g.death('F')
}
case 'L':
// Leprechaun steals some gold
lastpurse := p.Purse
p.Purse -= g.goldCalc()
if !g.save(VsMagic) {
p.Purse -= g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
}
if p.Purse < 0 {
p.Purse = 0
}
g.removeMon(mp.Pos, mp, false)
removed = true
if p.Purse != lastpurse {
g.msg("your purse feels lighter")
}
case 'N':
// Nymphs steal a magic item; look through the pack and
// pick out one we like.
var steal *Object
nobj := 0
for _, obj := range p.Pack {
if obj != p.CurArmor && obj != p.CurWeapon &&
obj != p.CurRing[Left] && obj != p.CurRing[Right] &&
obj.isMagic() {
if nobj++; g.rnd(nobj) == 0 {
steal = obj
}
}
}
if steal != nil {
g.removeMon(mp.Pos, g.Level.MonsterAt(mp.Pos.Y, mp.Pos.X), false)
removed = true
g.leavePack(steal, false, false)
g.msg("she stole %s!", g.invName(steal, true))
}
}
}
} else if mp.Type != 'I' {
if g.HasHit {
g.addmsg(". ")
g.HasHit = false
}
if mp.Type == 'F' {
p.Stats.HP -= p.VfHit
if p.Stats.HP <= 0 {
g.death(mp.Type) // Bye bye life ...
}
}
g.miss(mname, "", false)
if g.rollAttacks(&mp.Creature, &p.Creature, nil, false) {
removed = g.monsterHit(mp, mname, oldhp)
} else {
g.monsterMiss(mp, mname)
}
if g.Options.FightFlush && !g.ToDeath {
g.flushType()
}
g.Count = 0
g.status()
if removed {
return -1
return removed
}
// monsterHit lands a monster's blow on the hero: messages, death and
// to-death bookkeeping, then the monster's special power (the hit arm
// of fight.c attack). It reports whether the monster removed itself.
func (g *RogueGame) monsterHit(mp *Monster, mname string, oldhp int) bool {
p := &g.Player
if mp.Type != 'I' {
if g.HasHit {
g.addmsgf(". ")
}
g.hit(mname, "", false)
} else if g.HasHit {
g.endmsg()
}
return 0
g.HasHit = false
if p.Stats.HP <= 0 {
g.death(mp.Type) // Bye bye life ...
} else if !g.Kamikaze {
oldhp -= p.Stats.HP
if oldhp > g.MaxHit {
g.MaxHit = oldhp
}
if p.Stats.HP <= g.MaxHit {
g.ToDeath = false
}
}
if !mp.On(Cancelled) {
if h := g.data.hitHandlers[mp.Type-'A']; h != nil {
return h(g, mp, mname)
}
}
return false
}
// monsterMiss handles a monster's whiffed swing (the miss arm of
// fight.c attack); ice monsters miss silently.
func (g *RogueGame) monsterMiss(mp *Monster, mname string) {
if mp.Type == 'I' {
return
}
p := &g.Player
if g.HasHit {
g.addmsgf(". ")
g.HasHit = false
}
if mp.Type == 'F' {
p.Stats.HP -= p.VfHit
if p.Stats.HP <= 0 {
g.death(mp.Type) // Bye bye life ...
}
}
g.miss(mname, "", false)
}
// The monster special-power handlers, dispatched through
// gameData.hitHandlers when an uncancelled monster's hit lands. Each is
// one case of the C attack switch; a true return means the monster
// removed itself from the level.
func (g *RogueGame) hitAquator(*Monster, string) bool {
// If an aquator hits, you can lose armor class.
g.rustArmor(g.Player.CurArmor)
return false
}
func (g *RogueGame) hitIceMonster(_ *Monster, mname string) bool {
// The ice monster freezes you
g.Player.Flags.Clear(Awake)
if g.NoCommand == 0 {
g.addmsgf("you are frozen")
if !g.Options.Terse {
g.addmsgf(" by the %s", mname)
}
g.endmsg()
}
g.NoCommand += g.rnd(2) + 2
if g.NoCommand > BoreLevel {
g.death('h')
}
return false
}
func (g *RogueGame) hitRattlesnake(*Monster, string) bool {
// Rattlesnakes have poisonous bites
if g.save(VsPoison) {
return false
}
if !g.Player.IsWearing(RingSustainStrength) {
g.changeStrength(-1)
g.msg("%s", g.chooseTerse("a bite has weakened you",
"you feel a bite in your leg and now feel weaker"))
} else if !g.ToDeath {
g.msg("%s", g.chooseTerse("bite has no effect",
"a bite momentarily weakens you"))
}
return false
}
func (g *RogueGame) hitLifeDrainer(mp *Monster, _ string) bool {
// Wraiths might drain energy levels, and Vampires can steal max_hp
p := &g.Player
chance := 30
if mp.Type == 'W' {
chance = 15
}
if g.rnd(100) >= chance {
return false
}
var fewer int
if mp.Type == 'W' {
if p.Stats.Exp == 0 {
g.death('W') // All levels gone
}
if p.Stats.Lvl--; p.Stats.Lvl == 0 {
p.Stats.Exp = 0
p.Stats.Lvl = 1
} else {
p.Stats.Exp = g.data.eLevels[p.Stats.Lvl-1] + 1
}
fewer = g.roll(1, 10)
} else {
fewer = g.roll(1, 3)
}
p.Stats.HP -= fewer
p.Stats.MaxHP -= fewer
if p.Stats.HP <= 0 {
p.Stats.HP = 1
}
if p.Stats.MaxHP <= 0 {
g.death(mp.Type)
}
g.msg("you suddenly feel weaker")
return false
}
func (g *RogueGame) hitFlytrap(*Monster, string) bool {
// Venus Flytrap stops the poor guy from moving
p := &g.Player
p.Flags.Set(Held)
p.VfHit++
g.Monsters['F'-'A'].Stats.Dmg = DiceSpec{{Count: p.VfHit, Sides: 1}}
if p.Stats.HP--; p.Stats.HP <= 0 {
g.death('F')
}
return false
}
func (g *RogueGame) hitLeprechaun(mp *Monster, _ string) bool {
// Leprechaun steals some gold
p := &g.Player
lastpurse := p.Purse
p.Purse -= g.goldCalc()
if !g.save(VsMagic) {
p.Purse -= g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
}
if p.Purse < 0 {
p.Purse = 0
}
g.removeMon(mp.Pos, mp, false)
if p.Purse != lastpurse {
g.msg("your purse feels lighter")
}
return true
}
func (g *RogueGame) hitNymph(mp *Monster, _ string) bool {
// Nymphs steal a magic item; look through the pack and pick out one
// we like.
p := &g.Player
var steal *Object
nobj := 0
for _, obj := range p.Pack {
if obj != p.CurArmor && obj != p.CurWeapon &&
obj != p.CurRing[Left] && obj != p.CurRing[Right] &&
g.isMagic(obj) {
if nobj++; g.rnd(nobj) == 0 {
steal = obj
}
}
}
if steal == nil {
return false
}
g.removeMon(mp.Pos, g.Level.MonsterAt(mp.Pos.Y, mp.Pos.X), false)
g.leavePack(steal, false, false)
g.msg("she stole %s!", g.inventoryName(steal, true))
return true
}
// swing returns true if the swing hits (fight.c swing).
func (g *RogueGame) swing(atLvl, opArm, wplus int) bool {
res := g.rnd(20)
need := (20 - atLvl) - opArm
return res+wplus >= need
}
// rollEm rolls several attacks (fight.c roll_em).
func (g *RogueGame) rollEm(thatt, thdef *Creature, weap *Object, hurl bool) bool {
p := &g.Player
// rollAttacks rolls several attacks (fight.c roll_em).
func (g *RogueGame) rollAttacks(thatt, thdef *Creature, weap *Object, hurl bool) bool {
att := &thatt.Stats
def := &thdef.Stats
var attacks DiceSpec
var hplus, dplus int
var (
attacks DiceSpec
hplus, dplus int
)
if weap == nil {
attacks = att.Dmg
} else {
hplus = weap.HPlus
dplus = weap.DPlus
if weap == p.CurWeapon {
if p.IsRing(Left, RingIncreaseDamage) {
dplus += p.CurRing[Left].Bonus
} else if p.IsRing(Left, RingDexterity) {
hplus += p.CurRing[Left].Bonus
}
if p.IsRing(Right, RingIncreaseDamage) {
dplus += p.CurRing[Right].Bonus
} else if p.IsRing(Right, RingDexterity) {
hplus += p.CurRing[Right].Bonus
}
}
attacks = weap.Damage
if hurl {
if weap.Flags.Has(Missile) && p.CurWeapon != nil &&
WeaponKind(p.CurWeapon.Which) == weap.Launch {
attacks = weap.HurlDmg
hplus += p.CurWeapon.HPlus
dplus += p.CurWeapon.DPlus
} else if weap.Launch < 0 {
attacks = weap.HurlDmg
}
}
attacks, hplus, dplus = g.weaponAttack(weap, hurl)
}
// If the creature being attacked is not running (asleep or held) then
// the attacker gets a plus four bonus to hit.
if !thdef.Flags.Has(Awake) {
hplus += 4
}
defArm := g.defenderArmor(thdef)
didHit := false
for _, atk := range attacks {
if g.swing(att.Lvl, defArm, hplus+g.data.strPlus[att.Str]) {
proll := g.roll(atk.Count, atk.Sides)
damage := dplus + proll + g.data.addDam[att.Str]
if damage > 0 {
def.HP -= damage
}
didHit = true
}
}
return didHit
}
// weaponAttack picks the dice and to-hit/damage bonuses a weapon swings
// with: ring bonuses when wielded, and launcher pairing for hurled
// missiles (the weapon preamble of fight.c roll_em).
func (g *RogueGame) weaponAttack(weap *Object, hurl bool) (DiceSpec, int, int) {
p := &g.Player
hplus := weap.HPlus
dplus := weap.DPlus
if weap == p.CurWeapon {
hplus, dplus = g.wieldedRingBonus(hplus, dplus)
}
attacks := weap.Damage
if hurl {
if weap.Flags.Has(Missile) && p.CurWeapon != nil &&
WeaponKind(p.CurWeapon.Which) == weap.Launch {
attacks = weap.HurlDmg
hplus += p.CurWeapon.HPlus
dplus += p.CurWeapon.DPlus
} else if weap.Launch < 0 {
attacks = weap.HurlDmg
}
}
return attacks, hplus, dplus
}
// wieldedRingBonus folds damage and dexterity ring bonuses into the
// wielded weapon's to-hit/damage pluses (fight.c roll_em).
func (g *RogueGame) wieldedRingBonus(hplus, dplus int) (int, int) {
p := &g.Player
if p.IsRing(Left, RingIncreaseDamage) {
dplus += p.CurRing[Left].Bonus
} else if p.IsRing(Left, RingDexterity) {
hplus += p.CurRing[Left].Bonus
}
if p.IsRing(Right, RingIncreaseDamage) {
dplus += p.CurRing[Right].Bonus
} else if p.IsRing(Right, RingDexterity) {
hplus += p.CurRing[Right].Bonus
}
return hplus, dplus
}
// defenderArmor computes the defender's effective armor class: worn
// armor and protection rings when the hero defends (the def_arm
// computation of fight.c roll_em).
func (g *RogueGame) defenderArmor(thdef *Creature) int {
p := &g.Player
def := &thdef.Stats
defArm := def.ArmorClass
if def == &p.Stats {
if p.CurArmor != nil {
defArm = p.CurArmor.ArmorClass
}
if p.IsRing(Left, RingProtection) {
defArm -= p.CurRing[Left].Bonus
}
if p.IsRing(Right, RingProtection) {
defArm -= p.CurRing[Right].Bonus
}
}
didHit := false
for _, atk := range attacks {
if g.swing(att.Lvl, defArm, hplus+strPlus[att.Str]) {
proll := g.roll(atk.Count, atk.Sides)
damage := dplus + proll + addDam[att.Str]
if damage > 0 {
def.HP -= damage
}
didHit = true
}
}
return didHit
return defArm
}
// cAtoi parses a leading integer like C atoi: trailing non-digits are
@@ -387,7 +519,9 @@ func cAtoi(s string) int {
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
n, _ := strconv.Atoi(s[:i])
return n
}
@@ -398,9 +532,11 @@ func prname(mname string, upper bool) string {
if out == "" {
out = "you"
}
if upper {
out = string(toUpper(out[0])) + out[1:]
}
return out
}
@@ -409,12 +545,15 @@ func (g *RogueGame) thunk(weap *Object, mname string, noend bool) {
if g.ToDeath {
return
}
if weap.Kind == KindWeapon {
g.addmsg("the %s hits ", g.Items.Weapons[weap.Which].Name)
g.addmsgf("the %s hits ", g.Items.Weapons[weap.Which].Name)
} else {
g.addmsg("you hit ")
g.addmsgf("you hit ")
}
g.addmsg("%s", mname)
g.addmsgf("%s", mname)
if !noend {
g.endmsg()
}
@@ -425,7 +564,9 @@ func (g *RogueGame) hit(er, ee string, noend bool) {
if g.ToDeath {
return
}
g.addmsg("%s", prname(er, true))
g.addmsgf("%s", prname(er, true))
var s string
if g.Options.Terse {
s = " hit"
@@ -434,12 +575,16 @@ func (g *RogueGame) hit(er, ee string, noend bool) {
if er != "" {
i += 4
}
s = hNames[i]
s = g.data.hNames[i]
}
g.addmsg("%s", s)
g.addmsgf("%s", s)
if !g.Options.Terse {
g.addmsg("%s", prname(ee, false))
g.addmsgf("%s", prname(ee, false))
}
if !noend {
g.endmsg()
}
@@ -450,18 +595,24 @@ func (g *RogueGame) miss(er, ee string, noend bool) {
if g.ToDeath {
return
}
g.addmsg("%s", prname(er, true))
g.addmsgf("%s", prname(er, true))
i := 0
if !g.Options.Terse {
i = g.rnd(4)
}
if er != "" {
i += 4
}
g.addmsg("%s", mNames[i])
g.addmsgf("%s", g.data.mNames[i])
if !g.Options.Terse {
g.addmsg(" %s", prname(ee, false))
g.addmsgf(" %s", prname(ee, false))
}
if !noend {
g.endmsg()
}
@@ -472,12 +623,15 @@ func (g *RogueGame) bounce(weap *Object, mname string, noend bool) {
if g.ToDeath {
return
}
if weap.Kind == KindWeapon {
g.addmsg("the %s misses ", g.Items.Weapons[weap.Which].Name)
g.addmsgf("the %s misses ", g.Items.Weapons[weap.Which].Name)
} else {
g.addmsg("you missed ")
g.addmsgf("you missed ")
}
g.addmsg("%s", mname)
g.addmsgf("%s", mname)
if !noend {
g.endmsg()
}
@@ -489,15 +643,19 @@ func (g *RogueGame) removeMon(mp Coord, tp *Monster, waskill bool) {
for _, obj := range pack {
obj.Pos = tp.Pos
detachObj(&tp.Pack, obj)
if waskill {
g.fall(obj, false)
}
}
g.Level.SetMonsterAt(mp.Y, mp.X, nil)
g.mvaddch(mp.Y, mp.X, tp.OldCh)
detachMon(&g.Level.Monsters, tp)
g.Level.RemoveMonster(tp)
if tp.On(Targeted) {
g.Kamikaze = false
g.ToDeath = false
if g.Options.FightFlush {
g.flushType()
@@ -510,6 +668,38 @@ func (g *RogueGame) killed(tp *Monster, pr bool) {
p := &g.Player
p.Stats.Exp += tp.Stats.Exp
g.killedSpecial(tp)
// Get rid of the monster.
mname := g.setMname(tp)
g.removeMon(tp.Pos, tp, true)
if pr {
if g.HasHit {
g.addmsgf(". Defeated ")
g.HasHit = false
} else {
if !g.Options.Terse {
g.addmsgf("you have ")
}
g.addmsgf("defeated ")
}
g.msg("%s", mname)
}
// Do adjustments if he went up a level
g.checkLevel()
if g.Options.FightFlush {
g.flushType()
}
}
// killedSpecial handles deaths with side effects: a flytrap releases its
// grip and a leprechaun drops its gold (the switch of fight.c killed).
func (g *RogueGame) killedSpecial(tp *Monster) {
p := &g.Player
// If the monster was a venus flytrap, un-hold him
switch tp.Type {
case 'F':
@@ -521,36 +711,19 @@ func (g *RogueGame) killed(tp *Monster, pr bool) {
if ok {
tp.Room.Gold = pos
}
if ok && g.Depth >= g.MaxDepth {
gold := newObject()
gold.Kind = KindGold
gold.GoldValue = g.goldCalc()
if g.save(VsMagic) {
gold.GoldValue += g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
}
attachObj(&tp.Pack, gold)
}
}
// Get rid of the monster.
mname := g.setMname(tp)
g.removeMon(tp.Pos, tp, true)
if pr {
if g.HasHit {
g.addmsg(". Defeated ")
g.HasHit = false
} else {
if !g.Options.Terse {
g.addmsg("you have ")
}
g.addmsg("defeated ")
}
g.msg("%s", mname)
}
// Do adjustments if he went up a level
g.checkLevel()
if g.Options.FightFlush {
g.flushType()
}
}
// flushType flushes typeahead for the fight_flush option (mach_dep.c

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
@@ -6,10 +7,12 @@ import "testing"
// look() state the way playit() does before the first command.
func mkGame(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := NewGame(Config{Seed: seed, Term: &testTerm{}})
g := New(Params{Seed: seed, Term: &testTerm{}})
g.NewLevel()
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomin(g.Player.Pos)
g.Oldrp = g.roomIn(g.Player.Pos)
return g
}
@@ -18,10 +21,13 @@ func spawnAdjacent(g *RogueGame, typ byte) *Monster {
pos := Coord{X: g.Player.Pos.X + 1, Y: g.Player.Pos.Y}
tp := &Monster{}
g.newMonster(tp, typ, pos)
return tp
}
func TestRollEmParsesMultiAttackDice(t *testing.T) {
t.Parallel()
g := mkGame(t, 42)
att := &Creature{Stats: Stats{Str: 16, Lvl: 20, Dmg: dice("1x4/1x4/1x4")}}
def := &Creature{Stats: Stats{ArmorClass: 10, HP: 1000}}
@@ -29,9 +35,10 @@ func TestRollEmParsesMultiAttackDice(t *testing.T) {
// With attacker level 20 vs armor 10, swing always hits
// (rnd(20)+wplus >= (20-20)-10 is always true), so three attacks of
// 1x4 + str bonus 1 each must deal between 6 and 15 damage.
if !g.rollEm(att, def, nil, false) {
if !g.rollAttacks(att, def, nil, false) {
t.Fatal("attack with guaranteed swing missed")
}
dmg := 1000 - def.Stats.HP
if dmg < 6 || dmg > 15 {
t.Errorf("three 1x4+1 attacks dealt %d damage, want 6..15", dmg)
@@ -39,73 +46,75 @@ func TestRollEmParsesMultiAttackDice(t *testing.T) {
}
func TestFightKillsMonster(t *testing.T) {
t.Parallel()
g := mkGame(t, 7)
tp := spawnAdjacent(g, 'B') // bat: 1 hit die
tp.Stats.HP = 1
g.Player.Stats.Lvl = 20 // always hits
before := len(g.Level.Monsters)
g.fight(tp.Pos, g.Player.CurWeapon, false)
if len(g.Level.Monsters) != before-1 {
t.Error("monster not removed after fatal fight")
}
if g.Level.MonsterAt(tp.Pos.Y, tp.Pos.X) != nil {
t.Error("map still records dead monster")
}
if g.Player.Stats.Exp == 0 {
t.Error("no experience for the kill")
}
}
func TestAttackHurtsPlayer(t *testing.T) {
t.Parallel()
g := mkGame(t, 9)
tp := spawnAdjacent(g, 'T') // troll: 1x8/1x8/2x6
tp.Stats.Lvl = 20 // always hits
tp.Flags.Clear(Cancelled)
hpBefore := g.Player.Stats.HP
g.Player.Stats.HP = 500
g.Player.Stats.MaxHP = 500
g.attack(tp)
if g.Player.Stats.HP >= 500 {
t.Errorf("player HP unchanged (%d -> %d)", hpBefore, g.Player.Stats.HP)
}
}
func TestDeathUnwindsWithGameEnd(t *testing.T) {
g := mkGame(t, 11)
defer func() {
r := recover()
if _, ok := r.(gameEnd); !ok {
t.Fatalf("death did not unwind with gameEnd, got %v", r)
}
if g.Playing {
t.Error("still playing after death")
}
}()
g.Options.Tombstone = false
g.death('K')
}
func TestRunnersChaseHero(t *testing.T) {
t.Parallel()
g := mkGame(t, 3)
// Place a hobgoblin a few squares away in the hero's room and set it
// running at the hero.
p := &g.Player
pos := Coord{X: p.Pos.X + 3, Y: p.Pos.Y}
if !stepOk(g.Level.Char(pos.Y, pos.X)) || g.Level.MonsterAt(pos.Y, pos.X) != nil {
t.Skip("no clear lane on this seed")
}
tp := &Monster{}
g.newMonster(tp, 'H', pos)
tp.Flags.Set(Awake)
tp.Dest = &p.Pos
d0 := distCp(tp.Pos, p.Pos)
g.runners(0)
if d1 := distCp(tp.Pos, p.Pos); d1 >= d0 {
t.Errorf("monster did not close distance: %d -> %d", d0, d1)
}
}
func TestKilledLeprechaunDropsGoldViaFall(t *testing.T) {
t.Parallel()
g := mkGame(t, 21)
tp := spawnAdjacent(g, 'L')
tp.Stats.HP = 0

View File

@@ -1,5 +1,8 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
// ItemLore is the per-game item identity state: the randomized appearance
// names and the seven mutable ObjInfo tables (extern.c/init.c).
type ItemLore struct {
@@ -31,9 +34,9 @@ type Options struct {
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
}
// Config carries everything needed to construct a game.
type Config struct {
Seed int32 // dungeon number; the caller derives it (time+pid or SEED env)
// Params carries everything needed to construct a game.
type Params struct {
Seed int32 // dungeon number; caller derives it (time+pid or SEED)
Name string // player name (overridden by ROGUEOPTS name=)
RogueOpts string // the ROGUEOPTS environment string
Home string // home directory (save file default location)
@@ -44,7 +47,7 @@ type Config struct {
// RogueGame is one complete game of Rogue: every piece of state that was a
// global (or file-scope static) in the C sources, plus the terminal it is
// played on. Construct with NewGame, then call Run.
// played on. Construct with New, then call Run.
//
// The struct grows with the port; fields appear in the phase that ports the
// code owning them.
@@ -110,7 +113,7 @@ type RogueGame struct {
// screen / messages
scr *Screen
Msgs MsgLine
Msgs MessageLine
statusCache statusCache
invPage invPage // things.c discovery-list pagination statics
@@ -138,24 +141,84 @@ type RogueGame struct {
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
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 *gameData
}
// NewGame builds a game from cfg, seeds the RNG, and randomizes the item
// Greeting is the line C printed on stdout while the player waited for
// the dungeon to be dug, immediately before initscr() (main.c main). The
// caller prints it before the terminal package takes the screen, which is
// where initscr() sat; there is no trailing newline in either wording,
// because C followed the printf with fflush and let curses have the
// display.
//
// Only the wizard wording is #ifdef MASTER in C, and it carries the
// dungeon number, which is the seed (main.c assigns seed = dnum right
// after choosing dnum). The other wording is unconditional.
//
// The name is C's whoami, resolved the way main.c resolves it: parse_opts
// runs before the printf, so a ROGUEOPTS "name=" setting is what the
// player is greeted by, and the account name is only the fallback. New
// does the same parse a moment later; doing it here too is safe because
// ParseOpts does nothing but assign into the fields it is handed — no
// RNG, no screen — so it cannot disturb the item tables the seed-compat
// golden pins.
//
// The game it parses into is a throwaway, but it is built the way New
// builds the real one, because ParseOpts handles every option and not
// just the one this function reads: "inven=" is matched against the
// inv_t_name[] table and "file=~/..." against the home directory, both
// of which live on the game. A greeting that skimped on them faulted on
// a perfectly legal ROGUEOPTS before the player saw a single character.
func Greeting(params Params) string {
whoami := params.Name
if params.RogueOpts != "" {
opts := &RogueGame{
data: newGameData(),
Whoami: params.Name,
Home: params.Home,
}
opts.ParseOpts(params.RogueOpts)
whoami = opts.Whoami
}
if params.Wizard {
return fmt.Sprintf("Hello %s, welcome to dungeon #%d",
whoami, params.Seed)
}
return fmt.Sprintf(
"Hello %s, just a moment while I dig the dungeon...", whoami)
}
// New builds a game from params, seeds the RNG, and randomizes the item
// appearance tables (the front half of main.c main(); the player roll-up
// and first level arrive with later porting phases).
func NewGame(cfg Config) *RogueGame {
func New(params Params) *RogueGame {
g := &RogueGame{
Rng: &Rng{Seed: cfg.Seed},
Dnum: int(cfg.Seed),
Whoami: cfg.Name,
data: newGameData(),
Rng: &Rng{Seed: params.Seed},
Dnum: int(params.Seed),
Whoami: params.Name,
Fruit: "slime-mold",
Home: cfg.Home,
Wizard: cfg.Wizard,
NoScore: cfg.Wizard,
Home: params.Home,
Wizard: params.Wizard,
NoScore: params.Wizard,
Playing: true,
Depth: 1,
ScorePath: cfg.ScorePath,
ScorePath: params.ScorePath,
LastScore: -1,
sigSave: make(chan *autoSaveRequest, 1),
}
g.Options = Options{
SeeFloor: true,
@@ -164,17 +227,21 @@ func NewGame(cfg Config) *RogueGame {
}
g.InvDescribe = true
g.Msgs.SaveMsg = true
g.scr = NewScreen(cfg.Term)
g.FileName = cfg.Home + "/rogue.save"
g.rogueOpts = cfg.RogueOpts
if cfg.Wizard {
g.scr = NewScreen(params.Term)
g.Msgs.attach(g.scr, g.look, g.readchar)
g.FileName = params.Home + "/rogue.save"
g.rogueOpts = params.RogueOpts
if params.Wizard {
g.Player.Flags.Set(SenseMonsters)
}
if cfg.RogueOpts != "" {
g.ParseOpts(cfg.RogueOpts)
if params.RogueOpts != "" {
g.ParseOpts(params.RogueOpts)
}
g.Monsters = monsterTable
g.Monsters = g.data.monsterTable
g.Items.Group = 2 // weapons.c: int group = 2
for i := range g.Level.Passages {
g.Level.Passages[i].Flags = Gone | Dark
@@ -186,34 +253,50 @@ func NewGame(cfg Config) *RogueGame {
g.initColors() // set up colors of potions
g.initStones() // set up stone settings of rings
g.initMaterials() // set up materials of wands
return g
}
// Run plays the game to its end: the back half of main.c main() plus
// playit(). It returns after death, victory, quitting, or saving.
func (g *RogueGame) Run() (err error) {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(gameEnd); ok {
return // normal game over / save exit
}
panic(r)
}
}()
if !g.restored {
g.NewLevel() // draw current level
// Start up daemons and fuses
g.StartDaemon(DRunners, 0, After)
g.StartDaemon(DDoctor, 0, After)
g.Fuse(DSwander, 0, wanderTime(g), After)
g.StartDaemon(DStomach, 0, After)
}
// playit(). It does not return — the game ends by exiting the process
// (see myExit); one game run is one process.
func (g *RogueGame) Run() {
g.startLevel()
g.playit()
return nil
}
// startLevel draws the first level and starts the standing daemons and
// fuses for a fresh game; a restored game brings its own (the back half
// of main.c main()).
func (g *RogueGame) startLevel() {
if g.restored {
return
}
g.NewLevel() // draw current level
// Start up daemons and fuses
g.StartDaemon(DRunners, 0, After)
g.StartDaemon(DDoctor, 0, After)
g.Fuse(DSwander, 0, wanderTime(g), After)
g.StartDaemon(DStomach, 0, After)
}
// playit is the main loop of the program (main.c playit).
func (g *RogueGame) playit() {
g.prePlay()
for g.Playing {
g.command() // command execution
}
g.endit()
}
// prePlay does the option and position setup at the top of playit,
// before the command loop (main.c playit). It is split out so tests can
// drive a bounded number of turns; the loop itself never returns,
// because game-over exits the process.
func (g *RogueGame) prePlay() {
// set up defaults for modern terminals: curses' md_hasclreol() is
// always true, so the C default inventory style applies
if !g.restored {
@@ -226,11 +309,7 @@ func (g *RogueGame) playit() {
}
g.Oldpos = g.Player.Pos
g.Oldrp = g.roomin(g.Player.Pos)
for g.Playing {
g.command() // command execution
}
g.endit()
g.Oldrp = g.roomIn(g.Player.Pos)
}
// endit exits the game (main.c endit).
@@ -242,7 +321,7 @@ func (g *RogueGame) endit() {
func (g *RogueGame) fatal(s string) {
g.mvaddstr(NumLines-2, 0, s)
g.refresh()
g.myExit(0)
g.myExit()
}
// quit has the player make certain, then exits (main.c quit). The final
@@ -252,17 +331,21 @@ func (g *RogueGame) quit(int) {
if !g.QComm {
g.Msgs.Mpos = 0
}
oy, ox := g.scr.Std.GetYX()
g.msg("really quit?")
if g.readchar() == 'y' {
g.clear()
g.scr.Std.MvPrintw(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse)
g.scr.Std.MvPrintwf(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse)
g.move(NumLines-1, 0)
g.refresh()
g.score(g.Player.Purse, 1, 0)
g.myExit(0)
g.myExit()
return
}
g.move(0, 0)
g.clrtoeol()
g.status()

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

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "strings"
@@ -8,7 +9,7 @@ import "strings"
// initPlayer rolls her up (init.c init_player).
func (g *RogueGame) initPlayer() {
p := &g.Player
p.MaxStats = initStats
p.MaxStats = g.data.initStats
p.Stats = p.MaxStats
p.FoodLeft = HungerTime
// Give him some food
@@ -20,7 +21,7 @@ func (g *RogueGame) initPlayer() {
obj = newObject()
obj.Kind = KindArmor
obj.Which = int(ArmorRingMail)
obj.ArmorClass = aClass[ArmorRingMail] - 1
obj.ArmorClass = g.data.aClass[ArmorRingMail] - 1
obj.Flags.Set(Known)
obj.Count = 1
p.CurArmor = obj
@@ -50,36 +51,42 @@ func (g *RogueGame) initPlayer() {
// initColors initializes the potion color scheme for this game
// (init.c init_colors).
func (g *RogueGame) initColors() {
used := make([]bool, len(rainbow))
for i := PotionKind(0); i < NumPotionTypes; i++ {
used := make([]bool, len(g.data.rainbow))
for i := range NumPotionTypes {
var j int
for {
j = g.rnd(len(rainbow))
j = g.rnd(len(g.data.rainbow))
if !used[j] {
break
}
}
used[j] = true
g.Items.PotColors[i] = rainbow[j]
g.Items.PotColors[i] = g.data.rainbow[j]
}
}
// initNames generates the names of the various scrolls (init.c init_names).
func (g *RogueGame) initNames() {
for i := ScrollKind(0); i < NumScrollTypes; i++ {
for i := range NumScrollTypes {
var cp strings.Builder
nwords := g.rnd(3) + 2
for ; nwords > 0; nwords-- {
nsyl := g.rnd(3) + 1
for ; nsyl > 0; nsyl-- {
sp := sylls[g.rnd(len(sylls))]
sp := g.data.sylls[g.rnd(len(g.data.sylls))]
if cp.Len()+len(sp) > MaxNameLen {
break
}
cp.WriteString(sp)
}
cp.WriteByte(' ')
}
g.Items.ScrNames[i] = strings.TrimSuffix(cp.String(), " ")
}
}
@@ -87,47 +94,54 @@ func (g *RogueGame) initNames() {
// initStones initializes the ring stone setting scheme for this game
// (init.c init_stones).
func (g *RogueGame) initStones() {
used := make([]bool, len(stoneTable))
for i := RingKind(0); i < NumRingTypes; i++ {
used := make([]bool, len(g.data.stoneTable))
for i := range NumRingTypes {
var j int
for {
j = g.rnd(len(stoneTable))
j = g.rnd(len(g.data.stoneTable))
if !used[j] {
break
}
}
used[j] = true
g.Items.RingStones[i] = stoneTable[j].Name
g.Items.Rings[i].Worth += stoneTable[j].Value
g.Items.RingStones[i] = g.data.stoneTable[j].Name
g.Items.Rings[i].Worth += g.data.stoneTable[j].Value
}
}
// initMaterials initializes the construction materials for wands and staffs
// (init.c init_materials).
func (g *RogueGame) initMaterials() {
used := make([]bool, len(woods))
metused := make([]bool, len(metals))
for i := WandKind(0); i < NumWandTypes; i++ {
used := make([]bool, len(g.data.woods))
metused := make([]bool, len(g.data.metals))
for i := range NumWandTypes {
var str string
for {
if g.rnd(2) == 0 {
j := g.rnd(len(metals))
j := g.rnd(len(g.data.metals))
if !metused[j] {
g.Items.WandType[i] = "wand"
str = metals[j]
g.Items.WandType[i] = wandName
str = g.data.metals[j]
metused[j] = true
break
}
} else {
j := g.rnd(len(woods))
j := g.rnd(len(g.data.woods))
if !used[j] {
g.Items.WandType[i] = "staff"
str = woods[j]
g.Items.WandType[i] = staffName
str = g.data.woods[j]
used[j] = true
break
}
}
}
g.Items.WandMade[i] = str
}
}
@@ -143,20 +157,21 @@ func sumProbs(info []ObjInfo) {
// initProbs copies the base tables into the game and initializes the
// probabilities for the various items (init.c init_probs).
func (g *RogueGame) initProbs() {
g.Items.Things = baseThings
g.Items.Potions = basePotInfo
g.Items.Scrolls = baseScrInfo
g.Items.Rings = baseRingInfo
g.Items.Sticks = baseWsInfo
g.Items.Weapons = baseWeapInfo
g.Items.Armors = baseArmInfo
g.Items.Things = g.data.baseThings
g.Items.Potions = g.data.basePotInfo
g.Items.Scrolls = g.data.baseScrInfo
g.Items.Rings = g.data.baseRingInfo
g.Items.Sticks = g.data.baseWsInfo
g.Items.Weapons = g.data.baseWeapInfo
g.Items.Armors = g.data.baseArmInfo
sumProbs(g.Items.Things[:])
sumProbs(g.Items.Potions[:])
sumProbs(g.Items.Scrolls[:])
sumProbs(g.Items.Rings[:])
sumProbs(g.Items.Sticks[:])
sumProbs(g.Items.Weapons[:NumWeaponTypes]) // C sums MAXWEAPONS, excluding the flame entry
// C sums MAXWEAPONS, excluding the flame entry.
sumProbs(g.Items.Weapons[:NumWeaponTypes])
sumProbs(g.Items.Armors[:])
}
@@ -164,7 +179,8 @@ func (g *RogueGame) initProbs() {
// hallucinating (init.c pick_color).
func (g *RogueGame) pickColor(col string) string {
if g.Player.On(Hallucinating) {
return rainbow[g.rnd(len(rainbow))]
return g.data.rainbow[g.rnd(len(g.data.rainbow))]
}
return col
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
@@ -10,9 +11,11 @@ import (
// maxMsg is io.c MAXMSG: how much message fits before --More--.
const maxMsg = NumCols - len("--More--") - 1
// MsgLine is the io.c message machinery: the static msgbuf/newpos pair plus
// the related globals (mpos, huh, and the message-behavior flags).
type MsgLine struct {
// MessageLine is the io.c message machinery: the static msgbuf/newpos
// pair plus the related globals (mpos, huh, and the message-behavior
// flags). It owns the top line of the screen; attach wires in the
// display and input it needs.
type MessageLine struct {
buf strings.Builder // msgbuf
newpos int
Mpos int // where cursor is on top line
@@ -20,84 +23,137 @@ type MsgLine struct {
SaveMsg bool // remember last msg
LowerMsg bool // messages should start w/lower case
MsgEsc bool // check for ESC from msg's --More--
scr *Screen // the top line lives on scr.Std
look func(wakeup bool) // redraw before a --More-- (misc.c look)
readChar func() byte // input for --More-- prompts
}
// Msg displays a message at the top of the screen (io.c msg). It returns
// Escape if the player escaped out of a --More--, ^Escape otherwise (the C
// convention: callers compare against ESCAPE).
func (g *RogueGame) msg(format string, a ...any) int {
// Escape if the player escaped out of a --More--, ^Escape otherwise (the
// C convention: callers compare against ESCAPE).
func (m *MessageLine) Msg(format string, a ...any) int {
// if the string is "", just clear the line
if format == "" {
g.move(0, 0)
g.clrtoeol()
g.Msgs.Mpos = 0
m.scr.Std.Move(0, 0)
m.scr.Std.Clrtoeol()
m.Mpos = 0
return ^Escape
}
// otherwise add to the message and flush it out
g.doadd(format, a...)
return g.endmsg()
m.doaddf(format, a...)
return m.End()
}
// addmsg adds things to the current message (io.c addmsg).
func (g *RogueGame) addmsg(format string, a ...any) {
g.doadd(format, a...)
// Addf adds things to the current message (io.c addmsg).
func (m *MessageLine) Addf(format string, a ...any) {
m.doaddf(format, a...)
}
// endmsg displays a new msg, giving the player a chance to see the previous
// End displays a new msg, giving the player a chance to see the previous
// one if it is up there with the --More-- (io.c endmsg).
func (g *RogueGame) endmsg() int {
m := &g.Msgs
func (m *MessageLine) End() int {
if m.SaveMsg {
m.Huh = m.buf.String()
}
if m.Mpos != 0 {
g.look(false)
g.mvaddstr(0, m.Mpos, "--More--")
g.refresh()
if !m.MsgEsc {
g.waitFor(' ')
} else {
for {
ch := g.readchar()
if ch == ' ' {
break
}
if ch == Escape {
m.buf.Reset()
m.Mpos = 0
m.newpos = 0
return Escape
}
}
}
if m.Mpos != 0 && m.promptMore() == Escape {
return Escape
}
// All messages should start with uppercase, except ones that start
// with a pack addressing character
out := m.buf.String()
if len(out) > 0 && isLower(out[0]) && !m.LowerMsg &&
!(len(out) > 1 && out[1] == ')') {
(len(out) <= 1 || out[1] != ')') {
out = string(toUpper(out[0])) + out[1:]
}
g.mvaddstr(0, 0, out)
g.clrtoeol()
m.scr.Std.MvAddStr(0, 0, out)
m.scr.Std.Clrtoeol()
m.Mpos = m.newpos
m.newpos = 0
m.buf.Reset()
g.refresh()
m.scr.Refresh()
return ^Escape
}
// doadd performs an add onto the message buffer (io.c doadd).
func (g *RogueGame) doadd(format string, a ...any) {
m := &g.Msgs
// promptMore shows the --More-- prompt and waits for the reader to
// acknowledge; Escape means the player bailed out (the Mpos block of
// io.c endmsg).
func (m *MessageLine) promptMore() int {
m.look(false)
m.scr.Std.MvAddStr(0, m.Mpos, "--More--")
m.scr.Refresh()
if !m.MsgEsc {
m.waitForSpace()
return ^Escape
}
for {
ch := m.readChar()
if ch == ' ' {
return ^Escape
}
if ch == Escape {
m.buf.Reset()
m.Mpos = 0
m.newpos = 0
return Escape
}
}
}
// attach wires the message line to its display and input; NewGame and
// Restore call it once the screen and game exist.
func (m *MessageLine) attach(scr *Screen, look func(bool), readChar func() byte) {
m.scr = scr
m.look = look
m.readChar = readChar
}
// waitForSpace absorbs input until the player types a space: the
// --More-- acknowledgement (io.c wait_for).
func (m *MessageLine) waitForSpace() {
for {
if m.readChar() == ' ' {
return
}
}
}
// doaddf performs an add onto the message buffer (io.c doadd).
func (m *MessageLine) doaddf(format string, a ...any) {
s := fmt.Sprintf(format, a...)
if len(s)+m.newpos >= maxMsg {
g.endmsg()
m.End()
}
m.buf.WriteString(s)
m.newpos = m.buf.Len()
}
// msg, addmsgf, and endmsg are the game-side shorthands for the message
// line; the machinery lives on MessageLine.
func (g *RogueGame) msg(format string, a ...any) int {
return g.Msgs.Msg(format, a...)
}
func (g *RogueGame) addmsgf(format string, a ...any) {
g.Msgs.Addf(format, a...)
}
func (g *RogueGame) endmsg() {
g.Msgs.End()
}
// stepOk returns true if it is ok to step on ch (io.c step_ok).
func stepOk(ch byte) bool {
switch ch {
@@ -110,13 +166,39 @@ func stepOk(ch byte) bool {
// readchar reads and returns a character, checking for gross input errors
// (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 {
ch := g.scr.term.ReadChar()
if ch == 3 { // ^C
g.quit(0)
return 27
for {
ch, ok := g.scr.term.ReadChar()
if !ok {
g.serviceAutoSaveRequest()
continue
}
if ch == 3 { // ^C
g.quit(0)
return 27
}
return ch
}
return ch
}
// statusCache is the set of static shadow variables in io.c status() that
@@ -133,8 +215,6 @@ type statusCache struct {
init bool
}
var hungerStateName = [...]string{"", "Hungry", "Weak", "Faint"}
// status displays the important stats line, keeping the cursor where it was
// (io.c status).
func (g *RogueGame) status() {
@@ -146,17 +226,19 @@ func (g *RogueGame) status() {
if p.CurArmor != nil {
temp = p.CurArmor.ArmorClass
}
if s.init && s.hp == p.Stats.HP && s.exp == p.Stats.Exp &&
s.pur == p.Purse && s.arm == temp && s.str == p.Stats.Str &&
s.lvl == g.Depth && s.hungry == p.HungryState && !g.StatMsg {
if g.statusUnchanged(temp) {
return
}
s.init = true
s.arm = temp
oy, ox := g.scr.Std.GetYX()
if s.hp != p.Stats.MaxHP {
s.hp = p.Stats.MaxHP
s.hpwidth = 0
for t := p.Stats.MaxHP; t != 0; t /= 10 {
s.hpwidth++
@@ -175,7 +257,7 @@ func (g *RogueGame) status() {
"Level: %d Gold: %-5d Hp: %*d(%*d) Str: %2d(%d) Arm: %-2d Exp: %d/%d %s",
g.Depth, p.Purse, s.hpwidth, p.Stats.HP, s.hpwidth, p.Stats.MaxHP,
p.Stats.Str, p.MaxStats.Str, 10-s.arm, p.Stats.Lvl, p.Stats.Exp,
hungerStateName[p.HungryState])
g.data.hungerStateName[p.HungryState])
if g.StatMsg {
g.move(0, 0)
g.msg("%s", line)
@@ -183,10 +265,23 @@ func (g *RogueGame) status() {
g.move(StatLine, 0)
g.addstr(line)
}
g.clrtoeol()
g.move(oy, ox)
}
// statusUnchanged reports whether the status line still shows current
// values, so it need not be redrawn (the shadow-variable check of io.c
// status). temp is the effective armor class.
func (g *RogueGame) statusUnchanged(temp int) bool {
s := &g.statusCache
p := &g.Player
return s.init && s.hp == p.Stats.HP && s.exp == p.Stats.Exp &&
s.pur == p.Purse && s.arm == temp && s.str == p.Stats.Str &&
s.lvl == g.Depth && s.hungry == p.HungryState && !g.StatMsg
}
// waitFor sits around until the guy types the right key (io.c wait_for).
func (g *RogueGame) waitFor(ch byte) {
if ch == '\n' {
@@ -197,7 +292,11 @@ func (g *RogueGame) waitFor(ch byte) {
}
}
}
for g.readchar() != ch {
for {
if g.readchar() == ch {
return
}
}
}
@@ -221,11 +320,13 @@ func toUpper(c byte) byte {
if isLower(c) {
return c - 'a' + 'A'
}
return c
}
func toLower(c byte) byte {
if isUpper(c) {
return c - 'A' + 'a'
}
return c
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// Place describes a spot on the level map (rogue.h PLACE).
@@ -47,9 +48,33 @@ func (l *Level) VisibleChar(y, x int) byte {
if m := l.MonsterAt(y, x); m != nil {
return m.Disguise
}
return l.Char(y, x)
}
// ObjectAt finds the unclaimed object at (y, x) (misc.c find_obj).
func (l *Level) ObjectAt(y, x int) *Object {
for _, obj := range l.Objects {
if obj.Pos.Y == y && obj.Pos.X == x {
return obj
}
}
return nil
}
// AddObject puts an object on the level (list.c attach on lvl_obj).
func (l *Level) AddObject(obj *Object) { attachObj(&l.Objects, obj) }
// RemoveObject takes an object off the level (list.c detach on lvl_obj).
func (l *Level) RemoveObject(obj *Object) { detachObj(&l.Objects, obj) }
// AddMonster puts a monster on the level (list.c attach on mlist).
func (l *Level) AddMonster(m *Monster) { attachMon(&l.Monsters, m) }
// RemoveMonster takes a monster off the level (list.c detach on mlist).
func (l *Level) RemoveMonster(m *Monster) { detachMon(&l.Monsters, m) }
// goldCalc is the GOLDCALC macro: how much a gold pile is worth at depth.
func (g *RogueGame) goldCalc() int {
return g.rnd(50+10*g.Depth) + 2

View File

@@ -1,158 +1,255 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// misc.c — look() display maintenance, direction input, eating, level-ups,
// and small utilities. call_it arrives with the scroll/potion phase (it
// needs the get_str line editor).
// lookScan carries the state of one look() glance while it examines the
// nine squares around the hero.
type lookScan struct {
hero Coord
pch byte // map character under the hero
pfl PlaceFlags // map flags under the hero
wakeup bool
doorStop bool // door-stop checking applies (mid-run)
sy, sx, ey, ex int
sumhero, diffhero int
passcount int
}
// look takes a quick glance all around the player (misc.c look).
func (g *RogueGame) look(wakeup bool) {
p := &g.Player
hero := p.Pos
passcount := 0
rp := p.Room
if g.Oldpos != hero {
g.eraseLamp(g.Oldpos, g.Oldrp)
g.Oldpos = hero
g.Oldrp = rp
}
ey := hero.Y + 1
ex := hero.X + 1
sx := hero.X - 1
sy := hero.Y - 1
sumhero, diffhero := 0, 0
if g.DoorStop && !g.Firstmove && g.Running {
sumhero = hero.Y + hero.X
diffhero = hero.Y - hero.X
s := lookScan{
hero: hero,
wakeup: wakeup,
sy: hero.Y - 1,
sx: hero.X - 1,
ey: hero.Y + 1,
ex: hero.X + 1,
}
s.doorStop = g.DoorStop && !g.Firstmove
if s.doorStop && g.Running {
s.sumhero = hero.Y + hero.X
s.diffhero = hero.Y - hero.X
}
pp := g.Level.At(hero.Y, hero.X)
pch := pp.Ch
pfl := pp.Flags
s.pch = pp.Ch
s.pfl = pp.Flags
for y := sy; y <= ey; y++ {
if y <= 0 || y >= NumLines-1 {
continue
}
for x := sx; x <= ex; x++ {
if x < 0 || x >= NumCols {
continue
}
if !p.On(Blind) {
if y == hero.Y && x == hero.X {
continue
}
}
g.lookAround(&s)
pp := g.Level.At(y, x)
ch := pp.Ch
if ch == ' ' { // nothing need be done with a ' '
continue
}
fp := &pp.Flags
if pch != Door && ch != Door {
if (pfl & FPassage) != (*fp & FPassage) {
continue
}
}
if (fp.Has(FPassage) || ch == Door) && (pfl.Has(FPassage) || pch == Door) {
if hero.X != x && hero.Y != y &&
!stepOk(g.Level.Char(y, hero.X)) && !stepOk(g.Level.Char(hero.Y, x)) {
continue
}
}
tp := pp.Monst
if tp == nil {
ch = g.tripCh(y, x, ch)
} else if p.On(SenseMonsters) && tp.On(Invisible) {
if g.DoorStop && !g.Firstmove {
g.Running = false
}
continue
} else {
if wakeup {
g.wakeMonster(y, x)
}
if g.seeMonst(tp) {
if p.On(Hallucinating) {
ch = byte(g.rnd(26) + 'A')
} else {
ch = tp.Disguise
}
}
}
if p.On(Blind) && (y != hero.Y || x != hero.X) {
continue
}
g.move(y, x)
if p.Room.Flags.Has(Dark) && !g.Options.SeeFloor && ch == Floor {
ch = ' '
}
if tp != nil || ch != g.inch() {
g.addch(ch)
}
if g.DoorStop && !g.Firstmove && g.Running {
switch g.RunCh {
case 'h':
if x == ex {
continue
}
case 'j':
if y == sy {
continue
}
case 'k':
if y == ey {
continue
}
case 'l':
if x == sx {
continue
}
case 'y':
if (y+x)-sumhero >= 1 {
continue
}
case 'u':
if (y-x)-diffhero >= 1 {
continue
}
case 'n':
if (y+x)-sumhero <= -1 {
continue
}
case 'b':
if (y-x)-diffhero <= -1 {
continue
}
}
switch ch {
case Door:
if x == hero.X || y == hero.Y {
g.Running = false
}
case Passage:
if x == hero.X || y == hero.Y {
passcount++
}
case Floor, '|', '-', ' ':
default:
g.Running = false
}
}
}
}
if g.DoorStop && !g.Firstmove && passcount > 1 {
if s.doorStop && s.passcount > 1 {
g.Running = false
}
if !g.Running || !g.Options.Jump {
g.mvaddch(hero.Y, hero.X, PlayerCh)
}
}
// lookAround runs the nine-square scan of look().
func (g *RogueGame) lookAround(s *lookScan) {
for y := s.sy; y <= s.ey; y++ {
if y <= 0 || y >= NumLines-1 {
continue
}
for x := s.sx; x <= s.ex; x++ {
if x < 0 || x >= NumCols {
continue
}
g.lookCell(s, y, x)
}
}
}
// lookCell examines one square around the hero: visibility rules, trip
// and monster rendering, drawing, and run-stop checks (the loop body of
// misc.c look).
func (g *RogueGame) lookCell(s *lookScan, y, x int) {
pp := g.Level.At(y, x)
if g.lookSkips(s, pp, y, x) {
return
}
tp := pp.Monst
ch, skip := g.lookCellChar(s, tp, y, x, pp.Ch)
if skip {
return
}
if !g.lookShow(s, tp, ch, y, x) {
return
}
if s.doorStop && g.Running {
g.lookRunCheck(s, ch, y, x)
}
}
// lookSkips reports whether look ignores this square entirely: the
// hero's own square when sighted, blank rock, passage squares of
// another network, and diagonals the hero could not step to (the guard
// chain of the misc.c look loop).
func (g *RogueGame) lookSkips(s *lookScan, pp *Place, y, x int) bool {
if !g.Player.On(Blind) && y == s.hero.Y && x == s.hero.X {
return true
}
if pp.Ch == ' ' { // nothing need be done with a ' '
return true
}
return lookForeignPassage(s, pp.Flags, pp.Ch) ||
g.lookDiagonalBlocked(s, pp.Flags, pp.Ch, y, x)
}
// lookForeignPassage hides passage squares belonging to a different
// passage network than the hero's (misc.c look).
func lookForeignPassage(s *lookScan, fp PlaceFlags, ch byte) bool {
if s.pch != Door && ch != Door {
return (s.pfl & FPassage) != (fp & FPassage)
}
return false
}
// lookDiagonalBlocked hides diagonal door/passage squares the hero could
// not actually step to (misc.c look).
func (g *RogueGame) lookDiagonalBlocked(
s *lookScan, fp PlaceFlags, ch byte, y, x int,
) bool {
if !fp.Has(FPassage) && ch != Door {
return false
}
if !s.pfl.Has(FPassage) && s.pch != Door {
return false
}
return s.hero.X != x && s.hero.Y != y &&
!stepOk(g.Level.Char(y, s.hero.X)) && !stepOk(g.Level.Char(s.hero.Y, x))
}
// lookShow draws the square if it changed; it reports false when a
// blind hero cannot see it at all (the draw part of the look loop).
func (g *RogueGame) lookShow(s *lookScan, tp *Monster, ch byte, y, x int) bool {
p := &g.Player
if p.On(Blind) && (y != s.hero.Y || x != s.hero.X) {
return false
}
g.move(y, x)
if p.Room.Flags.Has(Dark) && !g.Options.SeeFloor && ch == Floor {
ch = ' '
}
if tp != nil || ch != g.inch() {
g.addch(ch)
}
return true
}
// lookCellChar picks what the square shows: trip rendering for empty
// squares, waking and disguises for monsters. skip means the square is
// not drawn at all (the monster switch of the look loop).
func (g *RogueGame) lookCellChar(
s *lookScan, tp *Monster, y, x int, ch byte,
) (byte, bool) {
p := &g.Player
switch {
case tp == nil:
return g.tripCh(y, x, ch), false
case p.On(SenseMonsters) && tp.On(Invisible):
if g.DoorStop && !g.Firstmove {
g.Running = false
}
return ch, true
default:
if s.wakeup {
g.wakeMonster(y, x)
}
if g.seeMonst(tp) {
if p.On(Hallucinating) {
return g.randomMonsterLetter(), false
}
return tp.Disguise, false
}
return ch, false
}
}
// lookRunCheck decides whether what this square shows should stop a run
// (the DoorStop tail of the misc.c look loop). Squares on the running
// edge are ignored.
func (g *RogueGame) lookRunCheck(s *lookScan, ch byte, y, x int) {
if s.atRunEdge(g.RunCh, y, x) {
return
}
switch ch {
case Door:
if x == s.hero.X || y == s.hero.Y {
g.Running = false
}
case Passage:
if x == s.hero.X || y == s.hero.Y {
s.passcount++
}
case Floor, '|', '-', ' ':
default:
g.Running = false
}
}
// atRunEdge reports whether (y, x) sits on the leading edge of the run
// direction, where door-stop checking does not apply (the first RunCh
// switch of the misc.c look loop).
func (s *lookScan) atRunEdge(runCh byte, y, x int) bool {
switch runCh {
case 'h':
return x == s.ex
case 'j':
return y == s.sy
case 'k':
return y == s.ey
case 'l':
return x == s.sx
case 'y':
return (y+x)-s.sumhero >= 1
case 'u':
return (y-x)-s.diffhero >= 1
case 'n':
return (y+x)-s.sumhero <= -1
case 'b':
return (y-x)-s.diffhero <= -1
}
return false
}
// tripCh returns the character for this space, taking into account whether
// or not the player is tripping (misc.c trip_ch).
func (g *RogueGame) tripCh(y, x int, ch byte) byte {
@@ -165,26 +262,30 @@ func (g *RogueGame) tripCh(y, x int, ch byte) byte {
}
}
}
return ch
}
// eraseLamp erases the area shown by a lamp in a dark room
// (misc.c erase_lamp).
func (g *RogueGame) eraseLamp(pos Coord, rp *Room) {
if !(g.Options.SeeFloor && rp.Flags&(Gone|Dark) == Dark &&
!g.Player.On(Blind)) {
if !g.Options.SeeFloor || rp.Flags&(Gone|Dark) != Dark ||
g.Player.On(Blind) {
return
}
ey := pos.Y + 1
ex := pos.X + 1
sy := pos.Y - 1
for x := pos.X - 1; x <= ex; x++ {
for y := sy; y <= ey; y++ {
if y == g.Player.Pos.Y && x == g.Player.Pos.X {
continue
}
g.move(y, x)
if g.inch() == Floor {
g.addch(' ')
}
@@ -198,53 +299,53 @@ func (g *RogueGame) showFloor() bool {
if g.Player.Room.Flags&(Gone|Dark) == Dark && !g.Player.On(Blind) {
return g.Options.SeeFloor
}
return true
}
// findObj finds the unclaimed object at (y, x) (misc.c find_obj).
func (g *RogueGame) findObj(y, x int) *Object {
for _, obj := range g.Level.Objects {
if obj.Pos.Y == y && obj.Pos.X == x {
return obj
}
}
return nil
return true
}
// eat lets her try to eat something (misc.c eat).
func (g *RogueGame) eat() {
obj := g.getItem("eat", KindFood)
if obj == nil {
obj, ok := g.promptPackItem("eat", KindFood)
if !ok {
return
}
if obj.Kind != KindFood {
if !g.Options.Terse {
g.msg("ugh, you would get ill if you ate that")
} else {
g.msg("that's Inedible!")
}
return
}
p := &g.Player
if p.FoodLeft < 0 {
p.FoodLeft = 0
}
if p.FoodLeft += HungerTime - 200 + g.rnd(400); p.FoodLeft > StomachSize {
p.FoodLeft = StomachSize
}
p.HungryState = 0
if obj == p.CurWeapon {
p.CurWeapon = nil
}
if obj.Which == 1 {
switch {
case obj.Which == 1:
g.msg("my, that was a yummy %s", g.Fruit)
} else if g.rnd(100) > 70 {
case g.rnd(100) > 70:
p.Stats.Exp++
g.msg("%s, this food tastes awful", g.chooseStr("bummer", "yuk"))
g.checkLevel()
} else {
default:
g.msg("%s, that tasted good", g.chooseStr("oh, wow", "yum"))
}
g.leavePack(obj, false, false)
}
@@ -252,38 +353,46 @@ func (g *RogueGame) eat() {
// check_level).
func (g *RogueGame) checkLevel() {
p := &g.Player
var i int
for i = 0; eLevels[i] != 0; i++ {
if eLevels[i] > p.Stats.Exp {
for i = 0; g.data.eLevels[i] != 0; i++ {
if g.data.eLevels[i] > p.Stats.Exp {
break
}
}
i++
olevel := p.Stats.Lvl
p.Stats.Lvl = i
if i > olevel {
add := g.roll(i-olevel, 10)
p.Stats.MaxHP += add
p.Stats.HP += add
g.msg("welcome to level %d", i)
}
}
// chgStr modifies the player's strength, keeping track of the highest it
// has been (misc.c chg_str).
func (g *RogueGame) chgStr(amt int) {
// changeStrength modifies the player's strength, keeping track of the
// highest it has been (misc.c chg_str).
func (g *RogueGame) changeStrength(amt int) {
if amt == 0 {
return
}
p := &g.Player
addStr(&p.Stats.Str, amt)
comp := p.Stats.Str
if p.IsRing(Left, RingAddStrength) {
addStr(&comp, -p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&comp, -p.CurRing[Right].Bonus)
}
if comp > p.MaxStats.Str {
p.MaxStats.Str = comp
}
@@ -303,24 +412,29 @@ func (g *RogueGame) addHaste(potion bool) bool {
p := &g.Player
if p.On(Hasted) {
g.NoCommand += g.rnd(8)
p.Flags.Clear(Awake | Hasted)
g.Extinguish(DNohaste)
g.msg("you faint from exhaustion")
return false
}
p.Flags.Set(Hasted)
if potion {
g.Fuse(DNohaste, 0, g.rnd(4)+4, After)
}
return true
}
// aggravate aggravates all the monsters on this level (misc.c aggravate).
func (g *RogueGame) aggravate() {
// runto() can splice the monster list while we walk it, so iterate a copy.
// runTo() can splice the monster list while we walk it, so iterate a copy.
monsters := append([]*Monster(nil), g.Level.Monsters...)
for _, mp := range monsters {
g.runto(mp.Pos)
g.runTo(mp.Pos)
}
}
@@ -330,10 +444,12 @@ func vowelstr(str string) string {
if str == "" {
return ""
}
switch str[0] {
case 'a', 'A', 'e', 'E', 'i', 'I', 'o', 'O', 'u', 'U':
return "n"
}
return ""
}
@@ -343,21 +459,25 @@ func (g *RogueGame) isCurrent(obj *Object) bool {
if obj == nil {
return false
}
p := &g.Player
if obj == p.CurArmor || obj == p.CurWeapon ||
obj == p.CurRing[Left] || obj == p.CurRing[Right] {
if !g.Options.Terse {
g.addmsg("That's already ")
g.addmsgf("That's already ")
}
g.msg("in use")
return true
}
return false
}
// getDir sets up the direction coordinate for use in various "prefix"
// commands (misc.c get_dir).
func (g *RogueGame) getDir() bool {
// promptDirection sets up the direction coordinate for use in various
// "prefix" commands (misc.c get_dir).
func (g *RogueGame) promptDirection() bool {
if g.Again && g.LastDir != 0 {
g.Delta = g.lastDelt
g.DirCh = g.LastDir
@@ -367,53 +487,76 @@ func (g *RogueGame) getDir() bool {
prompt = "which direction? "
g.msg("%s", prompt)
}
for {
gotit := true
switch g.DirCh = g.readchar(); g.DirCh {
case 'h', 'H':
g.Delta = Coord{X: -1, Y: 0}
case 'j', 'J':
g.Delta = Coord{X: 0, Y: 1}
case 'k', 'K':
g.Delta = Coord{X: 0, Y: -1}
case 'l', 'L':
g.Delta = Coord{X: 1, Y: 0}
case 'y', 'Y':
g.Delta = Coord{X: -1, Y: -1}
case 'u', 'U':
g.Delta = Coord{X: 1, Y: -1}
case 'b', 'B':
g.Delta = Coord{X: -1, Y: 1}
case 'n', 'N':
g.Delta = Coord{X: 1, Y: 1}
case Escape:
g.DirCh = g.readchar()
if g.DirCh == Escape {
g.LastDir = 0
g.resetLast()
return false
default:
g.Msgs.Mpos = 0
g.msg("%s", prompt)
gotit = false
}
if gotit {
if d, ok := deltaFor(g.DirCh); ok {
g.Delta = d
break
}
g.Msgs.Mpos = 0
g.msg("%s", prompt)
}
g.DirCh = toLower(g.DirCh)
g.LastDir = g.DirCh
g.lastDelt = g.Delta
}
if g.Player.On(Confused) && g.rnd(5) == 0 {
for {
g.Delta.Y = g.rnd(3) - 1
g.Delta.X = g.rnd(3) - 1
if g.Delta.Y != 0 || g.Delta.X != 0 {
break
}
g.confuseDirection()
}
g.Msgs.Mpos = 0
return true
}
// confuseDirection randomizes the chosen direction for a confused hero
// (the ISHUH tail of misc.c get_dir).
func (g *RogueGame) confuseDirection() {
for {
g.Delta.Y = g.rnd(3) - 1
g.Delta.X = g.rnd(3) - 1
if g.Delta.Y != 0 || g.Delta.X != 0 {
return
}
}
g.Msgs.Mpos = 0
return true
}
// deltaFor maps a direction key to its movement delta; ok is false for
// keys that are not directions (the switch of misc.c get_dir).
func deltaFor(ch byte) (Coord, bool) {
switch ch {
case 'h', 'H':
return Coord{X: -1, Y: 0}, true
case 'j', 'J':
return Coord{X: 0, Y: 1}, true
case 'k', 'K':
return Coord{X: 0, Y: -1}, true
case 'l', 'L':
return Coord{X: 1, Y: 0}, true
case 'y', 'Y':
return Coord{X: -1, Y: -1}, true
case 'u', 'U':
return Coord{X: 1, Y: -1}, true
case 'b', 'B':
return Coord{X: -1, Y: 1}, true
case 'n', 'N':
return Coord{X: 1, Y: 1}, true
}
return Coord{}, false
}
// callIt calls an object something after use (misc.c call_it).
@@ -422,6 +565,7 @@ func (g *RogueGame) callIt(info *ObjInfo) {
info.Guess = ""
} else if info.Guess == "" {
g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? "))
buf := ""
if g.getStr(&buf, g.scr.Std) == Norm {
if buf != "" {
@@ -431,21 +575,17 @@ func (g *RogueGame) callIt(info *ObjInfo) {
}
}
// thingList is misc.c rnd_thing()'s static table.
var thingList = []byte{
Potion, Scroll, Ring, Stick, Food, Weapon, Armor, Stairs, Gold, Amulet,
}
// rndThing picks a random thing appropriate for this level (misc.c
// rnd_thing).
func (g *RogueGame) rndThing() byte {
var i int
if g.Depth >= AmuletLevel {
i = g.rnd(len(thingList))
i = g.rnd(len(g.data.thingList))
} else {
i = g.rnd(len(thingList) - 1)
i = g.rnd(len(g.data.thingList) - 1)
}
return thingList[i]
return g.data.thingList[i]
}
// chooseStr picks the first or second string depending on whether the
@@ -454,6 +594,7 @@ func (g *RogueGame) chooseStr(ts, ns string) string {
if g.Player.On(Hallucinating) {
return ts
}
return ns
}
@@ -463,8 +604,10 @@ func unctrl(ch byte) string {
if ch < ' ' {
return "^" + string(ch+'@')
}
if ch == 0x7f {
return "^?"
}
return string(ch)
}

View File

@@ -1,34 +1,26 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// monsters.c — monster creation and saving throws.
// lvlMons and wandMons list monsters in rough order of vorpalness; zero
// entries in wandMons never wander (monsters.c).
var lvlMons = [26]byte{
'K', 'E', 'B', 'S', 'H', 'I', 'R', 'O', 'Z', 'L', 'C', 'Q', 'A',
'N', 'Y', 'F', 'T', 'W', 'P', 'X', 'U', 'M', 'V', 'G', 'J', 'D',
}
var wandMons = [26]byte{
'K', 'E', 'B', 'S', 'H', 0, 'R', 'O', 'Z', 0, 'C', 'Q', 'A',
0, 'Y', 0, 'T', 'W', 'P', 0, 'U', 'M', 'V', 'G', 'J', 0,
}
// randMonster picks a monster to show up; the lower the level, the meaner
// the monster (monsters.c randmonster).
func (g *RogueGame) randMonster(wander bool) byte {
mons := &lvlMons
mons := &g.data.lvlMons
if wander {
mons = &wandMons
mons = &g.data.wandMons
}
for {
d := g.Depth + (g.rnd(10) - 6)
if d < 0 {
d = g.rnd(5)
}
if d > 25 {
d = g.rnd(5) + 21
}
if mons[d] != 0 {
return mons[d]
}
@@ -38,17 +30,15 @@ func (g *RogueGame) randMonster(wander bool) byte {
// newMonster picks a new monster and adds it to the list (monsters.c
// new_monster).
func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) {
levAdd := g.Depth - AmuletLevel
if levAdd < 0 {
levAdd = 0
}
attachMon(&g.Level.Monsters, tp)
levAdd := max(g.Depth-AmuletLevel, 0)
g.Level.AddMonster(tp)
tp.Type = typ
tp.Disguise = typ
tp.Pos = cp
g.move(cp.Y, cp.X)
tp.OldCh = g.inch()
tp.Room = g.roomin(cp)
tp.Room = g.roomIn(cp)
g.Level.SetMonsterAt(cp.Y, cp.X, tp)
mp := &g.Monsters[tp.Type-'A']
tp.Stats.Lvl = mp.Stats.Lvl + levAdd
@@ -58,15 +48,19 @@ func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) {
tp.Stats.Dmg = mp.Stats.Dmg
tp.Stats.Str = mp.Stats.Str
tp.Stats.Exp = mp.Stats.Exp + levAdd*10 + expAdd(tp)
tp.Flags = mp.Flags
if g.Depth > 29 {
tp.Flags.Set(Hasted)
}
tp.Turn = true
tp.Pack = nil
if g.Player.IsWearing(RingAggravateMonsters) {
g.runto(cp)
g.runTo(cp)
}
if typ == 'X' {
tp.Disguise = g.rndThing()
}
@@ -81,11 +75,13 @@ func expAdd(tp *Monster) int {
} else {
mod = tp.Stats.MaxHP / 6
}
if tp.Stats.Lvl > 9 {
mod *= 20
} else if tp.Stats.Lvl > 6 {
mod *= 4
}
return mod
}
@@ -93,73 +89,115 @@ func expAdd(tp *Monster) int {
// (monsters.c wanderer).
func (g *RogueGame) wanderer() {
tp := &Monster{}
var cp Coord
for {
cp, _ = g.findFloor(nil, 0, true)
if g.roomin(cp) != g.Player.Room {
cp, _ = g.findFloor(true)
if g.roomIn(cp) != g.Player.Room {
break
}
}
g.newMonster(tp, g.randMonster(true), cp)
if g.Player.On(SenseMonsters) {
g.standout()
if !g.Player.On(Hallucinating) {
g.addch(tp.Type)
} else {
g.addch(byte(g.rnd(26) + 'A'))
g.addch(g.randomMonsterLetter())
}
g.standend()
}
g.runto(tp.Pos)
g.runTo(tp.Pos)
}
// wakeMonster is what to do when the hero steps next to a monster
// (monsters.c wake_monster).
func (g *RogueGame) wakeMonster(y, x int) *Monster {
func (g *RogueGame) wakeMonster(y, x int) {
p := &g.Player
tp := g.Level.MonsterAt(y, x)
if tp == nil {
panic("can't find monster in wake_monster")
}
ch := tp.Type
// Every time he sees a mean monster, it might start chasing him
if !tp.On(Awake) && g.rnd(3) != 0 && tp.On(Mean) && !tp.On(Held) &&
!p.IsWearing(RingStealth) && !p.On(Levitating) {
if g.meanWakes(tp) {
tp.Dest = &p.Pos
tp.Flags.Set(Awake)
}
if ch == 'M' && !p.On(Blind) && !p.On(Hallucinating) &&
!tp.On(Found) && !tp.On(Cancelled) && tp.On(Awake) {
rp := p.Room
if (rp != nil && !rp.Flags.Has(Dark)) ||
distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
tp.Flags.Set(Found)
if !g.save(VsMagic) {
if p.On(Confused) {
g.Lengthen(DUnconfuse, g.spread(HuhDuration))
} else {
g.Fuse(DUnconfuse, 0, g.spread(HuhDuration), After)
}
p.Flags.Set(Confused)
mname := g.setMname(tp)
g.addmsg("%s", mname)
if mname != "it" {
g.addmsg("'")
}
g.msg("s gaze has confused you")
}
}
if g.medusaCatches(tp) {
g.medusaGaze(tp, y, x)
}
// Let greedy ones guard gold
if tp.On(Greedy) && !tp.On(Awake) {
tp.Flags.Set(Awake)
if p.Room.GoldVal != 0 {
tp.Dest = &p.Room.Gold
} else {
tp.Dest = &p.Pos
}
}
return tp
}
// meanWakes decides whether a sleeping mean monster starts the chase
// (monsters.c wake_monster). The waking roll happens for any sleeping
// monster, as in C.
func (g *RogueGame) meanWakes(tp *Monster) bool {
p := &g.Player
return !tp.On(Awake) && g.rnd(3) != 0 && tp.On(Mean) && !tp.On(Held) &&
!p.IsWearing(RingStealth) && !p.On(Levitating)
}
// medusaCatches reports an uncovered, awake medusa the hero can see
// (monsters.c wake_monster).
func (g *RogueGame) medusaCatches(tp *Monster) bool {
p := &g.Player
return tp.Type == 'M' && !p.On(Blind) && !p.On(Hallucinating) &&
!tp.On(Found) && !tp.On(Cancelled) && tp.On(Awake)
}
// medusaGaze confuses the hero when the medusa's gaze lands (the M
// block of monsters.c wake_monster).
func (g *RogueGame) medusaGaze(tp *Monster, y, x int) {
p := &g.Player
rp := p.Room
if (rp == nil || rp.Flags.Has(Dark)) &&
distance(y, x, p.Pos.Y, p.Pos.X) >= LampDist {
return
}
tp.Flags.Set(Found)
if g.save(VsMagic) {
return
}
if p.On(Confused) {
g.Lengthen(DUnconfuse, g.spread(HuhDuration))
} else {
g.Fuse(DUnconfuse, 0, g.spread(HuhDuration), After)
}
p.Flags.Set(Confused)
mname := g.setMname(tp)
g.addmsgf("%s", mname)
if mname != "it" {
g.addmsgf("'")
}
g.msg("s gaze has confused you")
}
// givePack gives a pack to a monster if it deserves one (monsters.c
@@ -174,6 +212,7 @@ func (g *RogueGame) givePack(tp *Monster) {
// save_throw).
func (g *RogueGame) saveThrow(which int, st *Stats) bool {
need := 14 + which - st.Lvl/2
return g.roll(1, 20) >= need
}
@@ -185,9 +224,17 @@ func (g *RogueGame) save(which int) bool {
if p.IsRing(Left, RingProtection) {
which -= p.CurRing[Left].Bonus
}
if p.IsRing(Right, RingProtection) {
which -= p.CurRing[Right].Bonus
}
}
return g.saveThrow(which, &p.Stats)
}
// randomMonsterLetter picks a random monster display letter, used by the
// hallucination effects (the C rnd(26)+'A' idiom).
func (g *RogueGame) randomMonsterLetter() byte {
return byte(g.rnd(26) + 'A') //nolint:gosec // G115: 'A'..'Z' fits a byte
}

View File

@@ -1,159 +1,266 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// move.c — hero movement commands.
// doRun starts the hero running (move.c do_run).
func (g *RogueGame) doRun(ch byte) {
// startRun starts the hero running (move.c do_run).
func (g *RogueGame) startRun(ch byte) {
g.Running = true
g.After = false
g.RunCh = ch
}
// doMove checks that a move is legal and handles the consequences —
// fighting, picking up, etc. (move.c do_move).
func (g *RogueGame) doMove(dy, dx int) {
// moveHero checks that a move is legal and handles the consequences —
// fighting, picking up, etc. (move.c do_move). The C `goto over`
// re-check after a passage turn is the retry loop.
func (g *RogueGame) moveHero(dy, dx int) {
p := &g.Player
g.Firstmove = false
if g.NoMove > 0 {
g.NoMove--
g.msg("you are still stuck in the bear trap")
return
}
// Do a confused move (maybe)
var nh Coord
if p.On(Confused) && g.rnd(5) != 0 {
nh = g.rndmove(&p.Creature)
nh = g.randomStep(&p.Creature)
if nh == p.Pos {
g.After = false
g.Running = false
g.ToDeath = false
return
}
} else {
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
}
over:
// Check if he tried to move off the screen or make an illegal diagonal
// move, and stop him if he did.
hitBound := nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1
var ch byte
var fl PlaceFlags
if !hitBound {
if !g.diagOk(p.Pos, nh) {
g.After = false
g.Running = false
for {
ch, fl, stop := g.moveTarget(nh)
if stop {
return
}
if g.Running && p.Pos == nh {
g.After = false
g.Running = false
}
fl = *g.Level.FlagsAt(nh.Y, nh.X)
ch = g.Level.VisibleChar(nh.Y, nh.X)
if !fl.Has(FReal) && ch == Floor {
if !p.On(Levitating) {
ch = Trap
g.Level.SetChar(nh.Y, nh.X, Trap)
g.Level.FlagsAt(nh.Y, nh.X).Set(FReal)
}
} else if p.On(Held) && ch != 'F' {
g.msg("you are being held")
turned, ndy, ndx := g.moveResolve(nh, ch, fl, dy, dx)
if !turned {
return
}
// the C goto over: re-check the turned move
dy, dx = ndy, ndx
g.turnRefresh()
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
}
if hitBound {
ch = ' ' // fall into the wall case below
}
}
// moveResolve acts on the square the hero stepped at: a wall may turn a
// passage runner (reported with the new deltas); anything else completes
// or refuses the move (the switch of move.c do_move).
func (g *RogueGame) moveResolve(
nh Coord, ch byte, fl PlaceFlags, dy, dx int,
) (bool, int, int) {
switch ch {
case ' ', '|', '-':
if g.Options.PassGo && g.Running && p.Room.Flags.Has(Gone) &&
!p.On(Blind) {
var b1, b2 bool
switch g.RunCh {
case 'h', 'l':
b1 = p.Pos.Y != 1 && g.turnOk(p.Pos.Y-1, p.Pos.X)
b2 = p.Pos.Y != NumLines-2 && g.turnOk(p.Pos.Y+1, p.Pos.X)
if b1 != b2 {
if b1 {
g.RunCh = 'k'
dy = -1
} else {
g.RunCh = 'j'
dy = 1
}
dx = 0
g.turnref()
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
goto over
}
case 'j', 'k':
b1 = p.Pos.X != 0 && g.turnOk(p.Pos.Y, p.Pos.X-1)
b2 = p.Pos.X != NumCols-1 && g.turnOk(p.Pos.Y, p.Pos.X+1)
if b1 != b2 {
if b1 {
g.RunCh = 'h'
dx = -1
} else {
g.RunCh = 'l'
dx = 1
}
dy = 0
g.turnref()
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
goto over
}
}
if turn, ndy, ndx := g.passageTurn(dy, dx); turn {
return true, ndy, ndx
}
g.Running = false
g.After = false
default:
g.moveEnter(nh, fl, ch)
}
return false, 0, 0
}
// moveEnter completes a step onto a walkable square: doors, traps,
// passages, floor, and things (the entry arms of the move.c do_move
// switch).
func (g *RogueGame) moveEnter(nh Coord, fl PlaceFlags, ch byte) {
p := &g.Player
switch ch {
case Door:
g.Running = false
if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPassage) {
g.enterRoom(nh)
}
g.moveStuff(nh, fl)
case Trap:
tr := g.beTrapped(nh)
tr := g.springTrap(nh)
if tr == TrapDoor || tr == TrapTeleport {
return
}
g.moveStuff(nh, fl)
case Passage:
// when you're in a corridor, you don't know if you're in a maze
// room or not, and there ain't no way to find out if you're
// leaving a maze room, so it is necessary to always recalculate
// proom.
p.Room = g.roomin(p.Pos)
g.moveStuff(nh, fl)
p.Room = g.roomIn(p.Pos)
case Floor:
if !fl.Has(FReal) {
g.beTrapped(p.Pos)
g.springTrap(p.Pos)
}
g.moveStuff(nh, fl)
default:
if ch == Stairs {
g.SeenStairs = true
}
g.moveOnto(nh, fl, ch)
return
}
g.finishMove(nh, fl)
}
// offMap reports coordinates outside the walkable map (move.c do_move).
func offMap(nh Coord) bool {
return nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1
}
// moveTarget inspects the square the hero is stepping onto: bounds and
// diagonal legality, hidden traps underfoot, and being held. stop means
// the move is refused (the checks of move.c do_move).
func (g *RogueGame) moveTarget(nh Coord) (byte, PlaceFlags, bool) {
p := &g.Player
// Check if he tried to move off the screen or make an illegal
// diagonal move, and stop him if he did.
if offMap(nh) {
return ' ', 0, false // fall into the wall case
}
if !g.diagOk(p.Pos, nh) {
g.After = false
g.Running = false
if isUpper(ch) || g.Level.MonsterAt(nh.Y, nh.X) != nil {
g.fight(nh, p.CurWeapon, false)
} else {
if ch != Stairs {
g.Take = ch
}
g.moveStuff(nh, fl)
return 0, 0, true
}
if g.Running && p.Pos == nh {
g.After = false
g.Running = false
}
fl := *g.Level.FlagsAt(nh.Y, nh.X)
ch := g.Level.VisibleChar(nh.Y, nh.X)
if !fl.Has(FReal) && ch == Floor {
if !p.On(Levitating) {
ch = Trap
g.Level.SetChar(nh.Y, nh.X, Trap)
g.Level.FlagsAt(nh.Y, nh.X).Set(FReal)
}
} else if p.On(Held) && ch != 'F' {
g.msg("you are being held")
return 0, 0, true
}
return ch, fl, false
}
// moveOnto handles stepping at a monster or onto an item (the default
// arm of the move.c do_move switch).
func (g *RogueGame) moveOnto(nh Coord, fl PlaceFlags, ch byte) {
p := &g.Player
if ch == Stairs {
g.SeenStairs = true
}
g.Running = false
if isUpper(ch) || g.Level.MonsterAt(nh.Y, nh.X) != nil {
g.fight(nh, p.CurWeapon, false)
} else {
if ch != Stairs {
g.Take = ch
}
g.finishMove(nh, fl)
}
}
// moveStuff is the move_stuff label in do_move: complete the step.
func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) {
// passageTurn checks whether a runner in a gone-room passage should turn
// the corner instead of stopping at a wall (the PASSGO block of move.c
// do_move). It reports whether to turn and the new deltas, updating RunCh.
func (g *RogueGame) passageTurn(dy, dx int) (bool, int, int) {
p := &g.Player
if !g.Options.PassGo || !g.Running || !p.Room.Flags.Has(Gone) ||
p.On(Blind) {
return false, dy, dx
}
switch g.RunCh {
case 'h', 'l':
if turn, ndy := g.passageTurnVertical(); turn {
return true, ndy, 0
}
case 'j', 'k':
if turn, ndx := g.passageTurnHorizontal(); turn {
return true, 0, ndx
}
}
return false, dy, dx
}
// passageTurnVertical decides whether a horizontal runner turns up or
// down at a corner (move.c do_move).
func (g *RogueGame) passageTurnVertical() (bool, int) {
p := &g.Player
b1 := p.Pos.Y != 1 && g.turnOk(p.Pos.Y-1, p.Pos.X)
b2 := p.Pos.Y != NumLines-2 && g.turnOk(p.Pos.Y+1, p.Pos.X)
if b1 == b2 {
return false, 0
}
if b1 {
g.RunCh = 'k'
return true, -1
}
g.RunCh = 'j'
return true, 1
}
// passageTurnHorizontal decides whether a vertical runner turns left or
// right at a corner (move.c do_move).
func (g *RogueGame) passageTurnHorizontal() (bool, int) {
p := &g.Player
b1 := p.Pos.X != 0 && g.turnOk(p.Pos.Y, p.Pos.X-1)
b2 := p.Pos.X != NumCols-1 && g.turnOk(p.Pos.Y, p.Pos.X+1)
if b1 == b2 {
return false, 0
}
if b1 {
g.RunCh = 'h'
return true, -1
}
g.RunCh = 'l'
return true, 1
}
// finishMove is the move_stuff label in do_move: complete the step.
func (g *RogueGame) finishMove(nh Coord, fl PlaceFlags) {
p := &g.Player
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt())
if fl.Has(FPassage) && g.Level.Char(g.Oldpos.Y, g.Oldpos.X) == Door {
g.leaveRoom(nh)
}
p.Pos = nh
}
@@ -161,17 +268,21 @@ func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) {
// (move.c turn_ok).
func (g *RogueGame) turnOk(y, x int) bool {
pp := g.Level.At(y, x)
return pp.Ch == Door || pp.Flags&(FReal|FPassage) == (FReal|FPassage)
}
// turnref decides whether to refresh at a passage turning (move.c turnref).
func (g *RogueGame) turnref() {
// turnRefresh decides whether to refresh at a passage turning (move.c
// turnref).
func (g *RogueGame) turnRefresh() {
p := &g.Player
pp := g.Level.At(p.Pos.Y, p.Pos.X)
if !pp.Flags.Has(FSeen) {
if g.Options.Jump {
g.refresh()
}
pp.Flags.Set(FSeen)
}
}
@@ -182,6 +293,7 @@ func (g *RogueGame) doorOpen(rp *Room) {
if rp.Flags.Has(Gone) {
return
}
for y := rp.Pos.Y; y < rp.Pos.Y+rp.Max.Y; y++ {
for x := rp.Pos.X; x < rp.Pos.X+rp.Max.X; x++ {
if isUpper(g.Level.VisibleChar(y, x)) {
@@ -191,102 +303,143 @@ func (g *RogueGame) doorOpen(rp *Room) {
}
}
// beTrapped makes him pay for stepping on a trap (move.c be_trapped).
func (g *RogueGame) beTrapped(tc Coord) TrapKind {
// springTrap makes him pay for stepping on a trap (move.c be_trapped).
func (g *RogueGame) springTrap(tc Coord) TrapKind {
p := &g.Player
if p.On(Levitating) {
return TrapRust // anything that's not a door or teleport
}
g.Running = false
g.Count = 0
pp := g.Level.At(tc.Y, tc.X)
pp.Ch = Trap
tr := TrapKind(pp.Flags & FTrapMask)
pp.Flags.Set(FSeen)
switch tr {
case TrapDoor:
g.Depth++
g.NewLevel()
g.msg("you fell into a trap!")
case TrapBear:
g.NoMove += g.spread(3) // BEARTIME
g.msg("you are caught in a bear trap")
case TrapMystery:
switch g.rnd(11) {
case 0:
g.msg("you are suddenly in a parallel dimension")
case 1:
g.msg("the light in here suddenly seems %s", rainbow[g.rnd(len(rainbow))])
case 2:
g.msg("you feel a sting in the side of your neck")
case 3:
g.msg("multi-colored lines swirl around you, then fade")
case 4:
g.msg("a %s light flashes in your eyes", rainbow[g.rnd(len(rainbow))])
case 5:
g.msg("a spike shoots past your ear!")
case 6:
g.msg("%s sparks dance across your armor", rainbow[g.rnd(len(rainbow))])
case 7:
g.msg("you suddenly feel very thirsty")
case 8:
g.msg("you feel time speed up suddenly")
case 9:
g.msg("time now seems to be going slower")
case 10:
g.msg("you pack turns %s!", rainbow[g.rnd(len(rainbow))])
}
case TrapSleep:
g.NoCommand += g.spread(5) // SLEEPTIME
p.Flags.Clear(Awake)
g.msg("a strange white mist envelops you and you fall asleep")
case TrapArrow:
if g.swing(p.Stats.Lvl-1, p.Stats.ArmorClass, 1) {
p.Stats.HP -= g.roll(1, 6)
if p.Stats.HP <= 0 {
g.msg("an arrow killed you")
g.death('a')
} else {
g.msg("oh no! An arrow shot you")
}
} else {
arrow := newObject()
g.initWeapon(arrow, WeaponArrow)
arrow.Count = 1
arrow.Pos = p.Pos
g.fall(arrow, false)
g.msg("an arrow shoots past you")
}
case TrapTeleport:
// since the hero's leaving, look() won't put a TRAP down for us,
// so we have to do it ourself
g.teleport()
g.mvaddch(tc.Y, tc.X, Trap)
case TrapDart:
if !g.swing(p.Stats.Lvl+1, p.Stats.ArmorClass, 1) {
g.msg("a small dart whizzes by your ear and vanishes")
} else {
p.Stats.HP -= g.roll(1, 4)
if p.Stats.HP <= 0 {
g.msg("a poisoned dart killed you")
g.death('d')
}
if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) {
g.chgStr(-1)
}
g.msg("a small dart just hit you in the shoulder")
}
case TrapRust:
g.msg("a gush of water hits you on the head")
g.rustArmor(p.CurArmor)
if h := g.data.trapHandlers[tr]; h != nil {
h(g, tc)
}
g.flushType()
return tr
}
// rndmove moves in a random direction if the monster/person is confused
// (move.c rndmove).
func (g *RogueGame) rndmove(who *Creature) Coord {
// The per-trap effect handlers, dispatched through
// gameData.trapHandlers. Each is one case of the C be_trapped switch.
func (g *RogueGame) trapFall(Coord) {
g.Depth++
g.NewLevel()
g.msg("you fell into a trap!")
}
func (g *RogueGame) trapBear(Coord) {
g.NoMove += g.spread(3) // BEARTIME
g.msg("you are caught in a bear trap")
}
func (g *RogueGame) trapMystery(Coord) {
which := g.rnd(11)
switch which {
case 0:
g.msg("you are suddenly in a parallel dimension")
case 1:
g.msg("the light in here suddenly seems %s",
g.data.rainbow[g.rnd(len(g.data.rainbow))])
case 2:
g.msg("you feel a sting in the side of your neck")
case 3:
g.msg("multi-colored lines swirl around you, then fade")
case 4:
g.msg("a %s light flashes in your eyes", g.data.rainbow[g.rnd(len(g.data.rainbow))])
case 5:
g.msg("a spike shoots past your ear!")
default:
g.trapMysteryMore(which)
}
}
// trapMysteryMore holds the back half of the mystery-trap messages.
func (g *RogueGame) trapMysteryMore(which int) {
switch which {
case 6:
g.msg("%s sparks dance across your armor", g.data.rainbow[g.rnd(len(g.data.rainbow))])
case 7:
g.msg("you suddenly feel very thirsty")
case 8:
g.msg("you feel time speed up suddenly")
case 9:
g.msg("time now seems to be going slower")
case 10:
g.msg("you pack turns %s!", g.data.rainbow[g.rnd(len(g.data.rainbow))])
}
}
func (g *RogueGame) trapSleep(Coord) {
g.NoCommand += g.spread(5) // SLEEPTIME
g.Player.Flags.Clear(Awake)
g.msg("a strange white mist envelops you and you fall asleep")
}
func (g *RogueGame) trapArrow(Coord) {
p := &g.Player
if g.swing(p.Stats.Lvl-1, p.Stats.ArmorClass, 1) {
p.Stats.HP -= g.roll(1, 6)
if p.Stats.HP <= 0 {
g.msg("an arrow killed you")
g.death('a')
} else {
g.msg("oh no! An arrow shot you")
}
} else {
arrow := newObject()
g.initWeapon(arrow, WeaponArrow)
arrow.Count = 1
arrow.Pos = p.Pos
g.fall(arrow, false)
g.msg("an arrow shoots past you")
}
}
func (g *RogueGame) trapTeleport(tc Coord) {
// since the hero's leaving, look() won't put a TRAP down for us,
// so we have to do it ourself
g.teleport()
g.mvaddch(tc.Y, tc.X, Trap)
}
func (g *RogueGame) trapDart(Coord) {
p := &g.Player
if !g.swing(p.Stats.Lvl+1, p.Stats.ArmorClass, 1) {
g.msg("a small dart whizzes by your ear and vanishes")
return
}
p.Stats.HP -= g.roll(1, 4)
if p.Stats.HP <= 0 {
g.msg("a poisoned dart killed you")
g.death('d')
}
if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) {
g.changeStrength(-1)
}
g.msg("a small dart just hit you in the shoulder")
}
func (g *RogueGame) trapRust(Coord) {
g.msg("a gush of water hits you on the head")
g.rustArmor(g.Player.CurArmor)
}
// randomStep moves in a random direction if the monster/person is
// confused (move.c rndmove).
func (g *RogueGame) randomStep(who *Creature) Coord {
ret := Coord{
Y: who.Pos.Y + g.rnd(3) - 1,
X: who.Pos.X + g.rnd(3) - 1,
@@ -296,25 +449,32 @@ func (g *RogueGame) rndmove(who *Creature) Coord {
if ret == who.Pos {
return ret
}
if !g.diagOk(who.Pos, ret) {
return who.Pos
}
ch := g.Level.VisibleChar(ret.Y, ret.X)
if !stepOk(ch) {
return who.Pos
}
if ch == Scroll {
var found *Object
for _, obj := range g.Level.Objects {
if ret.Y == obj.Pos.Y && ret.X == obj.Pos.X {
found = obj
break
}
}
if found != nil && found.ScrollKind() == ScrollScareMonster {
return who.Pos
}
}
return ret
}
@@ -332,6 +492,7 @@ func (g *RogueGame) rustArmor(arm *Object) {
}
} else {
arm.ArmorClass++
if !g.Options.Terse {
g.msg("your armor appears to be weaker now. Oh my!")
} else {

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// new_level.c — dig and draw a new level.
@@ -13,6 +14,7 @@ const (
func (g *RogueGame) NewLevel() {
p := &g.Player
p.Flags.Clear(Held) // unhold when you go down just in case
if g.Depth > g.MaxDepth {
g.MaxDepth = g.Depth
}
@@ -20,61 +22,65 @@ func (g *RogueGame) NewLevel() {
for i := range g.Level.Places {
g.Level.Places[i] = Place{Ch: ' ', Flags: FReal}
}
g.clear()
// Free up the monsters on the last level; the objects and their packs
// go with them (the garbage collector is our free_list).
g.Level.Monsters = nil
g.Level.Objects = nil
g.doRooms() // Draw rooms
g.doPassages() // Draw passages
g.digRooms() // Draw rooms
g.digPassages() // Draw passages
p.NoFood++
g.putThings() // Place objects (if any)
// Place the traps
if g.rnd(10) < g.Depth {
g.Level.TrapCount = g.rnd(g.Depth/4) + 1
if g.Level.TrapCount > MaxTraps {
g.Level.TrapCount = MaxTraps
}
g.Level.TrapCount = min(g.rnd(g.Depth/4)+1, MaxTraps)
for i := g.Level.TrapCount; i > 0; i-- {
// not only wouldn't it be NICE to have traps in mazes (not
// that we care about being nice), since the trap number is
// stored where the passage number is, we can't actually do it.
var stairs Coord
for {
stairs, _ = g.findFloor(nil, 0, false)
stairs, _ = g.findFloor(false)
if g.Level.Char(stairs.Y, stairs.X) == Floor {
break
}
}
sp := g.Level.FlagsAt(stairs.Y, stairs.X)
sp.Clear(FReal)
*sp |= PlaceFlags(g.rnd(NumTrapTypes))
*sp |= PlaceFlags(g.rnd(NumTrapTypes)) //nolint:gosec // G115: 0..7 fits
}
}
// Place the staircase down.
stairs, _ := g.findFloor(nil, 0, false)
stairs, _ := g.findFloor(false)
g.Level.Stairs = stairs
g.Level.SetChar(stairs.Y, stairs.X, Stairs)
g.SeenStairs = false
for _, tp := range g.Level.Monsters {
tp.Room = g.roomin(tp.Pos)
tp.Room = g.roomIn(tp.Pos)
}
hero, _ := g.findFloor(nil, 0, true)
hero, _ := g.findFloor(true)
p.Pos = hero
g.enterRoom(hero)
g.mvaddch(hero.Y, hero.X, PlayerCh)
if p.On(SenseMonsters) {
g.turnSee(false)
}
if p.On(Hallucinating) {
g.visuals(0)
}
}
// rndRoom picks a room that is really there (new_level.c rnd_room).
func (g *RogueGame) rndRoom() int {
// randomRoom picks a room that is really there (new_level.c rnd_room).
func (g *RogueGame) randomRoom() int {
for {
rm := g.rnd(MaxRooms)
if !g.Level.Rooms[rm].Flags.Has(Gone) {
@@ -93,16 +99,16 @@ func (g *RogueGame) putThings() {
}
// check for treasure rooms, and if so, put it in.
if g.rnd(treasRoomChance) == 0 {
g.treasRoom()
g.treasureRoom()
}
// Do MAXOBJ attempts to put things on a level
for i := 0; i < MaxObj; i++ {
for range MaxObj {
if g.rnd(100) < 36 {
// Pick a new object and link it in the list
obj := g.newThing()
attachObj(&g.Level.Objects, obj)
g.Level.AddObject(obj)
// Put it somewhere
obj.Pos, _ = g.findFloor(nil, 0, false)
obj.Pos, _ = g.findFloor(false)
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
}
}
@@ -110,42 +116,40 @@ func (g *RogueGame) putThings() {
// yet, put it somewhere on the ground
if g.Depth >= AmuletLevel && !g.HasAmulet {
obj := newObject()
attachObj(&g.Level.Objects, obj)
g.Level.AddObject(obj)
obj.Damage = dice("0x0")
obj.HurlDmg = dice("0x0")
obj.ArmorClass = 11
obj.Kind = KindAmulet
// Put it somewhere
obj.Pos, _ = g.findFloor(nil, 0, false)
obj.Pos, _ = g.findFloor(false)
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Amulet)
}
}
// treasRoom adds a treasure room (new_level.c treas_room).
func (g *RogueGame) treasRoom() {
rp := &g.Level.Rooms[g.rndRoom()]
spots := (rp.Max.Y-2)*(rp.Max.X-2) - minTreas
if spots > maxTreas-minTreas {
spots = maxTreas - minTreas
}
// treasureRoom adds a treasure room (new_level.c treas_room).
func (g *RogueGame) treasureRoom() {
rp := &g.Level.Rooms[g.randomRoom()]
spots := min((rp.Max.Y-2)*(rp.Max.X-2)-minTreas, maxTreas-minTreas)
numMonst := g.rnd(spots) + minTreas
for nm := numMonst; nm > 0; nm-- {
mp, _ := g.findFloorIn(rp, 2*maxTries, false)
tp := g.newThing()
tp.Pos = mp
attachObj(&g.Level.Objects, tp)
g.Level.AddObject(tp)
g.Level.SetChar(mp.Y, mp.X, tp.Kind.Glyph())
}
// fill up room with monsters from the next level down
nm := g.rnd(spots) + minTreas
if nm < numMonst+2 {
nm = numMonst + 2
}
nm := max(g.rnd(spots)+minTreas, numMonst+2)
spots = (rp.Max.Y - 2) * (rp.Max.X - 2)
if nm > spots {
nm = spots
}
g.Depth++
for ; nm > 0; nm-- {
if mp, ok := g.findFloorIn(rp, maxTries, true); ok {
@@ -155,5 +159,6 @@ func (g *RogueGame) treasRoom() {
g.givePack(tp)
}
}
g.Depth--
}

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
@@ -7,103 +8,152 @@ import (
func genLevel(t *testing.T, seed int32) *RogueGame {
t.Helper()
g := NewGame(Config{Seed: seed})
g := New(Params{Seed: seed})
g.NewLevel()
return g
}
// renderMap draws the raw level map (not the screen) as text.
func renderMap(g *RogueGame) string {
var sb strings.Builder
for y := 0; y < NumLines; y++ {
for x := 0; x < NumCols; x++ {
for y := range NumLines {
for x := range NumCols {
ch := g.Level.Char(y, x)
if m := g.Level.MonsterAt(y, x); m != nil {
ch = m.Type
}
sb.WriteByte(ch)
}
sb.WriteByte('\n')
}
return sb.String()
}
func TestNewLevelInvariants(t *testing.T) {
t.Parallel()
for _, seed := range []int32{1, 12345, 2026, 99999} {
g := genLevel(t, seed)
// The staircase is somewhere real.
st := g.Level.Stairs
if g.Level.Char(st.Y, st.X) != Stairs {
t.Errorf("seed %d: no staircase at recorded stairs position", seed)
checkHeroPlacement(t, g, seed)
checkRoomsDrawn(t, g, seed)
checkMonstersPlaced(t, g, seed)
checkObjectsPlaced(t, g, seed)
checkStartingKit(t, g, seed)
}
}
// checkHeroPlacement verifies the staircase and hero landed on valid,
// unoccupied cells.
func checkHeroPlacement(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
// The staircase is somewhere real.
st := g.Level.Stairs
if g.Level.Char(st.Y, st.X) != Stairs {
t.Errorf("seed %d: no staircase at recorded stairs position", seed)
}
// The hero stands on a walkable, monster-free cell.
hp := g.Player.Pos
if !stepOk(g.Level.Char(hp.Y, hp.X)) {
t.Errorf("seed %d: hero on unwalkable cell %q", seed,
g.Level.Char(hp.Y, hp.X))
}
if g.Level.MonsterAt(hp.Y, hp.X) != nil {
t.Errorf("seed %d: hero standing on a monster", seed)
}
if g.Player.Room == nil {
t.Errorf("seed %d: hero not in any room", seed)
}
}
// checkRoomsDrawn verifies rooms and floor/passages appear on the map.
func checkRoomsDrawn(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
m := renderMap(g)
if !strings.Contains(m, "|") || !strings.Contains(m, "-") {
t.Errorf("seed %d: no room walls drawn", seed)
}
if !strings.Contains(m, ".") && !strings.Contains(m, "#") {
t.Errorf("seed %d: no floor or passages drawn", seed)
}
}
// checkMonstersPlaced verifies every monster is indexed on the map and
// placed in a room.
func checkMonstersPlaced(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
for _, mon := range g.Level.Monsters {
if g.Level.MonsterAt(mon.Pos.Y, mon.Pos.X) != mon {
t.Errorf("seed %d: monster %c not indexed at its position",
seed, mon.Type)
}
// The hero stands on a walkable, monster-free cell.
hp := g.Player.Pos
if !stepOk(g.Level.Char(hp.Y, hp.X)) {
t.Errorf("seed %d: hero on unwalkable cell %q", seed,
g.Level.Char(hp.Y, hp.X))
}
if g.Level.MonsterAt(hp.Y, hp.X) != nil {
t.Errorf("seed %d: hero standing on a monster", seed)
}
if g.Player.Room == nil {
t.Errorf("seed %d: hero not in any room", seed)
}
// Some rooms exist and are drawn.
m := renderMap(g)
if !strings.Contains(m, "|") || !strings.Contains(m, "-") {
t.Errorf("seed %d: no room walls drawn", seed)
}
if !strings.Contains(m, ".") && !strings.Contains(m, "#") {
t.Errorf("seed %d: no floor or passages drawn", seed)
}
// Every monster is indexed on the map and placed in a room.
for _, mon := range g.Level.Monsters {
if g.Level.MonsterAt(mon.Pos.Y, mon.Pos.X) != mon {
t.Errorf("seed %d: monster %c not indexed at its position",
seed, mon.Type)
}
if mon.Room == nil {
t.Errorf("seed %d: monster %c has no room", seed, mon.Type)
}
}
// Every level object sits on a cell displaying its type (items can
// share cells only with monsters standing on them).
for _, obj := range g.Level.Objects {
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
if ch != obj.Kind.Glyph() &&
g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil {
t.Errorf("seed %d: object %v at (%d,%d) but map shows %q",
seed, obj.Kind, obj.Pos.Y, obj.Pos.X, ch)
}
}
// The player has her starting kit: food, armor, mace, bow, arrows.
if len(g.Player.Pack) != 5 {
t.Errorf("seed %d: starting pack has %d items, want 5",
seed, len(g.Player.Pack))
}
if g.Player.CurWeapon == nil ||
g.Player.CurWeapon.WeaponKind() != WeaponMace {
t.Errorf("seed %d: not wielding the starting mace", seed)
}
if g.Player.CurArmor == nil ||
g.Player.CurArmor.ArmorKind() != ArmorRingMail {
t.Errorf("seed %d: not wearing the starting ring mail", seed)
if mon.Room == nil {
t.Errorf("seed %d: monster %c has no room", seed, mon.Type)
}
}
}
// checkObjectsPlaced verifies every level object sits on a cell
// displaying its type (items can share cells only with monsters standing
// on them).
func checkObjectsPlaced(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
for _, obj := range g.Level.Objects {
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
if ch != obj.Kind.Glyph() &&
g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil {
t.Errorf("seed %d: object %v at (%d,%d) but map shows %q",
seed, obj.Kind, obj.Pos.Y, obj.Pos.X, ch)
}
}
}
// checkStartingKit verifies the player has her starting kit: food, armor,
// mace, bow, arrows.
func checkStartingKit(t *testing.T, g *RogueGame, seed int32) {
t.Helper()
if len(g.Player.Pack) != 5 {
t.Errorf("seed %d: starting pack has %d items, want 5",
seed, len(g.Player.Pack))
}
if g.Player.CurWeapon == nil ||
g.Player.CurWeapon.WeaponKind() != WeaponMace {
t.Errorf("seed %d: not wielding the starting mace", seed)
}
if g.Player.CurArmor == nil ||
g.Player.CurArmor.ArmorKind() != ArmorRingMail {
t.Errorf("seed %d: not wearing the starting ring mail", seed)
}
}
func TestNewLevelDeterministic(t *testing.T) {
t.Parallel()
a := renderMap(genLevel(t, 12345))
b := renderMap(genLevel(t, 12345))
if a != b {
t.Error("same seed produced different levels")
}
c := renderMap(genLevel(t, 54321))
if a == c {
t.Error("different seeds produced identical levels")
@@ -113,11 +163,14 @@ func TestNewLevelDeterministic(t *testing.T) {
// TestDeeperLevels exercises generation across many depths and seeds —
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
func TestDeeperLevels(t *testing.T) {
t.Parallel()
for _, seed := range []int32{7, 42, 1000, 31337} {
g := NewGame(Config{Seed: seed})
g := New(Params{Seed: seed})
for depth := 1; depth <= 30; depth++ {
g.Depth = depth
g.NewLevel()
st := g.Level.Stairs
if g.Level.Char(st.Y, st.X) != Stairs {
t.Fatalf("seed %d depth %d: missing staircase", seed, depth)

View File

@@ -5,6 +5,7 @@ package game
// category (ObjectKind) from its display character (Glyph).
type ObjectKind int
// Item categories.
const (
KindNone ObjectKind = iota
KindPotion
@@ -25,35 +26,51 @@ const (
KindRingOrStick ObjectKind = -2
)
// kindGlyphs maps each kind to the character Rogue draws for it.
var kindGlyphs = [...]byte{
KindNone: ' ',
KindPotion: Potion,
KindScroll: Scroll,
KindFood: Food,
KindWeapon: Weapon,
KindArmor: Armor,
KindRing: Ring,
KindWand: Stick,
KindAmulet: Amulet,
KindGold: Gold,
}
// Category words shared by ObjectKind.String, the discovery list, and the
// ident table. (The bare identifiers Potion, Scroll, Ring, Gold are the
// glyph byte constants.)
const (
potionName = "potion"
scrollName = "scroll"
ringName = "ring"
goldName = "gold"
)
// Glyph returns the map/display character for this kind of object.
func (k ObjectKind) Glyph() byte {
if k < 0 || int(k) >= len(kindGlyphs) {
return ' '
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch k {
case KindPotion:
return Potion
case KindScroll:
return Scroll
case KindFood:
return Food
case KindWeapon:
return Weapon
case KindArmor:
return Armor
case KindRing:
return Ring
case KindWand:
return Stick
case KindAmulet:
return Amulet
case KindGold:
return Gold
}
return kindGlyphs[k]
return ' '
}
// String names the category the way the C type_name() did.
func (k ObjectKind) String() string {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch k {
case KindPotion:
return "potion"
return potionName
case KindScroll:
return "scroll"
return scrollName
case KindFood:
return "food"
case KindWeapon:
@@ -61,27 +78,36 @@ func (k ObjectKind) String() string {
case KindArmor:
return "suit of armor"
case KindRing:
return "ring"
case KindWand:
return "wand or staff"
case KindAmulet:
return "amulet"
case KindGold:
return "gold"
case KindRingOrStick:
return "ring, wand or staff"
return ringName
default:
return k.stringRest()
}
return "bizarre thing"
}
// objectKindForGlyph is the reverse of Glyph: what category of item does a
// map character denote. Returns KindNone for non-item characters.
func objectKindForGlyph(ch byte) ObjectKind {
for k, g := range kindGlyphs {
if g == ch && ObjectKind(k) != KindNone {
return ObjectKind(k)
}
switch ch {
case Potion:
return KindPotion
case Scroll:
return KindScroll
case Food:
return KindFood
case Weapon:
return KindWeapon
case Armor:
return KindArmor
case Ring:
return KindRing
case Stick:
return KindWand
case Amulet:
return KindAmulet
case Gold:
return KindGold
}
return KindNone
}
@@ -91,6 +117,24 @@ func (k ObjectKind) MergesInPack() bool {
return k == KindPotion || k == KindScroll || k == KindFood
}
// stringRest names the remaining kinds, including the ring-or-stick
// prompt pseudo-kind (the tail of the C type_name switch).
func (k ObjectKind) stringRest() string {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch k {
case KindWand:
return "wand or staff"
case KindAmulet:
return "amulet"
case KindGold:
return goldName
case KindRingOrStick:
return "ring, wand or staff"
}
return "bizarre thing"
}
// Object is the _o arm of the C THING union: anything that can lie on the
// floor or ride in a pack.
type Object struct {
@@ -120,6 +164,38 @@ func newObject() *Object {
return &Object{Launch: noWeapon}
}
// whichLimit reports how many entries a kind's per-kind tables hold, so
// Which is a legal index exactly while 0 <= Which < whichLimit(Kind).
// Kinds whose Which is not a table index at all — food (0 ration, 1 the
// fruit), the amulet, gold, and the KindNone an unrecognized glyph maps
// to — report 0, meaning any value is acceptable, which is what C did
// with them too.
//
// Weapons count one past NumWeaponTypes on purpose: Items.Weapons is
// sized NumWeaponTypes+1 for the WeaponFlame dragon-breath entry, and
// fireBolt really does set Which = WeaponFlame on a live Object. That
// entry has no init_dam[] row, so initWeapon and createObj bound
// themselves by NumWeaponTypes instead.
func whichLimit(kind ObjectKind) int {
//nolint:exhaustive // the remaining kinds do not index a per-kind table
switch kind {
case KindPotion:
return int(NumPotionTypes)
case KindScroll:
return int(NumScrollTypes)
case KindRing:
return int(NumRingTypes)
case KindWand:
return int(NumWandTypes)
case KindArmor:
return int(NumArmorTypes)
case KindWeapon:
return int(NumWeaponTypes) + 1
}
return 0
}
// PotionKind returns Which as a potion kind; valid only for KindPotion.
func (o *Object) PotionKind() PotionKind { return PotionKind(o.Which) }
@@ -138,6 +214,36 @@ func (o *Object) WeaponKind() WeaponKind { return WeaponKind(o.Which) }
// ArmorKind returns Which as an armor kind; valid only for KindArmor.
func (o *Object) ArmorKind() ArmorKind { return ArmorKind(o.Which) }
// hasValidWhich reports whether Which is a legal index into this
// object's per-kind tables. Objects the game builds itself always
// satisfy it; the wizard-create command and a restored save file are the
// only two ways a malformed one can appear, and both reject it (see
// createObj and validateSnapshotObjects).
//
// The Which >= 0 arm is unreachable from the keyboard: createObj derives
// Which with byte arithmetic (int(ch-'a') + 10), which wraps to a large
// positive value rather than going negative. It is kept as
// defense-in-depth for the non-keyboard source — a decoded save file,
// where Which is an int off the wire and can hold anything — and is
// exercised there by TestRestoreRejectsOutOfRangeWhich.
func (o *Object) hasValidWhich() bool {
limit := whichLimit(o.Kind)
return limit == 0 || (o.Which >= 0 && o.Which < limit)
}
// wizardCanCreate reports whether Which names something the wizard-create
// command can actually build. It is hasValidWhich narrowed for weapons:
// WeaponFlame owns a name-table slot but no init_dam[] row, so asking for
// it would leave initWeapon nothing to copy.
func (o *Object) wizardCanCreate() bool {
if o.Kind == KindWeapon {
return o.Which >= 0 && o.Which < int(NumWeaponTypes)
}
return o.hasValidWhich()
}
// attachObj is list.c attach(): push item onto the front of a list.
func attachObj(list *[]*Object, item *Object) {
*list = append([]*Object{item}, *list...)
@@ -148,6 +254,7 @@ func detachObj(list *[]*Object, item *Object) {
for i, o := range *list {
if o == item {
*list = append((*list)[:i], (*list)[i+1:]...)
return
}
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "strings"
@@ -28,6 +29,7 @@ type optDesc struct {
// optList builds the options.c optlist for this game.
func (g *RogueGame) optList() []optDesc {
o := &g.Options
return []optDesc{
{"terse", "Terse output", optBool, &o.Terse, nil, nil},
{"flush", "Flush typeahead during battle", optBool, &o.FightFlush, nil, nil},
@@ -46,6 +48,7 @@ func (g *RogueGame) optList() []optDesc {
func (g *RogueGame) option() {
hw := g.scr.Hw
optlist := g.optList()
hw.Clear()
// Display current values of options
for i := range optlist {
@@ -56,9 +59,11 @@ func (g *RogueGame) option() {
}
// Set values
hw.Move(0, 0)
for i := 0; i < len(optlist); i++ {
op := &optlist[i]
g.prOptname(op)
retval := g.getOpt(op)
if retval != Norm {
if retval == Quit {
@@ -70,6 +75,7 @@ func (g *RogueGame) option() {
i -= 2
} else { // trying to back up beyond the top
hw.Move(0, 0)
i--
}
}
@@ -84,18 +90,19 @@ func (g *RogueGame) option() {
// prOptname prints out the option name prompt (options.c pr_optname).
func (g *RogueGame) prOptname(op *optDesc) {
g.scr.Hw.Printw("%s (\"%s\"): ", op.prompt, op.name)
g.scr.Hw.Printwf("%s (\"%s\"): ", op.prompt, op.name)
}
// putOpt prints an option's current value (options.c put_bool/put_str/
// put_inv_t).
func (g *RogueGame) putOpt(op *optDesc) {
hw := g.scr.Hw
switch op.kind {
case optBool, optSeeFloor:
hw.AddStr(boolStr(*op.boolP))
case optInvT:
hw.AddStr(invTName[*op.intP])
hw.AddStr(g.data.invTName[*op.intP])
case optStr:
hw.AddStr(*op.strP)
}
@@ -105,12 +112,14 @@ func boolStr(b bool) string {
if b {
return "True"
}
return "False"
}
// getOpt reads a new value for an option (options.c get_bool/get_sf/
// get_inv_t/get_str dispatch).
func (g *RogueGame) getOpt(op *optDesc) int {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch op.kind {
case optBool:
return g.getBool(op.boolP)
@@ -129,9 +138,11 @@ func (g *RogueGame) getBool(bp *bool) int {
win := g.scr.Hw
oy, ox := win.GetYX()
win.AddStr(boolStr(*bp))
for {
win.Move(oy, ox)
g.scr.RefreshWin(win)
switch g.readchar() {
case 't', 'T':
*bp = true
@@ -145,13 +156,17 @@ func (g *RogueGame) getBool(bp *bool) int {
default:
win.Move(oy, ox+10)
win.AddStr("(T or F)")
continue
}
break
}
win.Move(oy, ox)
win.AddStr(boolStr(*bp))
win.AddCh('\n')
return Norm
}
@@ -159,10 +174,12 @@ func (g *RogueGame) getBool(bp *bool) int {
// get_sf).
func (g *RogueGame) getSf(bp *bool) int {
wasSf := g.Options.SeeFloor
retval := g.getBool(bp)
if retval == Quit {
return Quit
}
if wasSf != g.Options.SeeFloor {
if !g.Options.SeeFloor {
g.Options.SeeFloor = true
@@ -172,6 +189,7 @@ func (g *RogueGame) getSf(bp *bool) int {
g.look(false)
}
}
return Norm
}
@@ -183,57 +201,36 @@ func (g *RogueGame) getStr(opt *string, win *Window) int {
oy, ox := win.GetYX()
g.scr.RefreshWin(win)
// loop reading in the string, and put it in a temporary buffer
var buf []byte
var c byte
var (
buf []byte
c byte
)
for {
c = g.readchar()
if c == '\n' || c == '\r' || c == Escape {
if endsInput(c) || (len(buf) == 0 && c == '-' && !onStd) {
break
}
if c == 8 || c == 0x7f { // erase character
if len(buf) > 0 {
buf = buf[:len(buf)-1]
win.Move(oy, ox+len(displayStr(buf)))
}
win.Clrtoeol()
g.scr.RefreshWin(win)
continue
}
if c == CTRL('U') { // kill character
buf = buf[:0]
win.Move(oy, ox)
win.Clrtoeol()
g.scr.RefreshWin(win)
continue
}
if len(buf) == 0 {
if c == '-' && !onStd {
break
}
if c == '~' {
buf = append(buf, g.Home...)
win.AddStr(g.Home)
win.Clrtoeol()
g.scr.RefreshWin(win)
continue
}
}
if len(buf) >= MaxInp || !(isPrint(c) || c == ' ') {
continue // C beeps here
}
buf = append(buf, c)
win.AddStr(unctrl(c))
win.Clrtoeol()
g.scr.RefreshWin(win)
buf = g.getStrEdit(win, buf, c, oy, ox)
}
if len(buf) > 0 { // only change option if something has been typed
*opt = strucpy(string(buf))
}
win.MvPrintw(oy, ox, "%s\n", *opt)
win.MvPrintwf(oy, ox, "%s\n", *opt)
g.scr.RefreshWin(win)
if onStd {
g.Msgs.Mpos += len(buf)
}
return getStrResult(c)
}
// getStrResult maps the terminating key to the C return code (options.c
// get_str).
func getStrResult(c byte) int {
switch c {
case '-':
return Minus
@@ -244,12 +241,59 @@ func (g *RogueGame) getStr(opt *string, win *Window) int {
}
}
// endsInput reports the keys that finish line input (options.c get_str).
func endsInput(c byte) bool {
return c == '\n' || c == '\r' || c == Escape
}
// getStrErase deletes the last character of the buffer (options.c
// get_str).
func getStrErase(win *Window, buf []byte, oy, ox int) []byte {
if len(buf) > 0 {
buf = buf[:len(buf)-1]
win.Move(oy, ox+len(displayStr(buf)))
}
win.Clrtoeol()
return buf
}
// getStrEdit applies one key to the line editor's buffer: erase, kill,
// home expansion, or a typed character (options.c get_str).
func (g *RogueGame) getStrEdit(win *Window, buf []byte, c byte, oy, ox int) []byte {
switch {
case c == 8 || c == 0x7f:
buf = getStrErase(win, buf, oy, ox)
case c == CTRL('U'): // kill character
buf = buf[:0]
win.Move(oy, ox)
win.Clrtoeol()
case len(buf) == 0 && c == '~':
buf = append(buf, g.Home...)
win.AddStr(g.Home)
win.Clrtoeol()
case len(buf) >= MaxInp || (!isPrint(c) && c != ' '):
return buf // C beeps here
default:
buf = append(buf, c)
win.AddStr(unctrl(c))
win.Clrtoeol()
}
g.scr.RefreshWin(win)
return buf
}
// displayStr renders a buffer the way the input echo did.
func displayStr(buf []byte) string {
var sb strings.Builder
for _, c := range buf {
sb.WriteString(unctrl(c))
}
return sb.String()
}
@@ -257,10 +301,12 @@ func displayStr(buf []byte) string {
func (g *RogueGame) getInvT(ip *int) int {
win := g.scr.Hw
oy, ox := win.GetYX()
win.AddStr(invTName[*ip])
win.AddStr(g.data.invTName[*ip])
for {
win.Move(oy, ox)
g.scr.RefreshWin(win)
switch g.readchar() {
case 'o', 'O':
*ip = InvOver
@@ -276,11 +322,15 @@ func (g *RogueGame) getInvT(ip *int) int {
default:
win.Move(oy, ox+15)
win.AddStr("(O, S, or C)")
continue
}
break
}
win.MvPrintw(oy, ox, "%s\n", invTName[*ip])
win.MvPrintwf(oy, ox, "%s\n", g.data.invTName[*ip])
return Norm
}
@@ -289,87 +339,121 @@ func (g *RogueGame) getInvT(ip *int) int {
// "noname" (false), strings as "name=..." (options.c parse_opts).
func (g *RogueGame) ParseOpts(str string) {
optlist := g.optList()
for str != "" {
// Get option name
i := 0
for i < len(str) && isAlpha(str[i]) {
i++
}
name := str[:i]
rest := str[i:]
matched := false
for oi := range optlist {
op := &optlist[oi]
isBoolOpt := op.kind == optBool || op.kind == optSeeFloor
if strings.HasPrefix(op.name, name) && name != "" {
matched = true
if isBoolOpt {
*op.boolP = true
} else {
// Skip to start of string value
for rest != "" && rest[0] == '=' {
rest = rest[1:]
}
val := rest
var prefix string
if val != "" && val[0] == '~' {
prefix = g.Home
val = val[1:]
for val != "" && val[0] == '/' {
val = val[1:]
}
}
// Skip to end of string value
end := strings.IndexByte(val, ',')
if end < 0 {
end = len(val)
}
word := val[:end]
rest = val[end:]
if op.kind == optInvT {
// check for type of inventory
w := word
if w != "" {
w = string(toUpper(w[0])) + w[1:]
}
for ti, tn := range invTName {
if strings.HasPrefix(tn, w) {
*op.intP = ti
break
}
}
} else {
*op.strP = prefix + strucpy(word)
}
}
break
} else if isBoolOpt && strings.HasPrefix(name, "no") &&
strings.HasPrefix(op.name, name[2:]) {
matched = true
*op.boolP = false
break
}
}
_ = matched
rest := g.parseOptName(optlist, str[:i], str[i:])
// skip to start of next option name
for rest != "" && !isAlpha(rest[0]) {
rest = rest[1:]
}
str = rest
}
}
// parseOptName applies one named option, returning the unconsumed
// remainder: "name" turns a boolean on, "noname" turns it off, and
// string options consume a value (the option scan of options.c
// parse_opts).
func (g *RogueGame) parseOptName(optlist []optDesc, name, rest string) string {
for oi := range optlist {
op := &optlist[oi]
isBoolOpt := op.kind == optBool || op.kind == optSeeFloor
if strings.HasPrefix(op.name, name) && name != "" {
if isBoolOpt {
*op.boolP = true
return rest
}
return g.parseOptValue(op, rest)
}
if isBoolOpt && strings.HasPrefix(name, "no") &&
strings.HasPrefix(op.name, name[2:]) {
*op.boolP = false
return rest
}
}
return rest
}
// parseOptValue consumes an option's "=value" from rest, storing it,
// and returns the remainder (the string arm of options.c parse_opts).
func (g *RogueGame) parseOptValue(op *optDesc, rest string) string {
// Skip to start of string value
for rest != "" && rest[0] == '=' {
rest = rest[1:]
}
val := rest
var prefix string
if val != "" && val[0] == '~' {
prefix = g.Home
val = val[1:]
for val != "" && val[0] == '/' {
val = val[1:]
}
}
// Skip to end of string value
end := strings.IndexByte(val, ',')
if end < 0 {
end = len(val)
}
word := val[:end]
if op.kind == optInvT {
g.parseInvType(op, word)
} else {
*op.strP = prefix + strucpy(word)
}
return val[end:]
}
// parseInvType matches an inventory-style name by prefix (options.c
// parse_opts).
func (g *RogueGame) parseInvType(op *optDesc, word string) {
// check for type of inventory
if word != "" {
word = string(toUpper(word[0])) + word[1:]
}
for ti, tn := range g.data.invTName {
if strings.HasPrefix(tn, word) {
*op.intP = ti
break
}
}
}
// strucpy copies a string keeping only printable characters, capped at
// MAXINP (options.c strucpy).
func strucpy(s string) string {
if len(s) > MaxInp {
s = s[:MaxInp]
}
var sb strings.Builder
for i := 0; i < len(s); i++ {
for i := range len(s) {
if isPrint(s[i]) || s[i] == ' ' {
sb.WriteByte(s[i])
}
}
return sb.String()
}

View File

@@ -7,98 +7,23 @@ package game
func (g *RogueGame) addPack(obj *Object, silent bool) {
p := &g.Player
fromFloor := false
if obj == nil {
if obj = g.findObj(p.Pos.Y, p.Pos.X); obj == nil {
if obj = g.Level.ObjectAt(p.Pos.Y, p.Pos.X); obj == nil {
return
}
fromFloor = true
}
// Check for and deal with scare monster scrolls
if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster && obj.Flags.Has(WasFound) {
detachObj(&g.Level.Objects, obj)
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(Gone) {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
} else {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
}
g.msg("the scroll turns to dust as you pick it up")
if g.pickupScareScroll(obj) {
return
}
if len(p.Pack) == 0 {
p.Pack = append(p.Pack, obj)
obj.PackCh = g.packChar()
p.Inpack++
} else {
// Walk the pack looking for the insertion point, keeping items of
// one type together and merging stackable/grouped items — a direct
// translation of the C linked-list walk. lp is the index to insert
// after; -1 after a merge means no insertion.
lp := -1
merged := false
for i := 0; i < len(p.Pack); i++ {
if p.Pack[i].Kind != obj.Kind {
lp = i
continue
}
// found the group of our type: scan for matching subtype
for p.Pack[i].Kind == obj.Kind && p.Pack[i].Which != obj.Which {
lp = i
if i+1 >= len(p.Pack) {
break
}
i++
}
op := p.Pack[i]
if op.Kind == obj.Kind && op.Which == obj.Which {
if op.Kind.MergesInPack() {
if !g.packRoom(fromFloor, obj) {
return
}
op.Count++
obj = op
lp = -1
merged = true
} else if obj.Group != 0 {
lp = i
for p.Pack[i].Kind == obj.Kind &&
p.Pack[i].Which == obj.Which &&
p.Pack[i].Group != obj.Group {
lp = i
if i+1 >= len(p.Pack) {
break
}
i++
}
op = p.Pack[i]
if op.Kind == obj.Kind && op.Which == obj.Which &&
op.Group == obj.Group {
op.Count += obj.Count
p.Inpack--
if !g.packRoom(fromFloor, obj) {
return
}
obj = op
lp = -1
merged = true
}
} else {
lp = i
}
}
break
}
if !merged && lp != -1 {
if !g.packRoom(fromFloor, obj) {
return
}
obj.PackCh = g.packChar()
p.Pack = append(p.Pack[:lp+1],
append([]*Object{obj}, p.Pack[lp+1:]...)...)
}
obj, ok := g.packInsert(obj, fromFloor)
if !ok {
return
}
obj.Flags.Set(WasFound)
@@ -117,119 +42,264 @@ func (g *RogueGame) addPack(obj *Object, silent bool) {
// Notify the user
if !silent {
if !g.Options.Terse {
g.addmsg("you now have ")
g.addmsgf("you now have ")
}
g.msg("%s (%c)", g.invName(obj, !g.Options.Terse), obj.PackCh)
g.msg("%s (%c)", g.inventoryName(obj, !g.Options.Terse), obj.PackCh)
}
}
// pickupScareScroll crumbles a found scare monster scroll when it is
// picked up again; it reports whether it did (pack.c add_pack).
func (g *RogueGame) pickupScareScroll(obj *Object) bool {
if obj.Kind != KindScroll || obj.ScrollKind() != ScrollScareMonster ||
!obj.Flags.Has(WasFound) {
return false
}
p := &g.Player
g.Level.RemoveObject(obj)
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(Gone) {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
} else {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
}
g.msg("the scroll turns to dust as you pick it up")
return true
}
// packInsert places the object in the pack, keeping items of one type
// together and merging stackable and grouped items — a translation of
// the C linked-list walk in pack.c add_pack. It returns the pack entry
// the object ended up as; ok is false when the pack has no room.
func (g *RogueGame) packInsert(obj *Object, fromFloor bool) (*Object, bool) {
p := &g.Player
if len(p.Pack) == 0 {
p.Pack = append(p.Pack, obj)
obj.PackCh = p.nextPackChar()
p.Inpack++
return obj, true
}
merged := false
// lp is the index to insert after; -1 after a merge means no
// insertion.
i, lp := packScanKind(p.Pack, obj.Kind)
if i < len(p.Pack) {
i, lp = packScanWhich(p.Pack, obj, i, lp)
if op := p.Pack[i]; op.Kind == obj.Kind && op.Which == obj.Which {
var ok bool
obj, lp, merged, ok = g.packMatch(obj, op, i, fromFloor)
if !ok {
return nil, false
}
}
}
if !merged && lp != -1 {
if !g.packRoom(fromFloor, obj) {
return nil, false
}
obj.PackCh = p.nextPackChar()
p.Pack = append(p.Pack[:lp+1],
append([]*Object{obj}, p.Pack[lp+1:]...)...)
}
return obj, true
}
// packScanKind scans to the first pack entry of this kind, returning
// its index (or the pack length) and the entry to insert after (the
// outer scan of pack.c add_pack).
func packScanKind(pack []*Object, kind ObjectKind) (int, int) {
lp := -1
i := 0
for ; i < len(pack); i++ {
if pack[i].Kind == kind {
break
}
lp = i
}
return i, lp
}
// packScanWhich scans within the kind group for the matching subtype
// (the inner scan of pack.c add_pack).
func packScanWhich(pack []*Object, obj *Object, i, lp int) (int, int) {
for pack[i].Kind == obj.Kind && pack[i].Which != obj.Which {
lp = i
if i+1 >= len(pack) {
break
}
i++
}
return i, lp
}
// packMatch merges the object with a matching pack entry when possible:
// stackables merge counts, grouped missiles rejoin their bundle, and
// anything else marks the insertion point (the matched-subtype switch
// of pack.c add_pack). It returns the resulting entry, the insert-after
// index, whether a merge happened, and ok false when the pack is full.
func (g *RogueGame) packMatch(
obj, op *Object, i int, fromFloor bool,
) (*Object, int, bool, bool) {
switch {
case op.Kind.MergesInPack():
if !g.packRoom(fromFloor, obj) {
return nil, 0, false, false
}
op.Count++
return op, -1, true, true
case obj.Group != 0:
return g.packMatchGroup(obj, i, fromFloor)
default:
return obj, i, false, true
}
}
// packMatchGroup rejoins a grouped missile bundle with its group entry
// (the o_group arm of pack.c add_pack).
func (g *RogueGame) packMatchGroup(
obj *Object, i int, fromFloor bool,
) (*Object, int, bool, bool) {
p := &g.Player
lp := i
for p.Pack[i].Kind == obj.Kind &&
p.Pack[i].Which == obj.Which &&
p.Pack[i].Group != obj.Group {
lp = i
if i+1 >= len(p.Pack) {
break
}
i++
}
op := p.Pack[i]
if op.Kind == obj.Kind && op.Which == obj.Which &&
op.Group == obj.Group {
op.Count += obj.Count
p.Inpack--
if !g.packRoom(fromFloor, obj) {
return nil, 0, false, false
}
return op, -1, true, true
}
return obj, lp, false, true
}
// packRoom sees if there's room in the pack; if not, prints an appropriate
// message (pack.c pack_room).
func (g *RogueGame) packRoom(fromFloor bool, obj *Object) bool {
p := &g.Player
if p.Inpack++; p.Inpack > MaxPack {
if !g.Options.Terse {
g.addmsg("there's ")
g.addmsgf("there's ")
}
g.addmsg("no room")
g.addmsgf("no room")
if !g.Options.Terse {
g.addmsg(" in your pack")
g.addmsgf(" in your pack")
}
g.endmsg()
if fromFloor {
g.moveMsg(obj)
}
p.Inpack = MaxPack
return false
}
if fromFloor {
detachObj(&g.Level.Objects, obj)
g.Level.RemoveObject(obj)
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(Gone) {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
} else {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
}
}
return true
}
// leavePack takes an item out of the pack (pack.c leave_pack).
// leavePack takes an item out of the pack (pack.c leave_pack), keeping
// the repeat-command bookkeeping; the pack surgery is
// Player.removeFromPack.
func (g *RogueGame) leavePack(obj *Object, newobj, all bool) *Object {
p := &g.Player
p.Inpack--
nobj := obj
if obj.Count > 1 && !all {
g.LastPick = obj
obj.Count--
if obj.Group != 0 {
p.Inpack++
}
if newobj {
copied := *obj
nobj = &copied
nobj.Count = 1
}
} else {
g.LastPick = nil
p.PackUsed[obj.PackCh-'a'] = false
detachObj(&p.Pack, obj)
}
return nobj
}
// packChar returns the next unused pack character (pack.c pack_char).
func (g *RogueGame) packChar() byte {
p := &g.Player
for i := range p.PackUsed {
if !p.PackUsed[i] {
p.PackUsed[i] = true
return byte(i) + 'a'
}
}
return byte(len(p.PackUsed)) + 'a' // C would walk off the array here
return g.Player.removeFromPack(obj, newobj, all)
}
// inventory lists what is in the pack; returns true if there is something
// of the given type (pack.c inventory).
func (g *RogueGame) inventory(list []*Object, kind ObjectKind) bool {
g.NObjs = 0
for _, item := range list {
if kind != KindNone && kind != item.Kind &&
!(kind == KindCallable && item.Kind != KindFood &&
item.Kind != KindAmulet) &&
!(kind == KindRingOrStick &&
(item.Kind == KindRing || item.Kind == KindWand)) {
if !matchesFilter(kind, item) {
continue
}
g.NObjs++
g.Msgs.MsgEsc = true
line := string(item.PackCh) + ") " + g.invName(item, false)
line := string(item.PackCh) + ") " + g.inventoryName(item, false)
if g.addLine("%s", line) == Escape {
g.Msgs.MsgEsc = false
g.msg("")
return true
}
g.Msgs.MsgEsc = false
}
if g.NObjs == 0 {
if g.Options.Terse {
if kind == KindNone {
g.msg("empty handed")
} else {
g.msg("nothing appropriate")
}
if kind == KindNone {
g.msg("%s", g.chooseTerse("empty handed", "you are empty handed"))
} else {
if kind == KindNone {
g.msg("you are empty handed")
} else {
g.msg("you don't have anything appropriate")
}
g.msg("%s", g.chooseTerse("nothing appropriate",
"you don't have anything appropriate"))
}
return false
}
g.endLine()
return true
}
@@ -240,31 +310,54 @@ func (g *RogueGame) pickUp(ch byte) {
return
}
obj := g.findObj(p.Pos.Y, p.Pos.X)
obj := g.Level.ObjectAt(p.Pos.Y, p.Pos.X)
if g.MoveOn {
g.moveMsg(obj)
return
}
switch ch {
case Gold:
if obj == nil {
return
}
g.money(obj.GoldValue)
detachObj(&g.Level.Objects, obj)
g.Level.RemoveObject(obj)
p.Room.GoldVal = 0
default:
g.addPack(nil, false)
}
}
// matchesFilter reports whether an item passes a get_item/inventory kind
// filter (the C condition in pack.c inventory, untangled): KindNone takes
// everything, KindCallable takes anything nameable (not food, not the
// amulet), KindRingOrStick takes rings and wands.
func matchesFilter(kind ObjectKind, item *Object) bool {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch kind {
case KindNone:
return true
case KindCallable:
return item.Kind != KindFood && item.Kind != KindAmulet
case KindRingOrStick:
return item.Kind == KindRing || item.Kind == KindWand
default:
return item.Kind == kind
}
}
// moveMsg prints the message if you are just moving onto an object
// (pack.c move_msg).
func (g *RogueGame) moveMsg(obj *Object) {
if !g.Options.Terse {
g.addmsg("you ")
g.addmsgf("you ")
}
g.msg("moved onto %s", g.invName(obj, true))
g.msg("moved onto %s", g.inventoryName(obj, true))
}
// pickyInven allows the player to inventory a single item (pack.c
@@ -273,51 +366,55 @@ func (g *RogueGame) pickyInven() {
p := &g.Player
if len(p.Pack) == 0 {
g.msg("you aren't carrying anything")
return
}
if len(p.Pack) == 1 {
g.msg("a) %s", g.invName(p.Pack[0], false))
g.msg("a) %s", g.inventoryName(p.Pack[0], false))
return
}
g.msg("%s", g.chooseTerse("item: ", "which item do you wish to inventory: "))
g.Msgs.Mpos = 0
mch := g.readchar()
if mch == Escape {
g.msg("")
return
}
for _, obj := range p.Pack {
if mch == obj.PackCh {
g.msg("%c) %s", mch, g.invName(obj, false))
g.msg("%c) %s", mch, g.inventoryName(obj, false))
return
}
}
g.msg("'%s' not in pack", unctrl(mch))
}
// getItem picks something out of a pack for a purpose (pack.c get_item).
func (g *RogueGame) getItem(purpose string, kind ObjectKind) *Object {
// promptPackItem picks something out of a pack for a purpose (pack.c
// get_item); the second result reports whether the player chose an
// item.
func (g *RogueGame) promptPackItem(purpose string, kind ObjectKind) (*Object, bool) {
p := &g.Player
if len(p.Pack) == 0 {
g.msg("you aren't carrying anything")
return nil
return nil, false
}
if g.Again {
if g.LastPick != nil {
return g.LastPick
}
g.msg("you ran out")
return nil
return g.repeatLastItem()
}
for {
if !g.Options.Terse {
g.addmsg("which object do you want to ")
}
g.addmsg("%s", purpose)
if g.Options.Terse {
g.addmsg(" what")
}
g.msg("? (* for list): ")
g.promptItemPurpose(purpose)
ch := g.readchar()
g.Msgs.Mpos = 0
// Give the poor player a chance to abort the command
@@ -325,40 +422,77 @@ func (g *RogueGame) getItem(purpose string, kind ObjectKind) *Object {
g.resetLast()
g.After = false
g.msg("")
return nil
return nil, false
}
g.NObjs = 1 // normal case: person types one char
if ch == '*' {
g.Msgs.Mpos = 0
if !g.inventory(p.Pack, kind) {
g.After = false
return nil
return nil, false
}
continue
}
for _, obj := range p.Pack {
if obj.PackCh == ch {
return obj
return obj, true
}
}
g.msg("'%s' is not a valid item", unctrl(ch))
}
}
// repeatLastItem replays the previous selection for the repeat command
// (pack.c get_item).
func (g *RogueGame) repeatLastItem() (*Object, bool) {
if g.LastPick != nil {
return g.LastPick, true
}
g.msg("you ran out")
return nil, false
}
// promptItemPurpose prints the "which object do you want to ...?"
// prompt (pack.c get_item).
func (g *RogueGame) promptItemPurpose(purpose string) {
if !g.Options.Terse {
g.addmsgf("which object do you want to ")
}
g.addmsgf("%s", purpose)
if g.Options.Terse {
g.addmsgf(" what")
}
g.msg("? (* for list): ")
}
// money adds or subtracts gold from the pack (pack.c money).
func (g *RogueGame) money(value int) {
p := &g.Player
p.Purse += value
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
if p.Room.Flags.Has(Gone) {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
} else {
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
}
if value > 0 {
if !g.Options.Terse {
g.addmsg("you found ")
g.addmsgf("you found ")
}
g.msg("%d gold pieces", value)
}
}
@@ -369,9 +503,11 @@ func (g *RogueGame) floorCh() byte {
if g.Player.Room.Flags.Has(Gone) {
return Passage
}
if g.showFloor() {
return Floor
}
return ' '
}
@@ -382,6 +518,7 @@ func (g *RogueGame) floorAt() byte {
if ch == Floor {
ch = g.floorCh()
}
return ch
}
@@ -398,5 +535,6 @@ func (g *RogueGame) chooseTerse(terse, verbose string) string {
if g.Options.Terse {
return terse
}
return verbose
}

View File

@@ -1,222 +1,271 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// passages.c — draw the connecting passages.
// rdesConn is the hardcoded 3x3 room adjacency matrix from do_passages.
var rdesConn = [MaxRooms][MaxRooms]bool{
{false, true, false, true, false, false, false, false, false},
{true, false, true, false, true, false, false, false, false},
{false, true, false, false, false, true, false, false, false},
{true, false, false, false, true, false, true, false, false},
{false, true, false, true, false, true, false, true, false},
{false, false, true, false, true, false, false, false, true},
{false, false, false, true, false, false, false, true, false},
{false, false, false, false, true, false, true, false, true},
{false, false, false, false, false, true, false, true, false},
}
// doPassages draws all the passages on a level (passages.c do_passages).
func (g *RogueGame) doPassages() {
var isconn [MaxRooms][MaxRooms]bool
var ingraph [MaxRooms]bool
// digPassages draws all the passages on a level (passages.c do_passages).
func (g *RogueGame) digPassages() {
var (
isconn [MaxRooms][MaxRooms]bool
ingraph [MaxRooms]bool
)
// starting with one room, connect it to a random adjacent room and
// then pick a new room to start with.
roomcount := 1
r1 := g.rnd(MaxRooms)
ingraph[r1] = true
for {
for roomcount < MaxRooms {
// find a room to connect with
j := 0
r2 := -1
for i := 0; i < MaxRooms; i++ {
if rdesConn[r1][i] && !ingraph[i] {
if j++; g.rnd(j) == 0 {
r2 = i
}
}
}
if j == 0 {
// if no adjacent rooms are outside the graph, pick a new room
// to look from
r2 := g.pickNeighbor(r1, func(i int) bool { return !ingraph[i] })
if r2 < 0 {
// if no adjacent rooms are outside the graph, pick a new
// room to look from
for {
r1 = g.rnd(MaxRooms)
if ingraph[r1] {
break
}
}
} else {
// otherwise, connect new room to the graph, and draw a tunnel
// to it
ingraph[r2] = true
g.conn(r1, r2)
isconn[r1][r2] = true
isconn[r2][r1] = true
roomcount++
}
if roomcount >= MaxRooms {
break
continue
}
// otherwise, connect new room to the graph, and draw a tunnel
// to it
ingraph[r2] = true
g.connectRooms(r1, r2)
isconn[r1][r2] = true
isconn[r2][r1] = true
roomcount++
}
// attempt to add passages to the graph a random number of times so that
// there isn't always just one unique passage through it.
for roomcount = g.rnd(5); roomcount > 0; roomcount-- {
r1 = g.rnd(MaxRooms) // a random room to look from
// find an adjacent room not already connected
j := 0
r2 := -1
for i := 0; i < MaxRooms; i++ {
if rdesConn[r1][i] && !isconn[r1][i] {
if j++; g.rnd(j) == 0 {
r2 = i
}
}
}
// if there is one, connect it and look for the next added passage
if j != 0 {
g.conn(r1, r2)
// find an adjacent room not already connected; if there is one,
// connect it and look for the next added passage
r2 := g.pickNeighbor(r1, func(i int) bool { return !isconn[r1][i] })
if r2 >= 0 {
g.connectRooms(r1, r2)
isconn[r1][r2] = true
isconn[r2][r1] = true
}
}
g.passnum()
g.numberPassages()
}
// conn draws a corridor from a room in a certain direction (passages.c
// conn).
func (g *RogueGame) conn(r1, r2 int) {
var rm int
var direc byte
if r1 < r2 {
rm = r1
if r1+1 == r2 {
direc = 'r'
} else {
direc = 'd'
}
} else {
rm = r2
if r2+1 == r1 {
direc = 'r'
} else {
direc = 'd'
// pickNeighbor reservoir-picks an adjacent room for which ok holds, or
// -1 when there is none (passages.c do_passages).
func (g *RogueGame) pickNeighbor(r1 int, ok func(int) bool) int {
j := 0
r2 := -1
for i := range MaxRooms {
if g.data.rdesConn[r1][i] && ok(i) {
if j++; g.rnd(j) == 0 {
r2 = i
}
}
}
rpf := &g.Level.Rooms[rm]
// Set up the movement variables, in two cases: first drawing one down.
var rpt *Room
var del, turnDelta, spos, epos Coord
var distance, turnDistance int
return r2
}
// corridorPlan is the movement setup connectRooms computes before it
// digs (the local variables of passages.c conn).
type corridorPlan struct {
rpf, rpt *Room // the rooms being joined
del Coord // direction of move
turnDelta Coord // direction to turn
spos, epos Coord // start and end of move
distance, turnDistance int // how far to move and to turn
}
// connectRooms draws a corridor from a room in a certain direction
// (passages.c conn).
func (g *RogueGame) connectRooms(r1, r2 int) {
rm, direc := connOrient(r1, r2)
var plan corridorPlan
if direc == 'd' {
rmt := rm + 3 // room # of dest
rpt = &g.Level.Rooms[rmt] // room pointer of dest
del = Coord{X: 0, Y: 1} // direction of move
spos = rpf.Pos // start of move
epos = rpt.Pos // end of move
if !rpf.Flags.Has(Gone) { // if not gone pick door pos
for {
spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1
spos.Y = rpf.Pos.Y + rpf.Max.Y - 1
if !(rpf.Flags.Has(Maze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage)) {
break
}
}
}
if !rpt.Flags.Has(Gone) {
for {
epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1
if !(rpt.Flags.Has(Maze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage)) {
break
}
}
}
distance = abs(spos.Y-epos.Y) - 1 // distance to move
turnDelta.Y = 0 // direction to turn
if spos.X < epos.X {
turnDelta.X = 1
} else {
turnDelta.X = -1
}
turnDistance = abs(spos.X - epos.X) // how far to turn
} else { // setup for moving right
rmt := rm + 1
rpt = &g.Level.Rooms[rmt]
del = Coord{X: 1, Y: 0}
spos = rpf.Pos
epos = rpt.Pos
if !rpf.Flags.Has(Gone) {
for {
spos.X = rpf.Pos.X + rpf.Max.X - 1
spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1
if !(rpf.Flags.Has(Maze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage)) {
break
}
}
}
if !rpt.Flags.Has(Gone) {
for {
epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1
if !(rpt.Flags.Has(Maze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage)) {
break
}
}
}
distance = abs(spos.X-epos.X) - 1
if spos.Y < epos.Y {
turnDelta.Y = 1
} else {
turnDelta.Y = -1
}
turnDelta.X = 0
turnDistance = abs(spos.Y - epos.Y)
plan = g.connPlanDown(rm)
} else {
plan = g.connPlanRight(rm)
}
turnSpot := g.rnd(distance-1) + 1 // where turn starts
turnSpot := g.rnd(plan.distance-1) + 1 // where turn starts
// Draw in the doors on either side of the passage or just put #'s if
// the rooms are gone.
if !rpf.Flags.Has(Gone) {
g.door(rpf, spos)
} else {
g.putpass(spos)
g.connEnd(plan.rpf, plan.spos)
g.connEnd(plan.rpt, plan.epos)
g.digCorridor(plan, turnSpot)
}
// connOrient picks the upper-left room of the pair and the digging
// direction: right for horizontal neighbors, down otherwise (passages.c
// conn).
func connOrient(r1, r2 int) (int, byte) {
rm := min(r1, r2)
if abs(r1-r2) == 1 {
return rm, 'r'
}
return rm, 'd'
}
// connPlanDown sets up the movement variables for a corridor drawn
// downward (passages.c conn).
func (g *RogueGame) connPlanDown(rm int) corridorPlan {
rpf := &g.Level.Rooms[rm]
rpt := &g.Level.Rooms[rm+3] // room pointer of dest
plan := corridorPlan{
rpf: rpf,
rpt: rpt,
del: Coord{X: 0, Y: 1}, // direction of move
spos: rpf.Pos, // start of move
epos: rpt.Pos, // end of move
}
if !rpf.Flags.Has(Gone) { // if not gone pick door pos
for {
plan.spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1
plan.spos.Y = rpf.Pos.Y + rpf.Max.Y - 1
if !rpf.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) {
break
}
}
}
if !rpt.Flags.Has(Gone) {
g.door(rpt, epos)
} else {
g.putpass(epos)
for {
plan.epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1
if !rpt.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) {
break
}
}
}
// Get ready to move...
curr := spos
plan.distance = abs(plan.spos.Y-plan.epos.Y) - 1 // distance to move
plan.turnDelta.Y = 0 // direction to turn
if plan.spos.X < plan.epos.X {
plan.turnDelta.X = 1
} else {
plan.turnDelta.X = -1
}
plan.turnDistance = abs(plan.spos.X - plan.epos.X) // how far to turn
return plan
}
// connPlanRight sets up the movement variables for a corridor drawn to
// the right (passages.c conn).
func (g *RogueGame) connPlanRight(rm int) corridorPlan {
rpf := &g.Level.Rooms[rm]
rpt := &g.Level.Rooms[rm+1]
plan := corridorPlan{
rpf: rpf,
rpt: rpt,
del: Coord{X: 1, Y: 0},
spos: rpf.Pos,
epos: rpt.Pos,
}
if !rpf.Flags.Has(Gone) {
for {
plan.spos.X = rpf.Pos.X + rpf.Max.X - 1
plan.spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1
if !rpf.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) {
break
}
}
}
if !rpt.Flags.Has(Gone) {
for {
plan.epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1
if !rpt.Flags.Has(Maze) ||
g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) {
break
}
}
}
plan.distance = abs(plan.spos.X-plan.epos.X) - 1
if plan.spos.Y < plan.epos.Y {
plan.turnDelta.Y = 1
} else {
plan.turnDelta.Y = -1
}
plan.turnDelta.X = 0
plan.turnDistance = abs(plan.spos.Y - plan.epos.Y)
return plan
}
// connEnd draws a corridor end: a door on a real room, a passage square
// on a gone one (passages.c conn).
func (g *RogueGame) connEnd(rp *Room, pos Coord) {
if !rp.Flags.Has(Gone) {
g.door(rp, pos)
} else {
g.putPassage(pos)
}
}
// digCorridor digs from spos to epos, turning at turnSpot (the digging
// loop of passages.c conn).
func (g *RogueGame) digCorridor(plan corridorPlan, turnSpot int) {
curr := plan.spos
distance := plan.distance
turnDistance := plan.turnDistance
for distance > 0 {
// Move to new position
curr.X += del.X
curr.Y += del.Y
curr.X += plan.del.X
curr.Y += plan.del.Y
// Check if we are at the turn place, if so do the turn
if distance == turnSpot {
for ; turnDistance > 0; turnDistance-- {
g.putpass(curr)
curr.X += turnDelta.X
curr.Y += turnDelta.Y
g.putPassage(curr)
curr.X += plan.turnDelta.X
curr.Y += plan.turnDelta.Y
}
}
// Continue digging along
g.putpass(curr)
g.putPassage(curr)
distance--
}
curr.X += del.X
curr.Y += del.Y
if curr != epos {
curr.X += plan.del.X
curr.Y += plan.del.Y
if curr != plan.epos {
g.msg("warning, connectivity problem on this level")
}
}
// putpass adds a passage character or secret passage here (passages.c
// putPassage adds a passage character or secret passage here (passages.c
// putpass).
func (g *RogueGame) putpass(cp Coord) {
func (g *RogueGame) putPassage(cp Coord) {
pp := g.Level.At(cp.Y, cp.X)
pp.Flags.Set(FPassage)
if g.rnd(10)+1 < g.Depth && g.rnd(40) == 0 {
pp.Flags.Clear(FReal)
} else {
@@ -240,6 +289,7 @@ func (g *RogueGame) door(rm *Room, cp Coord) {
} else {
pp.Ch = '|'
}
pp.Flags.Clear(FReal)
} else {
pp.Ch = Door
@@ -250,79 +300,100 @@ func (g *RogueGame) door(rm *Room, cp Coord) {
// (passages.c add_pass).
func (g *RogueGame) addPass() {
for y := 1; y < NumLines-1; y++ {
for x := 0; x < NumCols; x++ {
pp := g.Level.At(y, x)
if pp.Flags.Has(FPassage) || pp.Ch == Door ||
(!pp.Flags.Has(FReal) && (pp.Ch == '|' || pp.Ch == '-')) {
ch := pp.Ch
if pp.Flags.Has(FPassage) {
ch = Passage
}
pp.Flags.Set(FSeen)
g.move(y, x)
if pp.Monst != nil {
pp.Monst.OldCh = pp.Ch
} else if pp.Flags.Has(FReal) {
g.addch(ch)
} else {
g.standout()
if pp.Flags.Has(FPassage) {
g.addch(Passage)
} else {
g.addch(Door)
}
g.standend()
}
}
for x := range NumCols {
g.addPassSpot(g.Level.At(y, x), y, x)
}
}
}
// passnum assigns a number to each passageway (passages.c passnum).
func (g *RogueGame) passnum() {
// addPassSpot shows one passage or door square for the wizard (the loop
// body of passages.c add_pass).
func (g *RogueGame) addPassSpot(pp *Place, y, x int) {
if !pp.Flags.Has(FPassage) && !hiddenExit(pp.Flags, pp.Ch) {
return
}
ch := pp.Ch
if pp.Flags.Has(FPassage) {
ch = Passage
}
pp.Flags.Set(FSeen)
g.move(y, x)
switch {
case pp.Monst != nil:
pp.Monst.OldCh = pp.Ch
case pp.Flags.Has(FReal):
g.addch(ch)
default:
g.standout()
if pp.Flags.Has(FPassage) {
g.addch(Passage)
} else {
g.addch(Door)
}
g.standend()
}
}
// hiddenExit reports a door, or a secret door still drawn as a wall
// (passages.c add_pass / numpass).
func hiddenExit(fp PlaceFlags, ch byte) bool {
return ch == Door || (!fp.Has(FReal) && (ch == '|' || ch == '-'))
}
// numberPassages assigns a number to each passageway (passages.c passnum).
func (g *RogueGame) numberPassages() {
g.pnum = 0
g.newpnum = false
for i := range g.Level.Passages {
g.Level.Passages[i].Exits = g.Level.Passages[i].Exits[:0]
}
for i := range g.Level.Rooms {
rp := &g.Level.Rooms[i]
for j := range rp.Exits {
g.newpnum = true
g.numpass(rp.Exits[j].Y, rp.Exits[j].X)
g.numberPassage(rp.Exits[j].Y, rp.Exits[j].X)
}
}
}
// numpass numbers a passageway square and its brethren (passages.c
// numberPassage numbers a passageway square and its brethren (passages.c
// numpass).
func (g *RogueGame) numpass(y, x int) {
func (g *RogueGame) numberPassage(y, x int) {
if x >= NumCols || x < 0 || y >= NumLines || y <= 0 {
return
}
fp := g.Level.FlagsAt(y, x)
if fp.Has(FPassNum) {
return
}
if g.newpnum {
g.pnum++
g.newpnum = false
}
// check to see if it is a door or secret door, i.e., a new exit, or a
// numerable type of place
if ch := g.Level.Char(y, x); ch == Door ||
(!fp.Has(FReal) && (ch == '|' || ch == '-')) {
if hiddenExit(*fp, g.Level.Char(y, x)) {
rp := &g.Level.Passages[g.pnum]
rp.Exits = append(rp.Exits, Coord{Y: y, X: x})
} else if !fp.Has(FPassage) {
return
}
*fp |= PlaceFlags(g.pnum)
*fp |= PlaceFlags(g.pnum) //nolint:gosec // G115: pnum < MaxPass=13
// recurse on the surrounding places
g.numpass(y+1, x)
g.numpass(y-1, x)
g.numpass(y, x+1)
g.numpass(y, x-1)
g.numberPassage(y+1, x)
g.numberPassage(y-1, x)
g.numberPassage(y, x+1)
g.numberPassage(y, x-1)
}
// abs is C abs() for ints.
@@ -330,5 +401,6 @@ func abs(n int) int {
if n < 0 {
return -n
}
return n
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
@@ -13,185 +14,236 @@ type pact struct {
straight string
}
// pActions is potions.c p_actions[]. The P_SEEINVIS message is dynamic
// (it names the fruit) and is computed in doPot.
var pActions = [NumPotionTypes]pact{
PotionConfusion: {Confused, DUnconfuse, HuhDuration,
"what a tripy feeling!",
"wait, what's going on here. Huh? What? Who?"},
PotionLSD: {Hallucinating, DComeDown, SeeDuration,
"Oh, wow! Everything seems so cosmic!",
"Oh, wow! Everything seems so cosmic!"},
PotionSeeInvisible: {CanSeeInvisible, DUnsee, SeeDuration, "", ""},
PotionBlindness: {Blind, DSight, SeeDuration,
"oh, bummer! Everything is dark! Help!",
"a cloak of darkness falls around you"},
PotionLevitation: {Levitating, DLand, HealTime,
"oh, wow! You're floating in the air!",
"you start to float in the air"},
}
// quaff drinks a potion from the pack (potions.c quaff).
func (g *RogueGame) quaff() {
p := &g.Player
obj := g.getItem("quaff", KindPotion)
obj, ok := g.promptPackItem("quaff", KindPotion)
// Make certain that it is something that we want to drink
if obj == nil {
if !ok {
return
}
if obj.Kind != KindPotion {
if !g.Options.Terse {
g.msg("yuk! Why would you want to drink that?")
} else {
g.msg("that's undrinkable")
}
return
}
if obj == p.CurWeapon {
p.CurWeapon = nil
}
// Calculate the effect it has on the poor guy.
trip := p.On(Hallucinating)
g.leavePack(obj, false, false)
switch obj.PotionKind() {
case PotionConfusion:
g.doPot(PotionConfusion, !trip)
case PotionPoison:
g.Items.Potions[PotionPoison].Know = true
if p.IsWearing(RingSustainStrength) {
g.msg("you feel momentarily sick")
} else {
g.chgStr(-(g.rnd(3) + 1))
g.msg("you feel very sick now")
g.comeDown(0)
}
case PotionHealing:
g.Items.Potions[PotionHealing].Know = true
if p.Stats.HP += g.roll(p.Stats.Lvl, 4); p.Stats.HP > p.Stats.MaxHP {
p.Stats.MaxHP++
p.Stats.HP = p.Stats.MaxHP
}
g.sight(0)
g.msg("you begin to feel better")
case PotionGainStrength:
g.Items.Potions[PotionGainStrength].Know = true
g.chgStr(1)
g.msg("you feel stronger, now. What bulging muscles!")
case PotionDetectMonsters:
p.Flags.Set(SenseMonsters)
g.Fuse(DTurnSee, 1, HuhDuration, After)
if !g.turnSee(false) {
g.msg("you have a %s feeling for a moment, then it passes",
g.chooseStr("normal", "strange"))
}
case PotionDetectMagic:
// Potion of magic detection. Show the potions and scrolls
show := false
if len(g.Level.Objects) > 0 {
g.scr.Hw.Clear()
for _, tp := range g.Level.Objects {
if tp.isMagic() {
show = true
g.scr.Hw.MvAddCh(tp.Pos.Y, tp.Pos.X, Magic)
g.Items.Potions[PotionDetectMagic].Know = true
}
}
for _, mp := range g.Level.Monsters {
for _, tp := range mp.Pack {
if tp.isMagic() {
show = true
g.scr.Hw.MvAddCh(mp.Pos.Y, mp.Pos.X, Magic)
}
}
}
}
if show {
g.Items.Potions[PotionDetectMagic].Know = true
g.showWin("You sense the presence of magic on this level.--More--")
} else {
g.msg("you have a %s feeling for a moment, then it passes",
g.chooseStr("normal", "strange"))
}
case PotionLSD:
if !trip {
if p.On(SenseMonsters) {
g.turnSee(false)
}
g.StartDaemon(DVisuals, 0, Before)
g.SeenStairs = g.seenStairs()
}
g.doPot(PotionLSD, true)
case PotionSeeInvisible:
show := p.On(CanSeeInvisible)
g.doPot(PotionSeeInvisible, false)
if !show {
g.invisOn()
}
g.sight(0)
case PotionRaiseLevel:
g.Items.Potions[PotionRaiseLevel].Know = true
g.msg("you suddenly feel much more skillful")
g.raiseLevel()
case PotionExtraHealing:
g.Items.Potions[PotionExtraHealing].Know = true
if p.Stats.HP += g.roll(p.Stats.Lvl, 8); p.Stats.HP > p.Stats.MaxHP {
if p.Stats.HP > p.Stats.MaxHP+p.Stats.Lvl+1 {
p.Stats.MaxHP++
}
p.Stats.MaxHP++
p.Stats.HP = p.Stats.MaxHP
}
g.sight(0)
g.comeDown(0)
g.msg("you begin to feel much better")
case PotionHaste:
g.Items.Potions[PotionHaste].Know = true
g.After = false
if g.addHaste(true) {
g.msg("you feel yourself moving much faster")
}
case PotionRestoreStrength:
if p.IsRing(Left, RingAddStrength) {
addStr(&p.Stats.Str, -p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&p.Stats.Str, -p.CurRing[Right].Bonus)
}
if p.Stats.Str < p.MaxStats.Str {
p.Stats.Str = p.MaxStats.Str
}
if p.IsRing(Left, RingAddStrength) {
addStr(&p.Stats.Str, p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&p.Stats.Str, p.CurRing[Right].Bonus)
}
g.msg("hey, this tastes great. It make you feel warm all over")
case PotionBlindness:
g.doPot(PotionBlindness, true)
case PotionLevitation:
g.doPot(PotionLevitation, true)
if h := g.data.quaffHandler(obj); h != nil {
h(g, trip)
}
g.status()
// Throw the item away
g.callIt(&g.Items.Potions[obj.Which])
// Throw the item away. A malformed potion has no lore entry to name,
// so it is drunk for no effect and never prompts to be called anything.
if obj.hasValidWhich() {
g.callIt(&g.Items.Potions[obj.Which])
}
}
// The per-potion effect handlers, dispatched through
// gameData.quaffHandlers. Each is one case of the C quaff switch.
func (g *RogueGame) quaffConfusion(trip bool) {
g.applyPotionFuse(PotionConfusion, !trip)
}
func (g *RogueGame) quaffPoison(bool) {
g.Items.Potions[PotionPoison].Know = true
if g.Player.IsWearing(RingSustainStrength) {
g.msg("you feel momentarily sick")
} else {
g.changeStrength(-(g.rnd(3) + 1))
g.msg("you feel very sick now")
g.comeDown(0)
}
}
func (g *RogueGame) quaffHealing(bool) {
p := &g.Player
g.Items.Potions[PotionHealing].Know = true
if p.Stats.HP += g.roll(p.Stats.Lvl, 4); p.Stats.HP > p.Stats.MaxHP {
p.Stats.MaxHP++
p.Stats.HP = p.Stats.MaxHP
}
g.sight(0)
g.msg("you begin to feel better")
}
func (g *RogueGame) quaffGainStrength(bool) {
g.Items.Potions[PotionGainStrength].Know = true
g.changeStrength(1)
g.msg("you feel stronger, now. What bulging muscles!")
}
func (g *RogueGame) quaffDetectMonsters(bool) {
g.Player.Flags.Set(SenseMonsters)
g.Fuse(DTurnSee, 1, HuhDuration, After)
if !g.turnSee(false) {
g.msg("you have a %s feeling for a moment, then it passes",
g.chooseStr("normal", "strange"))
}
}
func (g *RogueGame) quaffDetectMagic(bool) {
// Potion of magic detection. Show the potions and scrolls
show := false
if len(g.Level.Objects) > 0 {
g.scr.Hw.Clear()
for _, tp := range g.Level.Objects {
if g.isMagic(tp) {
show = true
g.scr.Hw.MvAddCh(tp.Pos.Y, tp.Pos.X, Magic)
g.Items.Potions[PotionDetectMagic].Know = true
}
}
for _, mp := range g.Level.Monsters {
for _, tp := range mp.Pack {
if g.isMagic(tp) {
show = true
g.scr.Hw.MvAddCh(mp.Pos.Y, mp.Pos.X, Magic)
}
}
}
}
if show {
g.Items.Potions[PotionDetectMagic].Know = true
g.showWin("You sense the presence of magic on this level.--More--")
} else {
g.msg("you have a %s feeling for a moment, then it passes",
g.chooseStr("normal", "strange"))
}
}
func (g *RogueGame) quaffLSD(trip bool) {
p := &g.Player
if !trip {
if p.On(SenseMonsters) {
g.turnSee(false)
}
g.StartDaemon(DVisuals, 0, Before)
g.SeenStairs = g.seenStairs()
}
g.applyPotionFuse(PotionLSD, true)
}
func (g *RogueGame) quaffSeeInvisible(bool) {
show := g.Player.On(CanSeeInvisible)
g.applyPotionFuse(PotionSeeInvisible, false)
if !show {
g.invisOn()
}
g.sight(0)
}
func (g *RogueGame) quaffRaiseLevel(bool) {
g.Items.Potions[PotionRaiseLevel].Know = true
g.msg("you suddenly feel much more skillful")
g.raiseLevel()
}
func (g *RogueGame) quaffExtraHealing(bool) {
p := &g.Player
g.Items.Potions[PotionExtraHealing].Know = true
if p.Stats.HP += g.roll(p.Stats.Lvl, 8); p.Stats.HP > p.Stats.MaxHP {
if p.Stats.HP > p.Stats.MaxHP+p.Stats.Lvl+1 {
p.Stats.MaxHP++
}
p.Stats.MaxHP++
p.Stats.HP = p.Stats.MaxHP
}
g.sight(0)
g.comeDown(0)
g.msg("you begin to feel much better")
}
func (g *RogueGame) quaffHaste(bool) {
g.Items.Potions[PotionHaste].Know = true
g.After = false
if g.addHaste(true) {
g.msg("you feel yourself moving much faster")
}
}
func (g *RogueGame) quaffRestoreStrength(bool) {
p := &g.Player
if p.IsRing(Left, RingAddStrength) {
addStr(&p.Stats.Str, -p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&p.Stats.Str, -p.CurRing[Right].Bonus)
}
if p.Stats.Str < p.MaxStats.Str {
p.Stats.Str = p.MaxStats.Str
}
if p.IsRing(Left, RingAddStrength) {
addStr(&p.Stats.Str, p.CurRing[Left].Bonus)
}
if p.IsRing(Right, RingAddStrength) {
addStr(&p.Stats.Str, p.CurRing[Right].Bonus)
}
g.msg("hey, this tastes great. It make you feel warm all over")
}
func (g *RogueGame) quaffBlindness(bool) {
g.applyPotionFuse(PotionBlindness, true)
}
func (g *RogueGame) quaffLevitation(bool) {
g.applyPotionFuse(PotionLevitation, true)
}
// raiseLevel: the guy just magically went up a level (potions.c
// raise_level).
func (g *RogueGame) raiseLevel() {
g.Player.Stats.Exp = eLevels[g.Player.Stats.Lvl-1] + 1
g.Player.Stats.Exp = g.data.eLevels[g.Player.Stats.Lvl-1] + 1
g.checkLevel()
}
// doPot does a potion with standard setup: it uses a fuse and turns on a
// flag (potions.c do_pot).
func (g *RogueGame) doPot(kind PotionKind, knowit bool) {
pp := &pActions[kind]
// applyPotionFuse does a potion with standard setup: it uses a fuse and
// turns on a flag (potions.c do_pot).
func (g *RogueGame) applyPotionFuse(kind PotionKind, knowit bool) {
pp := &g.data.pActions[kind]
if !g.Items.Potions[kind].Know {
g.Items.Potions[kind].Know = knowit
}
t := g.spread(pp.time)
if !g.Player.On(pp.flags) {
g.Player.Flags.Set(pp.flags)
@@ -200,24 +252,29 @@ func (g *RogueGame) doPot(kind PotionKind, knowit bool) {
} else {
g.Lengthen(pp.daemon, t)
}
high, straight := pp.high, pp.straight
if kind == PotionSeeInvisible {
s := fmt.Sprintf("this potion tastes like %s juice", g.Fruit)
high, straight = s, s
}
g.msg("%s", g.chooseStr(high, straight))
}
// isMagic reports whether an object radiates magic (potions.c is_magic).
func (o *Object) isMagic() bool {
func (g *RogueGame) isMagic(o *Object) bool {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch o.Kind {
case KindArmor:
return o.Flags.Has(Protected) || o.ArmorClass != aClass[o.Which]
return o.Flags.Has(Protected) || o.ArmorClass != g.data.armorClass(o.Which)
case KindWeapon:
return o.HPlus != 0 || o.DPlus != 0
case KindPotion, KindScroll, KindWand, KindRing, KindAmulet:
return true
}
return false
}
@@ -225,6 +282,7 @@ func (o *Object) isMagic() bool {
// invis_on).
func (g *RogueGame) invisOn() {
g.Player.Flags.Set(CanSeeInvisible)
for _, mp := range g.Level.Monsters {
if mp.On(Invisible) && g.seeMonst(mp) && !g.Player.On(Hallucinating) {
g.mvaddch(mp.Pos.Y, mp.Pos.X, mp.Disguise)
@@ -236,44 +294,62 @@ func (g *RogueGame) invisOn() {
// turn_see).
func (g *RogueGame) turnSee(turnOff bool) bool {
addNew := false
for _, mp := range g.Level.Monsters {
g.move(mp.Pos.Y, mp.Pos.X)
canSee := g.seeMonst(mp)
if turnOff {
if !canSee {
g.addch(mp.OldCh)
}
} else {
if !canSee {
g.standout()
}
if !g.Player.On(Hallucinating) {
g.addch(mp.Type)
} else {
g.addch(byte(g.rnd(26) + 'A'))
}
if !canSee {
g.standend()
addNew = true
}
} else if g.showSensed(mp, canSee) {
addNew = true
}
}
if turnOff {
g.Player.Flags.Clear(SenseMonsters)
} else {
g.Player.Flags.Set(SenseMonsters)
}
return addNew
}
// showSensed draws one monster for monster sense, standout when it is
// otherwise invisible; it reports whether the monster was newly revealed
// (the turn-on arm of the C turn_see loop).
func (g *RogueGame) showSensed(mp *Monster, canSee bool) bool {
if !canSee {
g.standout()
}
if !g.Player.On(Hallucinating) {
g.addch(mp.Type)
} else {
g.addch(g.randomMonsterLetter())
}
if !canSee {
g.standend()
return true
}
return false
}
// seenStairs reports whether the player has seen the stairs (potions.c
// seen_stairs).
func (g *RogueGame) seenStairs() bool {
st := g.Level.Stairs
g.move(st.Y, st.X)
if g.inch() == Stairs { // it's on the map
return true
}
if g.Player.Pos == st { // it's under him
return true
}
@@ -282,9 +358,11 @@ func (g *RogueGame) seenStairs() bool {
if g.seeMonst(tp) && tp.On(Awake) { // visible and awake:
return true // it must have moved there
}
if g.Player.On(SenseMonsters) && tp.OldCh == Stairs {
return true
}
}
return false
}

View File

@@ -7,17 +7,16 @@ import "fmt"
// ringOn puts a ring on a hand (rings.c ring_on).
func (g *RogueGame) ringOn() {
p := &g.Player
obj := g.getItem("put on", KindRing)
obj, ok := g.promptPackItem("put on", KindRing)
// Make certain that it is something that we want to wear
if obj == nil {
if !ok {
return
}
if obj.Kind != KindRing {
if !g.Options.Terse {
g.msg("it would be difficult to wrap that around a finger")
} else {
g.msg("not a ring")
}
g.msg("%s", g.chooseTerse("not a ring",
"it would be difficult to wrap that around a finger"))
return
}
@@ -26,30 +25,18 @@ func (g *RogueGame) ringOn() {
return
}
var ring int
switch {
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
if ring = g.gethand(); ring < 0 {
return
}
case p.CurRing[Left] == nil:
ring = Left
case p.CurRing[Right] == nil:
ring = Right
default:
if !g.Options.Terse {
g.msg("you already have a ring on each hand")
} else {
g.msg("wearing two")
}
ring := g.pickRingHand()
if ring < 0 {
return
}
p.CurRing[ring] = obj
// Calculate the effect it has on the poor guy.
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingAddStrength:
g.chgStr(obj.Bonus)
g.changeStrength(obj.Bonus)
case RingSeeInvisible:
g.invisOn()
case RingAggravateMonsters:
@@ -57,15 +44,38 @@ func (g *RogueGame) ringOn() {
}
if !g.Options.Terse {
g.addmsg("you are now wearing ")
g.addmsgf("you are now wearing ")
}
g.msg("%s (%c)", g.inventoryName(obj, true), obj.PackCh)
}
// pickRingHand chooses the hand for a new ring, asking when both are
// free; negative aborts (rings.c ring_on).
func (g *RogueGame) pickRingHand() int {
p := &g.Player
switch {
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
return g.gethand()
case p.CurRing[Left] == nil:
return Left
case p.CurRing[Right] == nil:
return Right
default:
g.msg("%s", g.chooseTerse("wearing two",
"you already have a ring on each hand"))
return -1
}
g.msg("%s (%c)", g.invName(obj, true), obj.PackCh)
}
// ringOff takes off a ring (rings.c ring_off).
func (g *RogueGame) ringOff() {
p := &g.Player
var ring int
switch {
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
if g.Options.Terse {
@@ -73,6 +83,7 @@ func (g *RogueGame) ringOff() {
} else {
g.msg("you aren't wearing any rings")
}
return
case p.CurRing[Left] == nil:
ring = Right
@@ -83,14 +94,18 @@ func (g *RogueGame) ringOff() {
return
}
}
g.Msgs.Mpos = 0
obj := p.CurRing[ring]
if obj == nil {
g.msg("not wearing such a ring")
return
}
if g.dropCheck(obj) {
g.msg("was wearing %s(%c)", g.invName(obj, true), obj.PackCh)
g.msg("was wearing %s(%c)", g.inventoryName(obj, true), obj.PackCh)
}
}
@@ -102,17 +117,22 @@ func (g *RogueGame) gethand() int {
} else {
g.msg("left hand or right hand? ")
}
c := g.readchar()
if c == Escape {
return -1
}
g.Msgs.Mpos = 0
if c == 'l' || c == 'L' {
return Left
}
if c == 'r' || c == 'R' {
return Right
}
if g.Options.Terse {
g.msg("L or R")
} else {
@@ -121,25 +141,6 @@ func (g *RogueGame) gethand() int {
}
}
// ringUses is the rings.c ring_eat static uses[] table: how much food each
// ring type uses up per turn (negative = a 1-in-n chance of 1).
var ringUses = [NumRingTypes]int{
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
}
// ringEat reports how much food the ring on the given hand uses up
// (rings.c ring_eat).
func (g *RogueGame) ringEat(hand int) int {
@@ -147,7 +148,8 @@ func (g *RogueGame) ringEat(hand int) int {
if ring == nil {
return 0
}
eat := ringUses[ring.RingKind()]
eat := g.data.ringUses[ring.RingKind()]
if eat < 0 {
if g.rnd(-eat) == 0 {
eat = 1
@@ -155,20 +157,26 @@ func (g *RogueGame) ringEat(hand int) int {
eat = 0
}
}
if ring.RingKind() == RingSlowDigestion {
eat = -eat
}
return eat
}
// ringNum prints ring bonuses (rings.c ring_num).
func ringNum(g *RogueGame, obj *Object) string {
// ringNum prints ring bonuses (rings.c ring_num). The unused game
// parameter keeps the nameit prfunc signature.
func ringNum(_ *RogueGame, obj *Object) string {
if !obj.Flags.Has(Known) {
return ""
}
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingProtection, RingAddStrength, RingIncreaseDamage, RingDexterity:
return fmt.Sprintf(" [%s]", num(obj.Bonus, 0, Ring))
}
return ""
}

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

@@ -1,77 +1,73 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
"fmt"
"os"
"time"
)
// rip.c — the fun ends: death or a total win.
//
// The C functions here call exit(); the port panics with a gameEnd sentinel
// that Run recovers, so the terminal is restored by normal unwinding.
// The C functions here call exit(). One game run is one process, so the
// port does the same: myExit restores the terminal and exits directly.
// gameEnd is the sentinel carried by the panic that replaces my_exit().
type gameEnd struct{ status int }
// myExit leaves the process properly (main.c my_exit): it unwinds to Run.
func (g *RogueGame) myExit(st int) {
g.Playing = false
panic(gameEnd{status: st})
}
var ripArt = []string{
" __________",
" / \\",
" / REST \\",
" / IN \\",
" / PEACE \\",
" / \\",
" | |",
" | |",
" | killed by a |",
" | |",
" | 1980 |",
" *| * * * | *",
" ________)/\\\\_//(\\/(/\\)/\\//\\/|_)_______",
// myExit leaves the process properly (main.c my_exit): it restores the
// terminal and ends the process. Every C caller exited with status 0.
func (g *RogueGame) myExit() {
g.scr.Fini()
os.Exit(0)
}
// death does something really fun when he dies (rip.c death).
func (g *RogueGame) death(monst byte) {
p := &g.Player
p.Purse -= p.Purse / 10
g.clear()
killer := g.killname(monst, false)
if !g.Options.Tombstone {
g.scr.Std.MvPrintw(NumLines-2, 0, "Killed by ")
g.scr.Std.MvPrintwf(NumLines-2, 0, "Killed by ")
if monst != 's' && monst != 'h' {
g.printw("a%s ", vowelstr(killer))
}
g.printw("%s with %d gold", killer, p.Purse)
} else {
year := time.Now().Year()
for i, line := range ripArt {
for i, line := range g.data.ripArt {
g.scr.Std.MvAddStr(8+i, 0, line)
}
g.mvaddstr(17, center(killer), killer)
if monst == 's' || monst == 'h' {
g.mvaddch(16, 32, ' ')
} else {
g.mvaddstr(16, 33, vowelstr(killer))
}
g.mvaddstr(14, center(g.Whoami), g.Whoami)
au := fmt.Sprintf("%d Au", p.Purse)
g.mvaddstr(15, center(au), au)
g.mvaddstr(18, 26, fmt.Sprintf("%4d", year))
}
g.mvaddstr(NumLines-1, 0, "[Press return to continue]")
g.refresh()
flags := 0
if g.HasAmulet {
flags = 3
}
g.score(p.Purse, flags, monst)
g.waitFor('\n')
g.myExit(0)
g.myExit()
}
// center returns the column to center the given string on the tombstone
@@ -85,6 +81,7 @@ func (g *RogueGame) totalWinner() {
p := &g.Player
g.clear()
g.standout()
banner := []string{
" ",
" @ @ @ @ @ @@@ @ @ ",
@@ -100,9 +97,12 @@ func (g *RogueGame) totalWinner() {
for i, line := range banner {
g.scr.Std.MvAddStr(i, 0, line)
}
g.standend()
g.scr.Std.MvAddStr(10, 0, "You have joined the elite ranks of those who have escaped the")
g.scr.Std.MvAddStr(11, 0, "Dungeons of Doom alive. You journey home and sell all your loot at")
g.scr.Std.MvAddStr(10, 0,
"You have joined the elite ranks of those who have escaped the")
g.scr.Std.MvAddStr(11, 0,
"Dungeons of Doom alive. You journey home and sell all your loot at")
g.scr.Std.MvAddStr(12, 0, "a great profit and are admitted to the Fighters' Guild.")
g.mvaddstr(NumLines-1, 0, "--Press space to continue--")
g.refresh()
@@ -110,127 +110,167 @@ func (g *RogueGame) totalWinner() {
g.clear()
g.mvaddstr(0, 0, " Worth Item")
g.move(1, 0)
oldpurse := p.Purse
line := 1
for _, obj := range p.Pack {
worth := 0
it := &g.Items
switch obj.Kind {
case KindFood:
worth = 2 * obj.Count
case KindWeapon:
worth = it.Weapons[obj.Which].Worth
worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count
obj.Flags.Set(Known)
case KindArmor:
worth = it.Armors[obj.Which].Worth
worth += (9 - obj.ArmorClass) * 100
worth += 10 * (aClass[obj.Which] - obj.ArmorClass)
obj.Flags.Set(Known)
case KindScroll:
op := &it.Scrolls[obj.Which]
worth = op.Worth * obj.Count
if !op.Know {
worth /= 2
}
op.Know = true
case KindPotion:
op := &it.Potions[obj.Which]
worth = op.Worth * obj.Count
if !op.Know {
worth /= 2
}
op.Know = true
case KindRing:
op := &it.Rings[obj.Which]
worth = op.Worth
if obj.RingKind() == RingAddStrength || obj.RingKind() == RingIncreaseDamage ||
obj.RingKind() == RingProtection || obj.RingKind() == RingDexterity {
if obj.Bonus > 0 {
worth += obj.Bonus * 100
} else {
worth = 10
}
}
if !obj.Flags.Has(Known) {
worth /= 2
}
obj.Flags.Set(Known)
op.Know = true
case KindWand:
op := &it.Sticks[obj.Which]
worth = op.Worth
worth += 20 * obj.Charges
if !obj.Flags.Has(Known) {
worth /= 2
}
obj.Flags.Set(Known)
op.Know = true
case KindAmulet:
worth = 1000
}
if worth < 0 {
worth = 0
}
g.scr.Std.MvPrintw(line, 0, "%c) %5d %s", obj.PackCh, worth,
g.invName(obj, false))
worth := g.objectWorth(obj)
g.scr.Std.MvPrintwf(line, 0, "%c) %5d %s", obj.PackCh, worth,
g.inventoryName(obj, false))
line++
p.Purse += worth
}
g.scr.Std.MvPrintw(line, 0, " %5d Gold Pieces ", oldpurse)
g.scr.Std.MvPrintwf(line, 0, " %5d Gold Pieces ", oldpurse)
g.refresh()
g.score(p.Purse, 2, ' ')
g.myExit(0)
g.myExit()
}
// killnameTable is the rip.c nlist[]: special death causes.
var killnameTable = []helpEntry{
{'a', "arrow", true},
{'b', "bolt", true},
{'d', "dart", true},
{'h', "hypothermia", false},
{'s', "starvation", false},
// objectWorth appraises one pack item on the way out, marking it known
// (the switch of rip.c total_winner).
func (g *RogueGame) objectWorth(obj *Object) int {
// Same defense as inventoryName: most arms below appraise from a
// per-kind table at obj.Which, so a malformed item is worth nothing
// rather than a panic on the way to the scoreboard.
if !obj.hasValidWhich() {
return 0
}
it := &g.Items
worth := 0
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindFood:
worth = 2 * obj.Count
case KindWeapon:
worth = it.Weapons[obj.Which].Worth
worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count
obj.Flags.Set(Known)
case KindArmor:
worth = it.Armors[obj.Which].Worth
worth += (9 - obj.ArmorClass) * 100
worth += 10 * (g.data.armorClass(obj.Which) - obj.ArmorClass)
obj.Flags.Set(Known)
case KindScroll:
worth = loreWorth(&it.Scrolls[obj.Which], obj.Count)
case KindPotion:
worth = loreWorth(&it.Potions[obj.Which], obj.Count)
case KindRing:
worth = g.ringWorth(obj)
case KindWand:
worth = g.wandWorth(obj)
case KindAmulet:
worth = 1000
}
if worth < 0 {
worth = 0
}
return worth
}
// loreWorth appraises a scroll or potion, halved when unidentified, and
// identifies it (rip.c total_winner).
func loreWorth(op *ObjInfo, count int) int {
worth := op.Worth * count
if !op.Know {
worth /= 2
}
op.Know = true
return worth
}
// ringWorth appraises a ring: bonus rings gain by their bonus, cursed
// ones are junk (rip.c total_winner).
func (g *RogueGame) ringWorth(obj *Object) int {
op := &g.Items.Rings[obj.Which]
worth := op.Worth
if obj.RingKind() == RingAddStrength || obj.RingKind() == RingIncreaseDamage ||
obj.RingKind() == RingProtection || obj.RingKind() == RingDexterity {
if obj.Bonus > 0 {
worth += obj.Bonus * 100
} else {
worth = 10
}
}
if !obj.Flags.Has(Known) {
worth /= 2
}
obj.Flags.Set(Known)
op.Know = true
return worth
}
// wandWorth appraises a wand or staff by its charges (rip.c
// total_winner).
func (g *RogueGame) wandWorth(obj *Object) int {
op := &g.Items.Sticks[obj.Which]
worth := op.Worth
worth += 20 * obj.Charges
if !obj.Flags.Has(Known) {
worth /= 2
}
obj.Flags.Set(Known)
op.Know = true
return worth
}
// killname converts a code to a monster name (rip.c killname).
func (g *RogueGame) killname(monst byte, doart bool) string {
var sp string
var article bool
var (
sp string
article bool
)
if isUpper(monst) {
sp = g.Monsters[monst-'A'].Name
article = true
} else {
sp = "Wally the Wonder Badger"
article = false
for _, hp := range killnameTable {
for _, hp := range g.data.killnameTable {
if hp.Ch == monst {
sp = hp.Desc
article = hp.Print
break
}
}
}
if doart && article {
return "a" + vowelstr(sp) + " " + sp
}
return sp
}
// DeathDemo implements the -d command line option (main.c): burn some
// random numbers to break patterns, then die a random death.
// random numbers to break patterns, then die a random death. It does not
// return — death exits the process.
func (g *RogueGame) DeathDemo() {
defer func() {
if r := recover(); r != nil {
if _, ok := r.(gameEnd); ok {
return
}
panic(r)
}
}()
dnum := g.rnd(100)
for dnum--; dnum > 0; dnum-- {
g.rnd(100)
}
g.Player.Purse = g.rnd(100) + 1
g.Depth = g.rnd(100) + 1
g.death(g.deathMonst())
@@ -245,5 +285,6 @@ func (g *RogueGame) deathMonst() byte {
'Y', 'Z', 'a', 'b', 'h', 'd', 's',
' ', // generates the "Wally the Wonder Badger" message
}
return poss[g.rnd(len(poss))]
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// Rng is the original Rogue linear congruential generator. The C RN macro is
@@ -11,21 +12,17 @@ type Rng struct {
Seed int32
}
// next steps the generator and returns the next raw value (the RN macro).
func (r *Rng) next() int {
r.Seed = r.Seed*11109 + 13849
return int(r.Seed>>16) & 0xffff
}
// Rnd picks a very random number in [0, rng) (main.c rnd).
func (r *Rng) Rnd(rng int) int {
if rng == 0 {
return 0
}
v := r.next()
if v < 0 {
v = -v
}
return v % rng
}
@@ -35,9 +32,17 @@ func (r *Rng) Roll(number, sides int) int {
for ; number > 0; number-- {
dtotal += r.Rnd(sides) + 1
}
return dtotal
}
// next steps the generator and returns the next raw value (the RN macro).
func (r *Rng) next() int {
r.Seed = r.Seed*11109 + 13849
return int(r.Seed>>16) & 0xffff
}
// rnd is the ported code's spelling of C rnd(): every call site in the C
// sources reads rnd(x), and keeping that shape makes cross-checking easy.
func (g *RogueGame) rnd(rng int) int { return g.Rng.Rnd(rng) }

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
@@ -6,6 +7,8 @@ import "testing"
// (seed = seed*11109+13849; rnd(range) = abs(RN) % range) compiled and run
// on this machine. They lock in seed compatibility with the C game.
func TestRndMatchesCImplementation(t *testing.T) {
t.Parallel()
golden := map[int32][]int{
1: {0, 30, 79, 7, 87, 1, 23, 7, 57, 98},
12345: {92, 92, 45, 98, 24, 39, 92, 67, 3, 7},
@@ -23,6 +26,8 @@ func TestRndMatchesCImplementation(t *testing.T) {
}
func TestRollMatchesCImplementation(t *testing.T) {
t.Parallel()
golden := map[int32][]int{
1: {6, 8, 10},
12345: {10, 10, 15},
@@ -42,10 +47,13 @@ func TestRollMatchesCImplementation(t *testing.T) {
// rnd(0) must return 0 without stepping the generator: the C macro
// short-circuits before evaluating RN, and BEFORE/AFTER depend on it.
func TestRndZeroDoesNotStep(t *testing.T) {
t.Parallel()
r := &Rng{Seed: 42}
if got := r.Rnd(0); got != 0 {
t.Fatalf("rnd(0) = %d, want 0", got)
}
if r.Seed != 42 {
t.Fatalf("rnd(0) stepped the generator: seed = %d, want 42", r.Seed)
}
@@ -54,10 +62,13 @@ func TestRndZeroDoesNotStep(t *testing.T) {
// spread(1)==1 and spread(2)==2 deterministically; the C BEFORE/AFTER
// constants rely on this.
func TestSpreadSmallValues(t *testing.T) {
t.Parallel()
g := &RogueGame{Rng: &Rng{Seed: 7}}
if got := g.spread(1); got != 1 {
t.Errorf("spread(1) = %d, want 1", got)
}
if got := g.spread(2); got != 2 {
t.Errorf("spread(2) = %d, want 2", got)
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// rooms.c — create the layout for the new level.
@@ -17,10 +18,11 @@ type mazeState struct {
const goldGrp = 1
// doRooms creates rooms and corridors with a connectivity graph (rooms.c
// digRooms creates rooms and corridors with a connectivity graph (rooms.c
// do_rooms).
func (g *RogueGame) doRooms() {
func (g *RogueGame) digRooms() {
var bsze Coord // maximum room size
bsze.X = NumCols / 3
bsze.Y = NumLines / 3
// Clear things for a new level
@@ -32,92 +34,136 @@ func (g *RogueGame) doRooms() {
}
// Put the gone rooms, if any, on the level
leftOut := g.rnd(4)
for i := 0; i < leftOut; i++ {
g.Level.Rooms[g.rndRoom()].Flags.Set(Gone)
for range leftOut {
g.Level.Rooms[g.randomRoom()].Flags.Set(Gone)
}
// dig and populate all the rooms on the level
for i := range g.Level.Rooms {
rp := &g.Level.Rooms[i]
// Find upper left corner of box that this room goes in
top := Coord{X: (i%3)*bsze.X + 1, Y: (i / 3) * bsze.Y}
if rp.Flags.Has(Gone) {
// Place a gone room. Make certain that there is a blank line
// for passage drawing.
for {
rp.Pos.X = top.X + g.rnd(bsze.X-2) + 1
rp.Pos.Y = top.Y + g.rnd(bsze.Y-2) + 1
rp.Max.X = -NumCols
rp.Max.Y = -NumLines
if rp.Pos.Y > 0 && rp.Pos.Y < NumLines-1 {
break
}
}
continue
g.digRoom(i, bsze)
}
}
// digRoom digs and populates one room (the loop body of rooms.c
// do_rooms).
func (g *RogueGame) digRoom(i int, bsze Coord) {
rp := &g.Level.Rooms[i]
// Find upper left corner of box that this room goes in
top := Coord{X: (i%3)*bsze.X + 1, Y: (i / 3) * bsze.Y}
if rp.Flags.Has(Gone) {
g.placeGoneRoom(rp, top, bsze)
return
}
// set room type
if g.rnd(10) < g.Depth-1 {
rp.Flags.Set(Dark) // dark room
if g.rnd(15) == 0 {
rp.Flags = Maze // maze room
}
// set room type
if g.rnd(10) < g.Depth-1 {
rp.Flags.Set(Dark) // dark room
if g.rnd(15) == 0 {
rp.Flags = Maze // maze room
}
}
// Find a place and size for a random room
if rp.Flags.Has(Maze) {
placeMazeRoom(rp, top, bsze)
} else {
g.placeNormalRoom(rp, top, bsze)
}
g.drawRoom(rp)
g.roomGold(rp)
g.roomMonster(rp)
}
// placeGoneRoom places a gone room, making certain that there is a
// blank line for passage drawing (rooms.c do_rooms).
func (g *RogueGame) placeGoneRoom(rp *Room, top, bsze Coord) {
for {
rp.Pos.X = top.X + g.rnd(bsze.X-2) + 1
rp.Pos.Y = top.Y + g.rnd(bsze.Y-2) + 1
rp.Max.X = -NumCols
rp.Max.Y = -NumLines
if rp.Pos.Y > 0 && rp.Pos.Y < NumLines-1 {
return
}
// Find a place and size for a random room
if rp.Flags.Has(Maze) {
rp.Max.X = bsze.X - 1
rp.Max.Y = bsze.Y - 1
if rp.Pos.X = top.X; rp.Pos.X == 1 {
rp.Pos.X = 0
}
if rp.Pos.Y = top.Y; rp.Pos.Y == 0 {
rp.Pos.Y++
rp.Max.Y--
}
} else {
for {
rp.Max.X = g.rnd(bsze.X-4) + 4
rp.Max.Y = g.rnd(bsze.Y-4) + 4
rp.Pos.X = top.X + g.rnd(bsze.X-rp.Max.X)
rp.Pos.Y = top.Y + g.rnd(bsze.Y-rp.Max.Y)
if rp.Pos.Y != 0 {
break
}
}
}
g.drawRoom(rp)
// Put the gold in
if g.rnd(2) == 0 && (!g.HasAmulet || g.Depth >= g.MaxDepth) {
gold := newObject()
rp.GoldVal = g.goldCalc()
gold.GoldValue = rp.GoldVal
rp.Gold, _ = g.findFloorIn(rp, 0, false)
gold.Pos = rp.Gold
g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold)
gold.Flags = Stackable
gold.Group = goldGrp
gold.Kind = KindGold
attachObj(&g.Level.Objects, gold)
}
// Put the monster in
prob := 25
if rp.GoldVal > 0 {
prob = 80
}
if g.rnd(100) < prob {
tp := &Monster{}
mp, _ := g.findFloorIn(rp, 0, true)
g.newMonster(tp, g.randMonster(false), mp)
g.givePack(tp)
}
}
// placeMazeRoom sizes a maze room to fill its box (rooms.c do_rooms).
func placeMazeRoom(rp *Room, top, bsze Coord) {
rp.Max.X = bsze.X - 1
rp.Max.Y = bsze.Y - 1
if rp.Pos.X = top.X; rp.Pos.X == 1 {
rp.Pos.X = 0
}
if rp.Pos.Y = top.Y; rp.Pos.Y == 0 {
rp.Pos.Y++
rp.Max.Y--
}
}
// placeNormalRoom rolls a place and size for an ordinary room (rooms.c
// do_rooms).
func (g *RogueGame) placeNormalRoom(rp *Room, top, bsze Coord) {
for {
rp.Max.X = g.rnd(bsze.X-4) + 4
rp.Max.Y = g.rnd(bsze.Y-4) + 4
rp.Pos.X = top.X + g.rnd(bsze.X-rp.Max.X)
rp.Pos.Y = top.Y + g.rnd(bsze.Y-rp.Max.Y)
if rp.Pos.Y != 0 {
return
}
}
}
// roomGold maybe puts a gold pile in the room (rooms.c do_rooms).
func (g *RogueGame) roomGold(rp *Room) {
if g.rnd(2) != 0 || (g.HasAmulet && g.Depth < g.MaxDepth) {
return
}
gold := newObject()
rp.GoldVal = g.goldCalc()
gold.GoldValue = rp.GoldVal
rp.Gold, _ = g.findFloorIn(rp, 0, false)
gold.Pos = rp.Gold
g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold)
gold.Flags = Stackable
gold.Group = goldGrp
gold.Kind = KindGold
g.Level.AddObject(gold)
}
// roomMonster maybe puts a monster in the room; gold attracts them
// (rooms.c do_rooms).
func (g *RogueGame) roomMonster(rp *Room) {
prob := 25
if rp.GoldVal > 0 {
prob = 80
}
if g.rnd(100) < prob {
tp := &Monster{}
mp, _ := g.findFloorIn(rp, 0, true)
g.newMonster(tp, g.randMonster(false), mp)
g.givePack(tp)
}
}
// drawRoom draws a box around a room and lays down the floor for normal
// rooms; for maze rooms, draws the maze (rooms.c draw_room).
func (g *RogueGame) drawRoom(rp *Room) {
if rp.Flags.Has(Maze) {
g.doMaze(rp)
g.digMaze(rp)
return
}
g.vert(rp, rp.Pos.X) // Draw left side
g.vert(rp, rp.Pos.X+rp.Max.X-1) // Draw right side
g.horiz(rp, rp.Pos.Y) // Draw top
@@ -145,8 +191,8 @@ func (g *RogueGame) horiz(rp *Room, starty int) {
}
}
// doMaze digs a maze (rooms.c do_maze).
func (g *RogueGame) doMaze(rp *Room) {
// digMaze digs a maze (rooms.c do_maze).
func (g *RogueGame) digMaze(rp *Room) {
m := &g.maze
for y := range m.maze {
for x := range m.maze[y] {
@@ -162,65 +208,84 @@ func (g *RogueGame) doMaze(rp *Room) {
starty := (g.rnd(rp.Max.Y) / 2) * 2
startx := (g.rnd(rp.Max.X) / 2) * 2
pos := Coord{Y: starty + m.starty, X: startx + m.startx}
g.putpass(pos)
g.putPassage(pos)
g.dig(starty, startx)
}
// dig digs out from around where we are now, if possible (rooms.c dig).
func (g *RogueGame) dig(y, x int) {
m := &g.maze
del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}}
for {
cnt := 0
var nexty, nextx int
for _, cp := range del {
newy := y + cp.Y
newx := x + cp.X
if newy < 0 || newy > m.maxy || newx < 0 || newx > m.maxx {
continue
}
if g.Level.FlagsAt(newy+m.starty, newx+m.startx).Has(FPassage) {
continue
}
if cnt++; g.rnd(cnt) == 0 {
nexty = newy
nextx = newx
}
}
if cnt == 0 {
nexty, nextx, ok := g.digPick(y, x)
if !ok {
return
}
g.accntMaze(y, x, nexty, nextx)
g.accntMaze(nexty, nextx, y, x)
var pos Coord
if nexty == y {
pos.Y = y + m.starty
if nextx-x < 0 {
pos.X = nextx + m.startx + 1
} else {
pos.X = nextx + m.startx - 1
}
} else {
pos.X = x + m.startx
if nexty-y < 0 {
pos.Y = nexty + m.starty + 1
} else {
pos.Y = nexty + m.starty - 1
}
}
g.putpass(pos)
pos.Y = nexty + m.starty
pos.X = nextx + m.startx
g.putpass(pos)
g.accountMaze(y, x, nexty, nextx)
g.accountMaze(nexty, nextx, y, x)
g.putPassage(digWallGap(m, y, x, nexty, nextx))
g.putPassage(Coord{Y: nexty + m.starty, X: nextx + m.startx})
g.dig(nexty, nextx)
}
}
// accntMaze accounts for maze exits (rooms.c accnt_maze).
func (g *RogueGame) accntMaze(y, x, ny, nx int) {
// digPick reservoir-picks the next unvisited maze cell; ok is false
// when the digger is boxed in (the candidate scan of rooms.c dig).
func (g *RogueGame) digPick(y, x int) (int, int, bool) {
m := &g.maze
del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}}
cnt := 0
var nexty, nextx int
for _, cp := range del {
newy := y + cp.Y
newx := x + cp.X
if newy < 0 || newy > m.maxy || newx < 0 || newx > m.maxx {
continue
}
if g.Level.FlagsAt(newy+m.starty, newx+m.startx).Has(FPassage) {
continue
}
if cnt++; g.rnd(cnt) == 0 {
nexty = newy
nextx = newx
}
}
return nexty, nextx, cnt != 0
}
// digWallGap picks the wall square to knock out between two maze cells
// (rooms.c dig).
func digWallGap(m *mazeState, y, x, nexty, nextx int) Coord {
if nexty == y {
pos := Coord{Y: y + m.starty, X: nextx + m.startx - 1}
if nextx-x < 0 {
pos.X = nextx + m.startx + 1
}
return pos
}
pos := Coord{X: x + m.startx, Y: nexty + m.starty - 1}
if nexty-y < 0 {
pos.Y = nexty + m.starty + 1
}
return pos
}
// accountMaze accounts for maze exits (rooms.c accnt_maze).
func (g *RogueGame) accountMaze(y, x, ny, nx int) {
sp := &g.maze.maze[y][x]
for i := 0; i < sp.nexits; i++ {
for i := range sp.nexits {
if sp.exits[i].Y == ny && sp.exits[i].X == nx {
return
}
@@ -233,18 +298,21 @@ func (g *RogueGame) accntMaze(y, x, ny, nx int) {
}
}
// rndPos picks a random spot in a room (rooms.c rnd_pos).
func (g *RogueGame) rndPos(rp *Room) Coord {
// randomPos picks a random spot in a room (rooms.c rnd_pos).
func (g *RogueGame) randomPos(rp *Room) Coord {
var cp Coord
cp.X = rp.Pos.X + g.rnd(rp.Max.X-2) + 1
cp.Y = rp.Pos.Y + g.rnd(rp.Max.Y-2) + 1
return cp
}
// findFloor finds a valid floor spot, picking a new random room each time
// around the loop (rooms.c find_floor with rp == NULL).
func (g *RogueGame) findFloor(rp *Room, limit int, monst bool) (Coord, bool) {
return g.findFloorImpl(rp, limit, monst, rp == nil)
// around the loop; it retries forever (rooms.c find_floor with rp == NULL
// — every such C call site passed FALSE for the limit).
func (g *RogueGame) findFloor(monst bool) (Coord, bool) {
return g.findFloorImpl(nil, 0, monst, true)
}
// findFloorIn finds a valid floor spot in this room (rooms.c find_floor
@@ -253,14 +321,24 @@ func (g *RogueGame) findFloorIn(rp *Room, limit int, monst bool) (Coord, bool) {
return g.findFloorImpl(rp, limit, monst, false)
}
func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Coord, bool) {
// floorChar is what an empty spot looks like in a room: passage in a
// maze, floor otherwise (rooms.c find_floor).
func floorChar(rp *Room) byte {
if rp.Flags.Has(Maze) {
return Passage
}
return Floor
}
func (g *RogueGame) findFloorImpl(
rp *Room, limit int, monst, pickroom bool,
) (Coord, bool) {
var compchar byte
if !pickroom {
compchar = Floor
if rp.Flags.Has(Maze) {
compchar = Passage
}
compchar = floorChar(rp)
}
cnt := limit
for {
if limit != 0 {
@@ -268,14 +346,14 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co
return Coord{}, false
}
}
if pickroom {
rp = &g.Level.Rooms[g.rndRoom()]
compchar = Floor
if rp.Flags.Has(Maze) {
compchar = Passage
}
rp = &g.Level.Rooms[g.randomRoom()]
compchar = floorChar(rp)
}
cp := g.rndPos(rp)
cp := g.randomPos(rp)
pp := g.Level.At(cp.Y, cp.X)
if monst {
if pp.Monst == nil && stepOk(pp.Ch) {
@@ -291,40 +369,53 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co
// enter_room).
func (g *RogueGame) enterRoom(cp Coord) {
p := &g.Player
rp := g.roomin(cp)
rp := g.roomIn(cp)
p.Room = rp
g.doorOpen(rp)
if !rp.Flags.Has(Dark) && !p.On(Blind) {
for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ {
g.move(y, rp.Pos.X)
for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ {
tp := g.Level.MonsterAt(y, x)
ch := g.Level.Char(y, x)
if tp == nil {
if g.inch() != ch {
g.addch(ch)
} else {
g.move(y, x+1)
}
} else {
tp.OldCh = ch
if !g.seeMonst(tp) {
if p.On(SenseMonsters) {
g.standout()
g.addch(tp.Disguise)
g.standend()
} else {
g.addch(ch)
}
} else {
g.addch(tp.Disguise)
}
}
g.enterRoomCell(y, x)
}
}
}
}
// enterRoomCell draws one square of a room being lit on entry (the loop
// body of rooms.c enter_room).
func (g *RogueGame) enterRoomCell(y, x int) {
tp := g.Level.MonsterAt(y, x)
ch := g.Level.Char(y, x)
if tp == nil {
if g.inch() != ch {
g.addch(ch)
} else {
g.move(y, x+1)
}
return
}
tp.OldCh = ch
if g.seeMonst(tp) {
g.addch(tp.Disguise)
return
}
if g.Player.On(SenseMonsters) {
g.standout()
g.addch(tp.Disguise)
g.standend()
} else {
g.addch(ch)
}
}
// leaveRoom is the code for when we exit a room (rooms.c leave_room).
func (g *RogueGame) leaveRoom(cp Coord) {
p := &g.Player
@@ -335,6 +426,7 @@ func (g *RogueGame) leaveRoom(cp Coord) {
}
var floor byte
switch {
case rp.Flags.Has(Gone):
floor = Passage
@@ -347,31 +439,43 @@ func (g *RogueGame) leaveRoom(cp Coord) {
p.Room = &g.Level.Passages[*g.Level.FlagsAt(cp.Y, cp.X)&FPassNum]
for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ {
for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ {
g.move(y, x)
switch ch := g.inch(); ch {
case Floor:
if floor == ' ' {
g.addch(' ')
}
default:
// to check for monster, we have to strip out the standout
// bit (our Window returns the bare character already)
if isUpper(ch) {
if p.On(SenseMonsters) {
g.standout()
g.addch(ch)
g.standend()
break
}
pp := g.Level.At(y, x)
if pp.Ch == Door {
g.addch(Door)
} else {
g.addch(floor)
}
}
}
g.leaveRoomCell(floor, y, x)
}
}
g.doorOpen(rp)
}
// leaveRoomCell hides one square of a room being left (the loop body of
// rooms.c leave_room).
func (g *RogueGame) leaveRoomCell(floor byte, y, x int) {
g.move(y, x)
switch ch := g.inch(); ch {
case Floor:
if floor == ' ' {
g.addch(' ')
}
default:
// to check for monster, we have to strip out the standout
// bit (our Window returns the bare character already)
if !isUpper(ch) {
return
}
if g.Player.On(SenseMonsters) {
g.standout()
g.addch(ch)
g.standend()
return
}
pp := g.Level.At(y, x)
if pp.Ch == Door {
g.addch(Door)
} else {
g.addch(floor)
}
}
}

View File

@@ -1,104 +1,211 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"path/filepath"
"strings"
"testing"
)
// TestRunScriptedSession drives a complete game through Run(): a few
// moves, a rest, an inventory, then Q-quit answered yes.
func TestRunScriptedSession(t *testing.T) {
tt := &testTerm{input: []byte("hjkl.i Qy")}
g := NewGame(Config{Seed: 99, Term: tt})
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
}
if g.Playing {
t.Error("still playing after quit")
}
// After quitting, the scoreboard is the last thing shown (in C it went
// to stdout after endwin; here it is drawn on the screen).
found := false
for y := 0; y < NumLines; y++ {
if strings.Contains(g.scr.Std.Line(y), "Top Ten") {
found = true
}
}
if !found {
t.Error("score list not on screen after quit")
// fortify makes the hero effectively immortal for a crash-sweep drive:
// game-over now calls myExit and os.Exit(0) (step 8), which would kill the
// test binary, so every death vector is neutralized. Re-applied each turn
// because combat, digestion, freezing, and level drain chip away at these.
func fortify(g *RogueGame) {
p := &g.Player
p.Stats.HP = 30000 // survive combat, arrow/dart traps, bolts
p.Stats.MaxHP = 30000 // survive vampire max-hp drain
p.Stats.Exp = 30000 // survive wraith level drain (death when exp hits 0)
p.FoodLeft = 30000 // never starve
g.NoCommand = 0 // never freeze to death (ice monster / sleep trap)
g.NoMove = 0 // never stay stuck in a bear trap
}
// driveTurns runs the game's per-turn loop up to n times, doing the same
// first-level and pre-play setup Run() does. Run() itself no longer
// returns — game-over exits the process — so tests drive command()
// directly, with short scripts that avoid quitting, saving, or playing
// long enough to starve, any of which would exit the test binary.
func driveTurns(t *testing.T, g *RogueGame, n int) {
t.Helper()
g.startLevel()
g.prePlay()
for range n {
g.command()
}
}
// TestRunManyTurns mashes movement keys for a while as a crash sweep of
// the whole turn loop (daemons, hunger, monsters, combat), ending with a
// quit. The input alternates directions so the hero bumps around rooms.
func TestRunManyTurns(t *testing.T) {
// Spaces between commands double as answers to any --More-- prompts;
// without them a single prompt would swallow the rest of the script
// (wait_for eats everything that isn't a space).
var script []byte
moves := []byte("h h j j k k l l y u b n s s . . ")
for range 200 {
script = append(script, moves...)
}
script = append(script, " Q y Qy"...)
tt := &testTerm{input: script}
g := NewGame(Config{Seed: 31337, Term: tt})
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
}
if g.Playing {
t.Error("session did not end")
}
}
// TestRunDownStairs walks the hero onto the stairs by teleporting there in
// wizard style, then descends and keeps playing.
// TestRunDownStairs stands the hero on the staircase and descends via the
// '>' command through the real turn loop, then checks the level changed.
func TestRunDownStairs(t *testing.T) {
tt := &testTerm{input: []byte(">..Qy")}
g := NewGame(Config{Seed: 7, Term: tt})
t.Parallel()
// '>' is a free action (After=false), so it is followed by a paying
// rest ('.') to end the command() call; without a paying action the
// turn loop would spin forever on the auto-fed prompt input.
tt := &testTerm{input: []byte(">.")}
g := New(Params{Seed: 7, Term: tt})
g.NewLevel()
g.Player.Pos = g.Level.Stairs // stand on the stairs
g.restored = true // keep Run from regenerating the level
g.restored = true // keep startLevel from regenerating
g.Daemons = DaemonList{} // and give it a fresh daemon table
g.StartDaemon(DRunners, 0, After)
g.StartDaemon(DDoctor, 0, After)
g.Fuse(DSwander, 0, wanderTime(g), After)
g.StartDaemon(DStomach, 0, After)
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
}
driveTurns(t, g, 1)
if g.Depth != 2 {
t.Errorf("depth = %d after descending, want 2", g.Depth)
}
}
// TestSaveCommandRoundTrip saves via the 'S' command (as a player would)
// and restores the game.
func TestSaveCommandRoundTrip(t *testing.T) {
// The C get_str caps input at MAXINP=50 characters, so the save path
// must be short: work from the temp directory.
t.Chdir(t.TempDir())
path := "cmd.save"
// 'S' with no default file name goes straight to the name prompt.
script := "S" + path + "\n"
tt := &testTerm{input: []byte(script)}
g := NewGame(Config{Seed: 55, Term: tt})
g.FileName = "" // force the name prompt
if err := g.Run(); err != nil {
t.Fatalf("Run: %v", err)
// TestScoreRendersList checks that the scoreboard is drawn on the screen
// (in C it went to stdout after endwin; here it stays on the screen). The
// quit and death paths that normally show it now exit the process, so the
// display is exercised through score() directly.
func TestScoreRendersList(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 1, Term: &testTerm{}})
g.Player.Purse = 100
g.score(g.Player.Purse, 1, 0) // flags 1 = quit; posts the top-ten list
found := false
for y := range NumLines {
if strings.Contains(g.scr.Std.Line(y), "Top Ten") {
found = true
}
}
h, err := Restore(path, Config{Term: &testTerm{}})
if !found {
t.Error("score list not on screen")
}
}
// TestDeepPlaythrough drives a fortified hero through the real command loop:
// quaff/read/zap on the first level, then descend through the staircase to
// depth 8, saving and restoring mid-way. It is a crash sweep of the turn
// engine, deep level generation, item effects, and mid-game save/restore.
// The hero is fortified so no death exits the process (step 8), and the fixed
// seed keeps it deterministic.
func TestDeepPlaythrough(t *testing.T) {
t.Parallel()
g := New(Params{Seed: 4242, Wizard: true, Term: &testTerm{}})
g.startLevel()
g.prePlay()
fortify(g)
// Stock and use one of each consumable through the command dispatch.
pot := give(g, &Object{Kind: KindPotion, Which: int(PotionHealing)})
scr := give(g, &Object{Kind: KindScroll, Which: int(ScrollMagicMapping)})
wand := newObject()
wand.Kind = KindWand
wand.Which = int(WandLight)
wand.Charges = 5
zap := give(g, wand)
setInput(t, g, 'q', pot) // quaff healing
g.command()
setInput(t, g, 'r', scr) // read magic mapping
g.command()
setInput(t, g, 'z', 'h', zap) // zap the light wand west
g.command()
fortify(g)
// Each consumable identifies itself on use, confirming the q/r/z
// commands actually ran through dispatch (not aborted on a bad prompt).
if !g.Items.Potions[PotionHealing].Know {
t.Error("quaff command did not identify the healing potion")
}
if !g.Items.Scrolls[ScrollMagicMapping].Know {
t.Error("read command did not identify the magic-mapping scroll")
}
if !g.Items.Sticks[WandLight].Know {
t.Error("zap command did not identify the light wand")
}
const wantDepth = 8
for g.Depth < wantDepth {
g.Player.Pos = g.Level.Stairs // stand on the stairs
setInput(t, g, '>', '.') // '>' descends (free), '.' pays the turn
g.command()
fortify(g)
if g.Depth == 4 {
g = saveAndRestore(t, g)
fortify(g)
}
}
if g.Depth != wantDepth {
t.Errorf("depth = %d after descending, want %d", g.Depth, wantDepth)
}
if g.Player.Stats.HP <= 0 {
t.Error("hero died during the playthrough")
}
}
// TestTurnLoopCrashSweep mashes movement, search, and rest through the real
// turn loop for many turns on several seeds, exercising combat, monster AI,
// and traps. The hero is fortified each turn so nothing exits the process,
// and the fixed seeds keep it deterministic; the point is to surface panics.
func TestTurnLoopCrashSweep(t *testing.T) {
t.Parallel()
// A generous mix of movement, search, and rest. The spaces between
// commands double as answers to any --More-- prompt (wait_for eats
// everything up to a space); without them one prompt would swallow the
// rest of the script. The script is long enough that the bounded drive
// never exhausts it (which would spin on the auto-fed prompt input).
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
for _, seed := range []int32{1, 99, 2026, 31337} {
g := New(Params{Seed: seed, Term: &testTerm{input: script}})
g.startLevel()
g.prePlay()
for range 200 {
fortify(g)
g.command()
}
if g.Player.Stats.HP <= 0 {
t.Errorf("seed %d: hero died despite fortify", seed)
}
}
}
// saveAndRestore snapshots the game to a file, restores it, checks the key
// state survived, and returns the restored game ready to keep playing.
func saveAndRestore(t *testing.T, g *RogueGame) *RogueGame {
t.Helper()
path := filepath.Join(t.TempDir(), "deep.save")
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
h, err := Restore(path, Params{Wizard: true, Term: &testTerm{}})
if err != nil {
t.Fatalf("Restore: %v", err)
}
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
t.Error("restored game does not match saved game")
}
// The restored game must be playable.
h.scr.term.(*testTerm).input = []byte("..Qy")
if err := h.Run(); err != nil {
t.Fatalf("restored Run: %v", err)
t.Errorf("restored game diverged: depth %d/%d purse %d/%d",
h.Depth, g.Depth, h.Player.Purse, g.Player.Purse)
}
return h
}

View File

@@ -1,9 +1,13 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
"encoding/gob"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
@@ -131,16 +135,19 @@ func (g *RogueGame) roomIdx(rp *Room) int {
if rp == nil {
return -1
}
for i := range g.Level.Rooms {
if rp == &g.Level.Rooms[i] {
return i
}
}
for i := range g.Level.Passages {
if rp == &g.Level.Passages[i] {
return 100 + i
}
}
return -1
}
@@ -152,6 +159,7 @@ func (g *RogueGame) roomAt(i int) *Room {
case i >= 0:
return &g.Level.Rooms[i]
}
return nil
}
@@ -160,18 +168,60 @@ func (g *RogueGame) packIdx(obj *Object) int {
if obj == nil {
return -1
}
for i, o := range g.Player.Pack {
if o == obj {
return i
}
}
return -1
}
// snapshot captures the complete game state.
func (g *RogueGame) snapshot() *SaveState {
p := &g.Player
st := &SaveState{
st := g.snapshotHeader()
// the map, sans monster pointers (rebuilt on load)
st.Places = make([]savedPlace, len(g.Level.Places))
for i := range g.Level.Places {
st.Places[i] = savedPlace{
Ch: g.Level.Places[i].Ch,
Flags: g.Level.Places[i].Flags,
}
}
// level objects by value; remember their pointers for dest encoding
objAt := make(map[*Object]int, len(g.Level.Objects))
for i, o := range g.Level.Objects {
st.Objects = append(st.Objects, *o)
objAt[o] = i
}
st.Player = g.snapshotPlayer()
// monsters, with chase targets as (kind, index) references
for _, m := range g.Level.Monsters {
sc := savedCreature{
Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise,
OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats,
RoomIdx: g.roomIdx(m.Room),
}
for _, o := range m.Pack {
sc.Pack = append(sc.Pack, *o)
}
st.Monsters = append(st.Monsters, sc)
st.Dests = append(st.Dests, g.destRefFor(m, objAt))
}
return st
}
// snapshotHeader captures the scalar game state (the field list of
// state.c rs_save_file).
func (g *RogueGame) snapshotHeader() *SaveState {
return &SaveState{
Version: saveFormatVersion,
Seed: g.Rng.Seed,
Dnum: g.Dnum,
@@ -219,25 +269,14 @@ func (g *RogueGame) snapshot() *SaveState {
AllScore: g.AllScore,
Screen: g.scr.Std.Contents(),
}
}
// the map, sans monster pointers (rebuilt on load)
st.Places = make([]savedPlace, len(g.Level.Places))
for i := range g.Level.Places {
st.Places[i] = savedPlace{
Ch: g.Level.Places[i].Ch,
Flags: g.Level.Places[i].Flags,
}
}
// snapshotPlayer captures the player, equipment as pack indices (the
// player half of snapshot).
func (g *RogueGame) snapshotPlayer() savedPlayer {
p := &g.Player
// level objects by value; remember their pointers for dest encoding
objAt := make(map[*Object]int, len(g.Level.Objects))
for i, o := range g.Level.Objects {
st.Objects = append(st.Objects, *o)
objAt[o] = i
}
// the player
st.Player = savedPlayer{
sp := savedPlayer{
Body: savedCreature{
Pos: p.Pos, Turn: p.Turn, Type: p.Type, Disguise: p.Disguise,
OldCh: p.OldCh, Flags: p.Flags, Stats: p.Stats,
@@ -253,55 +292,72 @@ func (g *RogueGame) snapshot() *SaveState {
MaxStats: p.MaxStats, VfHit: p.VfHit,
}
for _, o := range p.Pack {
st.Player.Body.Pack = append(st.Player.Body.Pack, *o)
sp.Body.Pack = append(sp.Body.Pack, *o)
}
// monsters, with chase targets as (kind, index) references
for _, m := range g.Level.Monsters {
sc := savedCreature{
Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise,
OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats,
RoomIdx: g.roomIdx(m.Room),
}
for _, o := range m.Pack {
sc.Pack = append(sc.Pack, *o)
}
st.Monsters = append(st.Monsters, sc)
return sp
}
ref := destRef{}
switch {
case m.Dest == nil:
case m.Dest == &p.Pos:
ref = destRef{Kind: 1}
default:
for mi, om := range g.Level.Monsters {
if m.Dest == &om.Pos {
ref = destRef{Kind: 2, Idx: mi}
}
}
if ref.Kind == 0 {
for _, oo := range g.Level.Objects {
if m.Dest == &oo.Pos {
ref = destRef{Kind: 3, Idx: objAt[oo]}
}
}
}
if ref.Kind == 0 {
for ri := range g.Level.Rooms {
if m.Dest == &g.Level.Rooms[ri].Gold {
ref = destRef{Kind: 4, Idx: ri}
}
}
}
}
st.Dests = append(st.Dests, ref)
// destRefFor encodes a monster's chase target as a (kind, index)
// reference: the hero, another monster, a level object, or room gold
// (state.c rs_write_thing).
func (g *RogueGame) destRefFor(m *Monster, objAt map[*Object]int) destRef {
switch {
case m.Dest == nil:
return destRef{}
case m.Dest == &g.Player.Pos:
return destRef{Kind: 1}
}
return st
for mi, om := range g.Level.Monsters {
if m.Dest == &om.Pos {
return destRef{Kind: 2, Idx: mi}
}
}
for _, oo := range g.Level.Objects {
if m.Dest == &oo.Pos {
return destRef{Kind: 3, Idx: objAt[oo]}
}
}
for ri := range g.Level.Rooms {
if m.Dest == &g.Level.Rooms[ri].Gold {
return destRef{Kind: 4, Idx: ri}
}
}
return destRef{}
}
// applySnapshot rebuilds live game state from a snapshot.
func (g *RogueGame) applySnapshot(st *SaveState) {
p := &g.Player
g.applyHeader(st)
for i := range g.Level.Places {
g.Level.Places[i] = Place{
Ch: st.Places[i].Ch,
Flags: st.Places[i].Flags,
}
}
// level objects
g.Level.Objects = nil
for i := range st.Objects {
o := st.Objects[i]
g.Level.Objects = append(g.Level.Objects, &o)
}
g.applyPlayer(st)
g.applyMonsters(st)
g.applyDests(st)
g.scr.Std.SetContents(st.Screen)
}
// applyHeader restores the scalar game state (the field list of
// applySnapshot).
func (g *RogueGame) applyHeader(st *SaveState) {
g.Rng.Seed = st.Seed
g.Dnum = st.Dnum
g.Whoami = st.Whoami
@@ -316,6 +372,12 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
g.Level.Passages = st.Passages
g.Level.Stairs = st.Stairs
g.Level.TrapCount = st.TrapCount
g.applyTurnState(st)
}
// applyTurnState restores the in-turn command state (the second half of
// applyHeader).
func (g *RogueGame) applyTurnState(st *SaveState) {
g.After = st.After
g.Again = st.Again
g.NoScore = st.NoScoreF
@@ -346,22 +408,12 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
g.LastScore = st.LastScore
g.AllScore = st.AllScore
g.Playing = true
}
for i := range g.Level.Places {
g.Level.Places[i] = Place{
Ch: st.Places[i].Ch,
Flags: st.Places[i].Flags,
}
}
// level objects
g.Level.Objects = nil
for i := range st.Objects {
o := st.Objects[i]
g.Level.Objects = append(g.Level.Objects, &o)
}
// the player
// applyPlayer restores the player, resolving equipment pack indices
// (the player half of applySnapshot).
func (g *RogueGame) applyPlayer(st *SaveState) {
p := &g.Player
sp := &st.Player
p.Pos = sp.Body.Pos
p.Turn = sp.Body.Turn
@@ -371,15 +423,18 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
p.Flags = sp.Body.Flags
p.Stats = sp.Body.Stats
p.Room = g.roomAt(sp.Body.RoomIdx)
p.Pack = nil
for i := range sp.Body.Pack {
o := sp.Body.Pack[i]
p.Pack = append(p.Pack, &o)
}
pick := func(i int) *Object {
if i < 0 || i >= len(p.Pack) {
return nil
}
return p.Pack[i]
}
p.CurArmor = pick(sp.CurArmor)
@@ -395,11 +450,15 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
p.NoFood = sp.NoFood
p.MaxStats = sp.MaxStats
p.VfHit = sp.VfHit
}
// monsters, their map index, and their chase targets
// applyMonsters rebuilds the monster list and its map index from a
// snapshot (the monster half of applySnapshot).
func (g *RogueGame) applyMonsters(st *SaveState) {
g.Level.Monsters = nil
for i := range st.Monsters {
sc := &st.Monsters[i]
m := &Monster{Creature: Creature{
Pos: sc.Pos, Turn: sc.Turn, Type: sc.Type, Disguise: sc.Disguise,
OldCh: sc.OldCh, Flags: sc.Flags, Stats: sc.Stats,
@@ -409,14 +468,21 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
o := sc.Pack[j]
m.Pack = append(m.Pack, &o)
}
g.Level.Monsters = append(g.Level.Monsters, m)
g.Level.SetMonsterAt(m.Pos.Y, m.Pos.X, m)
}
}
// applyDests re-aims the monsters' chase targets from their (kind,
// index) references (the fixup half of applySnapshot).
func (g *RogueGame) applyDests(st *SaveState) {
for i, ref := range st.Dests {
m := g.Level.Monsters[i]
switch ref.Kind {
case 1:
m.Dest = &p.Pos
m.Dest = &g.Player.Pos
case 2:
m.Dest = &g.Level.Monsters[ref.Idx].Pos
case 3:
@@ -425,136 +491,444 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
m.Dest = &g.Level.Rooms[ref.Idx].Gold
}
}
g.scr.Std.SetContents(st.Screen)
}
// saveGame implements the "save game" command (save.c save_game). The C
// goto over/gotfile flow becomes the useDefault flag.
// saveAnswer is a yes/no/escape prompt result in the save-game flow.
type saveAnswer int
// The saveGame prompt outcomes.
const (
saveYes saveAnswer = iota
saveNo
saveAbort
)
// saveGame implements the "save game" command (save.c save_game). The
// labeled prompt loop stands in for the C goto over/gotfile flow.
func (g *RogueGame) saveGame() {
g.Msgs.Mpos = 0
over:
useDefault := false
if g.FileName != "" {
var c byte
for {
g.msg("save file (%s)? ", g.FileName)
c = g.readchar()
g.Msgs.Mpos = 0
if c == Escape {
g.msg("")
prompt:
for {
useDefault := false
if g.FileName != "" {
a := g.askDefaultSave()
if a == saveAbort {
return
}
if c == 'n' || c == 'N' || c == 'y' || c == 'Y' {
break
}
g.msg("please answer Y or N")
useDefault = a == saveYes
}
if c == 'y' || c == 'Y' {
g.addstr("Yes\n")
g.refresh()
useDefault = true
for {
buf, ok := g.saveFileName(useDefault)
if !ok {
return
}
useDefault = false
a := g.saveCheckOverwrite(buf)
if a == saveAbort {
return
}
if a == saveNo {
continue prompt // the C goto over: start again
}
g.FileName = buf
err := g.saveFile(g.FileName)
if err != nil {
g.msg("%s", err.Error())
continue
}
break prompt
}
}
g.myExit()
}
// askDefaultSave asks whether to save to the current file name (save.c
// save_game).
func (g *RogueGame) askDefaultSave() saveAnswer {
for {
var buf string
if useDefault {
buf = g.FileName
useDefault = false
} else {
g.Msgs.Mpos = 0
g.msg("file name: ")
if g.getStr(&buf, g.scr.Std) == Quit {
g.msg("")
return
}
g.Msgs.Mpos = 0
g.msg("save file (%s)? ", g.FileName)
c := g.readchar()
g.Msgs.Mpos = 0
switch c {
case Escape:
g.msg("")
return saveAbort
case 'y', 'Y':
g.addstr("Yes\n")
g.refresh()
return saveYes
case 'n', 'N':
return saveNo
}
// test to see if the file exists
if _, err := os.Stat(buf); err == nil {
for {
g.msg("File exists. Do you wish to overwrite it?")
g.Msgs.Mpos = 0
c := g.readchar()
if c == Escape {
g.msg("")
return
}
if c == 'y' || c == 'Y' {
break
}
if c == 'n' || c == 'N' {
goto over
}
g.msg("Please answer Y or N")
}
g.msg("file name: %s", buf)
os.Remove(g.FileName)
}
g.FileName = buf
if err := g.saveFile(g.FileName); err != nil {
g.msg("%s", err.Error())
continue
}
break
g.msg("please answer Y or N")
}
}
// saveFileName picks the save path: the default, or a prompted one; ok
// is false when the player quit the prompt (save.c save_game).
func (g *RogueGame) saveFileName(useDefault bool) (string, bool) {
if useDefault {
return g.FileName, true
}
g.Msgs.Mpos = 0
g.msg("file name: ")
buf := ""
if g.getStr(&buf, g.scr.Std) == Quit {
g.msg("")
return "", false
}
g.Msgs.Mpos = 0
return buf, true
}
// saveCheckOverwrite guards an existing file: saveNo restarts the whole
// prompt, saveAbort quits (save.c save_game).
func (g *RogueGame) saveCheckOverwrite(buf string) saveAnswer {
// test to see if the file exists
_, statErr := os.Stat(buf)
if statErr != nil {
return saveYes
}
answer := g.askOverwrite()
if answer != saveYes {
return answer
}
g.msg("file name: %s", buf)
_ = os.Remove(g.FileName) // best effort, as in C (md_unlink)
return saveYes
}
// askOverwrite asks whether to overwrite the existing file (save.c
// save_game).
func (g *RogueGame) askOverwrite() saveAnswer {
for {
g.msg("File exists. Do you wish to overwrite it?")
g.Msgs.Mpos = 0
switch g.readchar() {
case Escape:
g.msg("")
return saveAbort
case 'y', 'Y':
return saveYes
case 'n', 'N':
return saveNo
}
g.msg("Please answer Y or N")
}
g.myExit(0)
}
// saveFile writes the saved game (save.c save_file).
//
// 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 {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400)
f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
if err != nil {
return err
}
defer f.Close()
if err := gob.NewEncoder(f).Encode(g.snapshot()); err != nil {
os.Remove(path)
return err
tmp := f.Name()
writeErr := writeSnapshotFile(f, g.snapshot())
if writeErr != nil {
_ = os.Remove(tmp) // never leave a half-written file behind
return writeErr
}
return os.Chmod(path, 0o400)
renErr := os.Rename(tmp, path)
if renErr != nil {
_ = os.Remove(tmp)
return renErr
}
return nil
}
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
// (save.c auto_save).
func (g *RogueGame) AutoSave() {
if g.FileName != "" {
os.Remove(g.FileName)
g.saveFile(g.FileName)
// 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 {
return encErr
}
return closeErr
}
// autoSaveRequest is one signal-triggered autosave in flight: the signal
// goroutine posts it and waits, the game goroutine performs the save and
// closes done. ok is written before done is closed and read only after,
// so the close is the happens-before edge that publishes it.
type autoSaveRequest struct {
done chan struct{}
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.
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
// ErrSaveCorrupt reports a save file whose contents are structurally
// impossible for a game this code could have written.
var ErrSaveCorrupt = errors.New("sorry, saved game is corrupt")
// validateSnapshotObjects rejects a snapshot carrying an item whose Which
// would index past the end of its kind's tables. The file is written by
// this program, so such a value can only come from corruption or
// tampering; refusing it at the door is what keeps a malformed object
// from reaching the effect tables in doZap, quaff, and readScroll, where
// the wizard-create bug (issue #10) used to put one.
func validateSnapshotObjects(st *SaveState) error {
lists := make([][]Object, 0, 2+len(st.Monsters))
lists = append(lists, st.Objects, st.Player.Body.Pack)
for i := range st.Monsters {
lists = append(lists, st.Monsters[i].Pack)
}
for _, list := range lists {
for i := range list {
obj := &list[i]
if !obj.hasValidWhich() {
return fmt.Errorf("%w: %s has out-of-range which %d",
ErrSaveCorrupt, obj.Kind, obj.Which)
}
}
}
return nil
}
// Restore restores a saved game from a file (save.c restore). The file is
// deleted, as in C, to defeat restarting from the same save.
func Restore(path string, cfg Config) (*RogueGame, error) {
f, err := os.Open(path)
func Restore(path string, params Params) (*RogueGame, error) {
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
if err != nil {
return nil, err
}
defer f.Close()
defer func() { _ = f.Close() }() // read-only handle
var st SaveState
if err := gob.NewDecoder(f).Decode(&st); err != nil {
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w", path, err)
decErr := gob.NewDecoder(f).Decode(&st)
if decErr != nil {
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w",
path, decErr)
}
if st.Version != saveFormatVersion {
return nil, fmt.Errorf("sorry, saved game is out of date")
return nil, ErrSaveOutOfDate
}
valErr := validateSnapshotObjects(&st)
if valErr != nil {
return nil, valErr
}
g := &RogueGame{
data: newGameData(),
Rng: &Rng{},
Playing: true,
ScorePath: cfg.ScorePath,
ScorePath: params.ScorePath,
FileName: path,
rogueOpts: cfg.RogueOpts,
rogueOpts: params.RogueOpts,
restored: true,
sigSave: make(chan *autoSaveRequest, 1),
}
g.scr = NewScreen(cfg.Term)
g.scr = NewScreen(params.Term)
g.Msgs.attach(g.scr, g.look, g.readchar)
g.applySnapshot(&st)
// defeat multiple restarting from the same place
if err := os.Remove(path); err != nil {
return nil, fmt.Errorf("cannot unlink file: %w", err)
rmErr := os.Remove(path)
if rmErr != nil {
return nil, fmt.Errorf("cannot unlink file: %w", rmErr)
}
return g, nil
}

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
@@ -8,6 +9,8 @@ import (
)
func TestSaveRestoreRoundTrip(t *testing.T) {
t.Parallel()
g := mkGame(t, 4242)
// Dirty up some state so the round trip is meaningful.
g.Player.Purse = 123
@@ -15,6 +18,7 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
g.HasAmulet = true
g.Items.Potions[PotionHealing].Know = true
g.Items.Scrolls[ScrollMagicMapping].Guess = "map???"
g.Monsters['F'-'A'].Stats.Dmg = dice("3x1") // mutated bestiary must survive
if len(g.Level.Monsters) > 0 {
g.Level.Monsters[0].Flags.Set(Awake)
@@ -22,91 +26,141 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
}
path := filepath.Join(t.TempDir(), "rogue.save")
if err := g.saveFile(path); err != nil {
t.Fatalf("saveFile: %v", err)
saveErr := g.saveFile(path)
if saveErr != nil {
t.Fatalf("saveFile: %v", saveErr)
}
h, err := Restore(path, Config{Term: &testTerm{}})
h, err := Restore(path, Params{Term: &testTerm{}})
if err != nil {
t.Fatalf("Restore: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
_, statErr := os.Stat(path)
if !os.IsNotExist(statErr) {
t.Error("save file not deleted on restore (C anti-restart rule)")
}
checkRestoredState(t, g, h)
checkRestoredMonsters(t, g, h)
checkEquipmentAliasing(t, g, h)
}
// checkRestoredState verifies the scalar state survived the round trip.
func checkRestoredState(t *testing.T, g, h *RogueGame) {
t.Helper()
if h.Player.Purse != 123 || h.Player.FoodLeft != 777 {
t.Errorf("player state lost: purse=%d food=%d",
h.Player.Purse, h.Player.FoodLeft)
}
if !h.HasAmulet {
t.Error("amulet flag lost")
}
if !h.Items.Potions[PotionHealing].Know {
t.Error("potion identification lost")
}
if h.Items.Scrolls[ScrollMagicMapping].Guess != "map???" {
t.Error("scroll guess lost")
}
if h.Monsters['F'-'A'].Stats.Dmg.String() != "3x1" {
t.Error("mutated bestiary lost")
}
if h.Rng.Seed != g.Rng.Seed {
t.Error("RNG state lost")
}
if renderMap(h) != renderMap(g) {
t.Error("restored level map differs")
}
}
// checkRestoredMonsters verifies the monster list and its pointer fixups
// survived the round trip.
func checkRestoredMonsters(t *testing.T, g, h *RogueGame) {
t.Helper()
if len(h.Level.Monsters) != len(g.Level.Monsters) {
t.Fatalf("monster count %d != %d",
len(h.Level.Monsters), len(g.Level.Monsters))
}
if len(g.Level.Monsters) > 0 {
m := h.Level.Monsters[0]
if m.Dest != &h.Player.Pos {
t.Error("monster chase target not re-aliased to the hero")
}
if h.Level.MonsterAt(m.Pos.Y, m.Pos.X) != m {
t.Error("map monster index not rebuilt")
}
if m.Room == nil {
t.Error("monster room pointer not rebuilt")
}
if len(g.Level.Monsters) == 0 {
return
}
// Equipment aliasing: the wielded mace must be the same *Object as the
// one in the pack.
m := h.Level.Monsters[0]
if m.Dest != &h.Player.Pos {
t.Error("monster chase target not re-aliased to the hero")
}
if h.Level.MonsterAt(m.Pos.Y, m.Pos.X) != m {
t.Error("map monster index not rebuilt")
}
if m.Room == nil {
t.Error("monster room pointer not rebuilt")
}
}
// checkEquipmentAliasing verifies the wielded mace is the same *Object as
// the one in the pack.
func checkEquipmentAliasing(t *testing.T, g, h *RogueGame) {
t.Helper()
st := g.snapshot()
t.Logf("snapshot indices: weapon=%d armor=%d rings=%v packlen=%d",
st.Player.CurWeapon, st.Player.CurArmor, st.Player.CurRing,
len(st.Player.Body.Pack))
t.Logf("restored: CurWeapon=%p pack has %d items", h.Player.CurWeapon,
len(h.Player.Pack))
found := false
for i, o := range h.Player.Pack {
t.Logf(" pack[%d]=%p type=%v which=%d", i, o, o.Kind, o.Which)
if o == h.Player.CurWeapon {
found = true
}
}
if !found {
t.Error("restored CurWeapon is not aliased into the pack")
}
}
func TestRestoreRejectsWrongVersion(t *testing.T) {
t.Parallel()
g := mkGame(t, 1)
path := filepath.Join(t.TempDir(), "rogue.save")
st := g.snapshot()
st.Version = "0.0.0"
f, err := os.Create(path)
f, err := os.Create(path) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatal(err)
}
if err := gob.NewEncoder(f).Encode(st); err != nil {
t.Fatal(err)
encErr := gob.NewEncoder(f).Encode(st)
if encErr != nil {
t.Fatal(encErr)
}
f.Close()
if _, err := Restore(path, Config{}); err == nil {
closeErr := f.Close()
if closeErr != nil {
t.Fatal(closeErr)
}
_, restoreErr := Restore(path, Params{})
if restoreErr == nil {
t.Error("restore accepted an out-of-date save")
}
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
@@ -25,29 +26,29 @@ type ScoreEnt struct {
Time int64
}
var scoreReasons = [4]string{
"killed",
"quit",
"A total winner",
"killed with Amulet",
}
// rdScore reads the scoreboard file (save.c rd_score).
func (g *RogueGame) rdScore() []ScoreEnt {
topTen := make([]ScoreEnt, numScores)
if g.ScorePath == "" {
return topTen
}
f, err := os.Open(g.ScorePath)
if err != nil {
return topTen
}
defer f.Close()
defer func() { _ = f.Close() }() // read-only handle
var onDisk []ScoreEnt
if err := gob.NewDecoder(f).Decode(&onDisk); err != nil {
return topTen
decErr := gob.NewDecoder(f).Decode(&onDisk)
if decErr != nil {
return topTen // unreadable scoreboard reads as empty, as in C
}
copy(topTen, onDisk)
return topTen
}
@@ -57,29 +58,45 @@ func (g *RogueGame) wrScore(topTen []ScoreEnt) {
return
}
// lock_sc/unlock_sc: exclusive-create lock file with stale takeover.
// The whole scoreboard write is best effort, as it was in C: a shared
// scoreboard must never take the game down.
lock := g.ScorePath + ".lck"
for range 5 {
lf, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
lf, err := os.OpenFile(lock, //nolint:gosec // G304: configured path
os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
lf.Close()
defer os.Remove(lock)
_ = lf.Close()
defer func() { _ = os.Remove(lock) }()
break
}
if fi, serr := os.Stat(lock); serr == nil &&
time.Since(fi.ModTime()) > 10*time.Second {
os.Remove(lock)
fi, statErr := os.Stat(lock)
if statErr == nil && time.Since(fi.ModTime()) > staleLockAge {
_ = os.Remove(lock)
continue
}
time.Sleep(time.Second)
}
f, err := os.OpenFile(g.ScorePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
f, err := os.OpenFile(g.ScorePath,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
gob.NewEncoder(f).Encode(topTen)
_ = gob.NewEncoder(f).Encode(topTen)
_ = f.Close()
}
// staleLockAge is how old a scoreboard lock file may be before another
// process assumes its owner died and takes it over (mach_dep.c lock_sc
// aged its lock the same way).
const staleLockAge = 10 * time.Second
// score figures the score and posts it (rip.c score). flags -1 means just
// display the list (the -s command line option).
func (g *RogueGame) score(amount, flags int, monst byte) {
@@ -93,94 +110,11 @@ func (g *RogueGame) score(amount, flags int, monst byte) {
// Insert her in list if need be
ins := -1
if !g.NoScore && flags >= 0 {
uid := os.Getuid()
scp := len(topTen)
for i := range topTen {
if amount > topTen[i].Score {
scp = i
break
} else if !g.AllScore && flags != 2 &&
topTen[i].UID == uid && topTen[i].Flags != 2 {
// only one score per nowin uid
scp = len(topTen)
break
}
}
if scp < len(topTen) {
sc2 := len(topTen) - 1
if flags != 2 && !g.AllScore {
for i := scp; i < len(topTen); i++ {
if topTen[i].UID == uid && topTen[i].Flags != 2 {
sc2 = i
break
}
}
}
for sc2 > scp {
topTen[sc2] = topTen[sc2-1]
sc2--
}
lvl := g.Depth
if flags == 2 {
lvl = g.MaxDepth
}
topTen[scp] = ScoreEnt{
UID: uid,
Score: amount,
Flags: flags,
Monster: monst,
Name: g.Whoami,
Level: lvl,
Time: time.Now().Unix(),
}
ins = scp
}
ins = g.scoreInsert(topTen, amount, flags, monst)
}
// Build the list display
label := "Rogueists"
if g.AllScore {
label = "Scores"
}
lines := []string{
fmt.Sprintf("Top Ten %s:", label),
" Score Name",
}
highlight := -1
for i := range topTen {
scp := &topTen[i]
if scp.Score == 0 {
break
}
line := fmt.Sprintf("%2d %5d %s: %s on level %d", i+1,
scp.Score, scp.Name, scoreReasons[scp.Flags], scp.Level)
if scp.Flags == 0 || scp.Flags == 3 {
line += fmt.Sprintf(" by %s", g.killname(scp.Monster, true))
}
line += "."
if i == ins {
highlight = len(lines)
}
lines = append(lines, line)
}
if g.scr != nil && g.scr.term != nil {
g.clear()
for i, line := range lines {
if i == highlight {
g.standout()
}
g.mvaddstr(i, 0, line)
if i == highlight {
g.standend()
}
}
g.refresh()
} else {
for _, line := range lines {
fmt.Println(line)
}
}
lines, highlight := g.scoreLines(topTen, ins)
g.showScores(lines, highlight)
// Update the list file
if ins >= 0 {
@@ -188,6 +122,136 @@ func (g *RogueGame) score(amount, flags int, monst byte) {
}
}
// scoreInsert slots the new score into the top ten, honoring the
// one-score-per-losing-uid rule; -1 means it did not place (the
// insertion half of rip.c score).
func (g *RogueGame) scoreInsert(topTen []ScoreEnt, amount, flags int, monst byte) int {
uid := os.Getuid()
scp := g.scoreSlot(topTen, amount, flags, uid)
if scp >= len(topTen) {
return -1
}
sc2 := len(topTen) - 1
if flags != 2 && !g.AllScore {
for i := scp; i < len(topTen); i++ {
if topTen[i].UID == uid && topTen[i].Flags != 2 {
sc2 = i
break
}
}
}
for sc2 > scp {
topTen[sc2] = topTen[sc2-1]
sc2--
}
lvl := g.Depth
if flags == 2 {
lvl = g.MaxDepth
}
topTen[scp] = ScoreEnt{
UID: uid,
Score: amount,
Flags: flags,
Monster: monst,
Name: g.Whoami,
Level: lvl,
Time: time.Now().Unix(),
}
return scp
}
// scoreSlot finds where the new score lands: len(topTen) when it does
// not place, or when this uid already holds a losing score (the scan of
// rip.c score).
func (g *RogueGame) scoreSlot(topTen []ScoreEnt, amount, flags, uid int) int {
for i := range topTen {
if amount > topTen[i].Score {
return i
}
if !g.AllScore && flags != 2 &&
topTen[i].UID == uid && topTen[i].Flags != 2 {
// only one score per nowin uid
return len(topTen)
}
}
return len(topTen)
}
// scoreLines formats the scoreboard, noting which display line holds
// the freshly inserted score (the display half of rip.c score).
func (g *RogueGame) scoreLines(topTen []ScoreEnt, ins int) ([]string, int) {
label := "Rogueists"
if g.AllScore {
label = "Scores"
}
lines := []string{
fmt.Sprintf("Top Ten %s:", label),
" Score Name",
}
highlight := -1
for i := range topTen {
scp := &topTen[i]
if scp.Score == 0 {
break
}
line := fmt.Sprintf("%2d %5d %s: %s on level %d", i+1,
scp.Score, scp.Name, g.data.scoreReasons[scp.Flags], scp.Level)
if scp.Flags == 0 || scp.Flags == 3 {
line += " by " + g.killname(scp.Monster, true)
}
line += "."
if i == ins {
highlight = len(lines)
}
lines = append(lines, line)
}
return lines, highlight
}
// showScores prints the scoreboard on the screen when there is one,
// else to standard output (rip.c score).
func (g *RogueGame) showScores(lines []string, highlight int) {
if g.scr == nil || g.scr.term == nil {
for _, line := range lines {
_, _ = fmt.Fprintln(os.Stdout, line) // CLI output
}
return
}
g.clear()
for i, line := range lines {
if i == highlight {
g.standout()
}
g.mvaddstr(i, 0, line)
if i == highlight {
g.standend()
}
}
g.refresh()
}
// ShowScores implements the -s command line option: print the scoreboard
// and nothing else.
func (g *RogueGame) ShowScores() {

View File

@@ -14,8 +14,27 @@ type Terminal interface {
// Render blits the window to the device.
Render(w *Window)
// ReadChar blocks for the next key, translated to Rogue's input bytes
// (arrows become hjkl, control keys their C0 codes).
ReadChar() byte
// (arrows become hjkl, control keys their C0 codes). ok is false when
// 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
// game calls it on its way out, since one game run is one process.
Fini()
}
// cell is one screen position.
@@ -37,26 +56,28 @@ type Window struct {
func NewWindow(rows, cols int) *Window {
w := &Window{rows: rows, cols: cols, cells: make([]cell, rows*cols)}
w.Clear()
return w
}
func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] }
// Move positions the cursor (curses move/wmove).
func (w *Window) Move(y, x int) { w.cy, w.cx = y, x }
// GetYX reports the cursor position (curses getyx).
func (w *Window) GetYX() (y, x int) { return w.cy, w.cx }
func (w *Window) GetYX() (int, int) { return w.cy, w.cx }
// AddCh writes a character at the cursor and advances it (curses addch).
func (w *Window) AddCh(ch byte) {
if ch == '\n' {
w.cy, w.cx = w.cy+1, 0
return
}
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
return
}
*w.at(w.cy, w.cx) = cell{ch: ch, standout: w.standout}
if w.cx++; w.cx >= w.cols {
w.cx = 0
@@ -68,7 +89,7 @@ func (w *Window) AddCh(ch byte) {
// AddStr writes a string at the cursor (curses addstr).
func (w *Window) AddStr(s string) {
for i := 0; i < len(s); i++ {
for i := range len(s) {
w.AddCh(s[i])
}
}
@@ -85,15 +106,15 @@ func (w *Window) MvAddStr(y, x int, s string) {
w.AddStr(s)
}
// Printw writes formatted text at the cursor (curses printw).
func (w *Window) Printw(format string, a ...any) {
// Printwf writes formatted text at the cursor (curses printw).
func (w *Window) Printwf(format string, a ...any) {
w.AddStr(fmt.Sprintf(format, a...))
}
// MvPrintw moves then writes formatted text (curses mvprintw).
func (w *Window) MvPrintw(y, x int, format string, a ...any) {
// MvPrintwf moves then writes formatted text (curses mvprintw).
func (w *Window) MvPrintwf(y, x int, format string, a ...any) {
w.Move(y, x)
w.Printw(format, a...)
w.Printwf(format, a...)
}
// Inch returns the character under the cursor (curses inch, sans
@@ -102,12 +123,14 @@ func (w *Window) Inch() byte {
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
return ' '
}
return w.at(w.cy, w.cx).ch
}
// MvInch moves then reads (curses mvinch).
func (w *Window) MvInch(y, x int) byte {
w.Move(y, x)
return w.Inch()
}
@@ -120,6 +143,7 @@ func (w *Window) Clear() {
for i := range w.cells {
w.cells[i] = cell{ch: ' '}
}
w.cy, w.cx = 0, 0
}
@@ -128,6 +152,7 @@ func (w *Window) Clrtoeol() {
if w.cy < 0 || w.cy >= w.rows {
return
}
for x := w.cx; x < w.cols; x++ {
*w.at(w.cy, x) = cell{ch: ' '}
}
@@ -138,13 +163,14 @@ func (w *Window) CopyFrom(src *Window) {
copy(w.cells, src.cells)
}
// Size reports the window dimensions.
func (w *Window) Size() (rows, cols int) { return w.rows, w.cols }
// Size reports the window dimensions as rows, columns.
func (w *Window) Size() (int, int) { return w.rows, w.cols }
// CellAt reports the character and standout attribute at a position; used
// by Terminal implementations to render the window.
func (w *Window) CellAt(y, x int) (ch byte, standout bool) {
func (w *Window) CellAt(y, x int) (byte, bool) {
c := w.at(y, x)
return c.ch, c.standout
}
@@ -155,6 +181,7 @@ func (w *Window) Contents() []byte {
for i, c := range w.cells {
out[i] = c.ch
}
return out
}
@@ -171,12 +198,16 @@ func (w *Window) SetContents(data []byte) {
// victory screens.
func (w *Window) Line(y int) string {
buf := make([]byte, w.cols)
for x := 0; x < w.cols; x++ {
for x := range w.cols {
buf[x] = w.at(y, x).ch
}
return string(buf)
}
// at addresses the cell at (y, x) in the backing array.
func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] }
// Screen bundles the two windows the game draws on with the device that
// shows them.
type Screen struct {
@@ -201,6 +232,30 @@ func (s *Screen) Refresh() {
}
}
// Repaint forces the device to redraw everything it is showing, if there
// is a device (curses clearok(curscr, TRUE) + wrefresh(curscr)).
func (s *Screen) Repaint() {
if s.term != nil {
s.term.Repaint()
}
}
// Fini restores the terminal device, if there is one (curses endwin).
func (s *Screen) Fini() {
if s.term != nil {
s.term.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).
func (s *Screen) RefreshWin(w *Window) {
if s.term != nil {
@@ -217,7 +272,7 @@ func (g *RogueGame) mvaddch(y, x int, c byte) { g.scr.Std.MvAddCh(y, x, c) }
func (g *RogueGame) mvaddstr(y, x int, s string) {
g.scr.Std.MvAddStr(y, x, s)
}
func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printw(f, a...) }
func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printwf(f, a...) }
func (g *RogueGame) inch() byte { return g.scr.Std.Inch() }
func (g *RogueGame) mvinch(y, x int) byte { return g.scr.Std.MvInch(y, x) }
func (g *RogueGame) standout() { g.scr.Std.Standout(true) }
@@ -225,3 +280,4 @@ func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
func (g *RogueGame) clear() { g.scr.Std.Clear() }
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
func (g *RogueGame) refresh() { g.scr.Refresh() }
func (g *RogueGame) repaint() { g.scr.Repaint() }

View File

@@ -1,31 +1,25 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// scrolls.c — read a scroll and let it happen.
// idType maps identify scrolls to the kind of item they identify
// (scrolls.c static id_type).
var idType = [ScrollIdentifyRingOrStick + 1]ObjectKind{
ScrollIdentifyPotion: KindPotion,
ScrollIdentifyScroll: KindScroll,
ScrollIdentifyWeapon: KindWeapon,
ScrollIdentifyArmor: KindArmor,
ScrollIdentifyRingOrStick: KindRingOrStick,
}
// readScroll reads a scroll from the pack and does the appropriate thing
// (scrolls.c read_scroll).
func (g *RogueGame) readScroll() {
p := &g.Player
obj := g.getItem("read", KindScroll)
if obj == nil {
obj, ok := g.promptPackItem("read", KindScroll)
if !ok {
return
}
if obj.Kind != KindScroll {
if !g.Options.Terse {
g.msg("there is nothing on it to read")
} else {
g.msg("nothing to read")
}
return
}
// Calculate the effect it has on the poor guy.
@@ -35,223 +29,350 @@ func (g *RogueGame) readScroll() {
// Get rid of the thing
g.leavePack(obj, false, false)
switch obj.ScrollKind() {
case ScrollMonsterConfusion:
// Scroll of monster confusion. Give him that power.
p.Flags.Set(CanConfuse)
g.msg("your hands begin to glow %s", g.pickColor("red"))
case ScrollEnchantArmor:
if p.CurArmor != nil {
p.CurArmor.ArmorClass--
p.CurArmor.Flags.Clear(Cursed)
g.msg("your armor glows %s for a moment", g.pickColor("silver"))
}
case ScrollHoldMonster:
// Hold monster scroll. Stop all monsters within two spaces from
// chasing after the hero.
held := 0
for x := p.Pos.X - 2; x <= p.Pos.X+2; x++ {
if x < 0 || x >= NumCols {
continue
}
for y := p.Pos.Y - 2; y <= p.Pos.Y+2; y++ {
if y < 0 || y > NumLines-1 {
continue
}
if mp := g.Level.MonsterAt(y, x); mp != nil && mp.On(Awake) {
mp.Flags.Clear(Awake)
mp.Flags.Set(Held)
held++
}
}
}
if held > 0 {
g.addmsg("the monster")
if held > 1 {
g.addmsg("s around you")
}
g.addmsg(" freeze")
if held == 1 {
g.addmsg("s")
}
g.endmsg()
g.Items.Scrolls[ScrollHoldMonster].Know = true
} else {
g.msg("you feel a strange sense of loss")
}
case ScrollSleep:
// Scroll which makes you fall asleep
g.Items.Scrolls[ScrollSleep].Know = true
g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME
p.Flags.Clear(Awake)
g.msg("you fall asleep")
case ScrollCreateMonster:
// Create a monster: first look in a circle around him, next try
// his room, otherwise give up
i := 0
var mp Coord
for y := p.Pos.Y - 1; y <= p.Pos.Y+1; y++ {
for x := p.Pos.X - 1; x <= p.Pos.X+1; x++ {
// Don't put a monster on top of the player.
if y == p.Pos.Y && x == p.Pos.X {
continue
}
// Or anything else nasty
if ch := g.Level.VisibleChar(y, x); stepOk(ch) {
if ch == Scroll {
if fo := g.findObj(y, x); fo != nil && fo.ScrollKind() == ScrollScareMonster {
continue
}
}
if i++; g.rnd(i) == 0 {
mp = Coord{Y: y, X: x}
}
}
}
}
if i == 0 {
g.msg("you hear a faint cry of anguish in the distance")
} else {
tp := &Monster{}
g.newMonster(tp, g.randMonster(false), mp)
}
case ScrollIdentifyPotion, ScrollIdentifyScroll, ScrollIdentifyWeapon, ScrollIdentifyArmor, ScrollIdentifyRingOrStick:
// Identify, let him figure something out
g.Items.Scrolls[obj.Which].Know = true
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
g.whatis(true, idType[obj.ScrollKind()])
case ScrollMagicMapping:
// Scroll of magic mapping.
g.Items.Scrolls[ScrollMagicMapping].Know = true
g.msg("oh, now this scroll has a map on it")
// take all the things we want to keep hidden out of the window
for y := 1; y < NumLines-1; y++ {
for x := 0; x < NumCols; x++ {
pp := g.Level.At(y, x)
ch := pp.Ch
pass := false
switch ch {
case Door, Stairs:
case '-', '|':
if !pp.Flags.Has(FReal) {
ch = Door
pp.Ch = Door
pp.Flags.Set(FReal)
}
case ' ':
if pp.Flags.Has(FReal) {
// def: hidden things in walls stay hidden
if pp.Flags.Has(FPassage) {
pass = true
} else {
ch = ' '
}
} else {
pp.Flags.Set(FReal)
pp.Ch = Passage
pass = true
}
case Passage:
pass = true
case Floor:
if pp.Flags.Has(FReal) {
ch = ' '
} else {
ch = Trap
pp.Ch = Trap
pp.Flags.Set(FSeen | FReal)
}
default:
if pp.Flags.Has(FPassage) {
pass = true
} else {
ch = ' '
}
}
if pass {
if !pp.Flags.Has(FReal) {
pp.Ch = Passage
}
pp.Flags.Set(FSeen | FReal)
ch = Passage
}
if ch != ' ' {
if tp := pp.Monst; tp != nil {
tp.OldCh = ch
if !p.On(SenseMonsters) {
g.mvaddch(y, x, ch)
}
} else {
g.mvaddch(y, x, ch)
}
}
}
}
case ScrollFoodDetection:
// Food detection
found := false
g.scr.Hw.Clear()
for _, fo := range g.Level.Objects {
if fo.Kind == KindFood {
found = true
g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food)
}
}
if found {
g.Items.Scrolls[ScrollFoodDetection].Know = true
g.showWin("Your nose tingles and you smell food.--More--")
} else {
g.msg("your nose tingles")
}
case ScrollTeleportation:
// Scroll of teleportation: make him disappear and reappear
curRoom := p.Room
g.teleport()
if curRoom != p.Room {
g.Items.Scrolls[ScrollTeleportation].Know = true
}
case ScrollEnchantWeapon:
if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon {
g.msg("you feel a strange sense of loss")
} else {
p.CurWeapon.Flags.Clear(Cursed)
if g.rnd(2) == 0 {
p.CurWeapon.HPlus++
} else {
p.CurWeapon.DPlus++
}
g.msg("your %s glows %s for a moment",
g.Items.Weapons[p.CurWeapon.Which].Name, g.pickColor("blue"))
}
case ScrollScareMonster:
// Reading it is a mistake and produces laughter at her poor boo
// boo.
g.msg("you hear maniacal laughter in the distance")
case ScrollRemoveCurse:
uncurse(p.CurArmor)
uncurse(p.CurWeapon)
uncurse(p.CurRing[Left])
uncurse(p.CurRing[Right])
g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes",
"you feel as if somebody is watching over you"))
case ScrollAggravateMonsters:
// This scroll aggravates all the monsters on the current level
// and sets them running towards the hero
g.aggravate()
g.msg("you hear a high pitched humming noise")
case ScrollProtectArmor:
if p.CurArmor != nil {
p.CurArmor.Flags.Set(Protected)
g.msg("your armor is covered by a shimmering %s shield",
g.pickColor("gold"))
} else {
g.msg("you feel a strange sense of loss")
}
if h := g.data.readHandler(obj); h != nil {
h(g, obj)
}
g.look(true) // put the result of the scroll on the screen
g.status()
g.callIt(&g.Items.Scrolls[obj.Which])
// A malformed scroll has no lore entry to name (see quaff).
if obj.hasValidWhich() {
g.callIt(&g.Items.Scrolls[obj.Which])
}
}
// The per-scroll effect handlers, dispatched through
// gameData.readHandlers. Each is one case of the C read_scroll switch.
func (g *RogueGame) readMonsterConfusion(*Object) {
// Scroll of monster confusion. Give him that power.
g.Player.Flags.Set(CanConfuse)
g.msg("your hands begin to glow %s", g.pickColor("red"))
}
func (g *RogueGame) readEnchantArmor(*Object) {
p := &g.Player
if p.CurArmor != nil {
p.CurArmor.ArmorClass--
p.CurArmor.Flags.Clear(Cursed)
g.msg("your armor glows %s for a moment", g.pickColor("silver"))
}
}
func (g *RogueGame) readHoldMonster(*Object) {
// Hold monster scroll. Stop all monsters within two spaces from
// chasing after the hero.
held := g.holdMonstersNear()
if held > 0 {
g.addmsgf("the monster")
if held > 1 {
g.addmsgf("s around you")
}
g.addmsgf(" freeze")
if held == 1 {
g.addmsgf("s")
}
g.endmsg()
g.Items.Scrolls[ScrollHoldMonster].Know = true
} else {
g.msg("you feel a strange sense of loss")
}
}
// holdMonstersNear freezes every awake monster within two spaces of the
// hero and reports how many froze (the scan of the C S_HOLD case).
func (g *RogueGame) holdMonstersNear() int {
p := &g.Player
held := 0
for x := p.Pos.X - 2; x <= p.Pos.X+2; x++ {
if x < 0 || x >= NumCols {
continue
}
for y := p.Pos.Y - 2; y <= p.Pos.Y+2; y++ {
if y < 0 || y > NumLines-1 {
continue
}
if mp := g.Level.MonsterAt(y, x); mp != nil && mp.On(Awake) {
mp.Flags.Clear(Awake)
mp.Flags.Set(Held)
held++
}
}
}
return held
}
func (g *RogueGame) readSleep(*Object) {
// Scroll which makes you fall asleep
g.Items.Scrolls[ScrollSleep].Know = true
g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME
g.Player.Flags.Clear(Awake)
g.msg("you fall asleep")
}
func (g *RogueGame) readCreateMonster(*Object) {
// Create a monster: first look in a circle around him, next try
// his room, otherwise give up
mp, ok := g.createMonsterSpot()
if !ok {
g.msg("you hear a faint cry of anguish in the distance")
} else {
tp := &Monster{}
g.newMonster(tp, g.randMonster(false), mp)
}
}
// createMonsterSpot reservoir-samples a legal spot around the hero for a
// created monster; ok is false when every neighbor is blocked (the scan
// of the C S_CREATE case).
func (g *RogueGame) createMonsterSpot() (Coord, bool) {
p := &g.Player
i := 0
var mp Coord
for y := p.Pos.Y - 1; y <= p.Pos.Y+1; y++ {
for x := p.Pos.X - 1; x <= p.Pos.X+1; x++ {
// Don't put a monster on top of the player.
if y == p.Pos.Y && x == p.Pos.X {
continue
}
// Or anything else nasty
if ch := g.Level.VisibleChar(y, x); stepOk(ch) {
if ch == Scroll {
if fo := g.Level.ObjectAt(y, x); fo != nil &&
fo.ScrollKind() == ScrollScareMonster {
continue
}
}
if i++; g.rnd(i) == 0 {
mp = Coord{Y: y, X: x}
}
}
}
}
return mp, i != 0
}
func (g *RogueGame) readIdentify(obj *Object) {
// Identify, let him figure something out. idType is shorter than the
// scroll table keying it (it stops after the last identify scroll),
// so the filter lookup carries its own bound. That bound is not
// reachable today: readHandlers registers readIdentify only for the
// identify scrolls, all of which sit inside idType. It is kept as
// defense-in-depth against a future table resize.
g.Items.Scrolls[obj.Which].Know = true
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
g.whatis(true, g.data.identifyType(obj.ScrollKind()))
}
func (g *RogueGame) readMagicMapping(*Object) {
// Scroll of magic mapping.
g.Items.Scrolls[ScrollMagicMapping].Know = true
g.msg("oh, now this scroll has a map on it")
// take all the things we want to keep hidden out of the window
for y := 1; y < NumLines-1; y++ {
for x := range NumCols {
g.revealSpot(y, x)
}
}
}
// revealSpot uncovers one map cell for magic mapping and draws it (the
// loop body of the C SCR_MAP case).
func (g *RogueGame) revealSpot(y, x int) {
pp := g.Level.At(y, x)
ch := revealChar(pp)
if ch != ' ' {
if tp := pp.Monst; tp != nil {
tp.OldCh = ch
if !g.Player.On(SenseMonsters) {
g.mvaddch(y, x, ch)
}
} else {
g.mvaddch(y, x, ch)
}
}
}
// revealChar decides what magic mapping shows at a cell, making secret
// doors, hidden passages, and hidden traps real as a side effect; ' '
// means show nothing (the switch of the C SCR_MAP loop).
func revealChar(pp *Place) byte {
ch := pp.Ch
pass := false
switch ch {
case Door, Stairs:
case '-', '|':
ch = revealWall(pp)
case ' ':
pass = revealSolid(pp)
case Passage:
pass = true
case Floor:
ch = revealFloor(pp)
default:
if pp.Flags.Has(FPassage) {
pass = true
} else {
ch = ' '
}
}
if pass {
if !pp.Flags.Has(FReal) {
pp.Ch = Passage
}
pp.Flags.Set(FSeen | FReal)
ch = Passage
}
return ch
}
// revealWall handles a wall cell for magic mapping: a secret door
// becomes a real door (the '-'/'|' arm).
func revealWall(pp *Place) byte {
if !pp.Flags.Has(FReal) {
pp.Ch = Door
pp.Flags.Set(FReal)
return Door
}
return pp.Ch
}
// revealSolid handles a blank cell for magic mapping, reporting whether
// it is passage: hidden passages become real; hidden things in walls
// stay hidden (the ' ' arm).
func revealSolid(pp *Place) bool {
if pp.Flags.Has(FReal) {
return pp.Flags.Has(FPassage)
}
pp.Flags.Set(FReal)
pp.Ch = Passage
return true
}
// revealFloor handles a floor cell for magic mapping: a hidden trap
// becomes a real, seen trap; real floor shows nothing (the FLOOR arm).
func revealFloor(pp *Place) byte {
if pp.Flags.Has(FReal) {
return ' '
}
pp.Ch = Trap
pp.Flags.Set(FSeen | FReal)
return Trap
}
func (g *RogueGame) readFoodDetection(*Object) {
// Food detection
found := false
g.scr.Hw.Clear()
for _, fo := range g.Level.Objects {
if fo.Kind == KindFood {
found = true
g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food)
}
}
if found {
g.Items.Scrolls[ScrollFoodDetection].Know = true
g.showWin("Your nose tingles and you smell food.--More--")
} else {
g.msg("your nose tingles")
}
}
func (g *RogueGame) readTeleportation(*Object) {
// Scroll of teleportation: make him disappear and reappear
p := &g.Player
curRoom := p.Room
g.teleport()
if curRoom != p.Room {
g.Items.Scrolls[ScrollTeleportation].Know = true
}
}
func (g *RogueGame) readEnchantWeapon(*Object) {
p := &g.Player
if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon {
g.msg("you feel a strange sense of loss")
} else {
p.CurWeapon.Flags.Clear(Cursed)
if g.rnd(2) == 0 {
p.CurWeapon.HPlus++
} else {
p.CurWeapon.DPlus++
}
g.msg("your %s glows %s for a moment",
g.Items.Weapons[p.CurWeapon.Which].Name, g.pickColor("blue"))
}
}
func (g *RogueGame) readScareMonster(*Object) {
// Reading it is a mistake and produces laughter at her poor boo
// boo.
g.msg("you hear maniacal laughter in the distance")
}
func (g *RogueGame) readRemoveCurse(*Object) {
p := &g.Player
uncurse(p.CurArmor)
uncurse(p.CurWeapon)
uncurse(p.CurRing[Left])
uncurse(p.CurRing[Right])
g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes",
"you feel as if somebody is watching over you"))
}
func (g *RogueGame) readAggravateMonsters(*Object) {
// This scroll aggravates all the monsters on the current level
// and sets them running towards the hero
g.aggravate()
g.msg("you hear a high pitched humming noise")
}
func (g *RogueGame) readProtectArmor(*Object) {
p := &g.Player
if p.CurArmor != nil {
p.CurArmor.Flags.Set(Protected)
g.msg("your armor is covered by a shimmering %s shield",
g.pickColor("gold"))
} else {
g.msg("you feel a strange sense of loss")
}
}
// uncurse uncurses an item (scrolls.c uncurse).

101
game/seedcompat_test.go Normal file
View File

@@ -0,0 +1,101 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"fmt"
"os"
"strings"
"testing"
)
// dumpItemTables formats a game's per-seed item appearance tables in the
// same layout the instrumented C reference prints: the potion colors,
// scroll names, ring stones, and wand/staff materials, each generated by
// consuming the RNG in a fixed order during New().
func dumpItemTables(seed int32, g *RogueGame) string {
var b strings.Builder
fmt.Fprintf(&b, "SEED %d\n", seed)
fmt.Fprintln(&b, "POTIONS")
for _, c := range g.Items.PotColors {
fmt.Fprintln(&b, c)
}
fmt.Fprintln(&b, "SCROLLS")
for _, s := range g.Items.ScrNames {
fmt.Fprintln(&b, s)
}
fmt.Fprintln(&b, "RINGS")
for _, s := range g.Items.RingStones {
fmt.Fprintln(&b, s)
}
fmt.Fprintln(&b, "STICKS")
for i := range g.Items.WandType {
fmt.Fprintf(&b, "%s %s\n", g.Items.WandType[i], g.Items.WandMade[i])
}
return b.String()
}
// TestSeedCompatItemTables proves the port's seed-compatibility claim: for
// the same seed, the Go game generates the exact per-seed item appearance
// tables as the C reference on modern-rogue. That requires the LCG and its
// consumption order through the whole init sequence (init_probs →
// init_player → init_names → init_colors → init_stones → init_materials) to
// match C byte for byte. The golden is captured from an instrumented build
// of the C game (testdata/README.md).
func TestSeedCompatItemTables(t *testing.T) {
t.Parallel()
golden, err := os.ReadFile("testdata/item_tables.golden")
if err != nil {
t.Fatalf("read golden: %v", err)
}
// These must match the seeds the golden was generated from
// (testdata/README.md).
seeds := []int32{1, 42, 12345, 99999}
var got strings.Builder
for _, seed := range seeds {
g := New(Params{Seed: seed, Wizard: true})
got.WriteString(dumpItemTables(seed, g))
}
if got.String() != string(golden) {
t.Errorf("Go item tables diverge from the C reference at %s",
firstDiff(string(golden), got.String()))
}
}
// firstDiff returns a description of the first line where want and got
// differ, for a readable failure.
func firstDiff(want, got string) string {
wl := strings.Split(want, "\n")
gl := strings.Split(got, "\n")
for i := 0; i < len(wl) || i < len(gl); i++ {
w, g := "", ""
if i < len(wl) {
w = wl[i]
}
if i < len(gl) {
g = gl[i]
}
if w != g {
return fmt.Sprintf("line %d: C=%q Go=%q", i+1, w, g)
}
}
return "no line difference (trailing content?)"
}

View File

@@ -1,175 +1,314 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
// sticks.c — zap wands and staffs.
// The two ws_type strings a stick can be made as.
const (
wandName = "wand"
staffName = "staff"
)
// doZap performs a zap with a wand (sticks.c do_zap).
func (g *RogueGame) doZap() {
p := &g.Player
obj := g.getItem("zap with", KindWand)
if obj == nil {
obj, ok := g.promptPackItem("zap with", KindWand)
if !ok {
return
}
if obj.Kind != KindWand {
g.After = false
g.msg("you can't zap with that!")
return
}
if obj.Charges == 0 {
g.msg("nothing happens")
return
}
switch obj.WandKind() {
case WandLight:
// Reddy Kilowatt wand. Light up the room
g.Items.Sticks[WandLight].Know = true
if p.Room.Flags.Has(Gone) {
g.msg("the corridor glows and then fades")
} else {
p.Room.Flags.Clear(Dark)
// Light the room and put the player back up
g.enterRoom(p.Pos)
g.addmsg("the room is lit")
if !g.Options.Terse {
g.addmsg(" by a shimmering %s light", g.pickColor("blue"))
}
g.endmsg()
// C's switch has a case for every one of the 14 WS_ kinds, so its
// closing "otherwise: msg(...)" arm is reachable only for an o_which
// outside the table — the malformed objects hasValidWhich screens
// for, which is why no handler and a legal Which can only mean WS_NOP.
//
// The message is under #ifdef MASTER, not under a wizard test: C
// printed it for every player of a MASTER build, which is the build
// this port is (see the '+' command, issue #11). Do not gate it on
// g.Wizard.
//
// WS_NOP is a case of its own ("when WS_NOP: break;"): the wand that
// deliberately does nothing says nothing either. All three arms fall
// out of the switch into o_charges--, so even the bizarre schtick
// costs a charge.
h := g.data.zapHandler(obj)
switch {
case h != nil:
if !h(g, obj) {
return // the zap aborted; no charge is used
}
case WandDrainLife:
// take away 1/2 of hero's hit points, then take it away evenly
// from the monsters in the room (or next to hero if he is in a
// passage)
if p.Stats.HP < 2 {
g.msg("you are too weak to use it")
return
}
g.drain()
case WandInvisibility, WandPolymorph, WandTeleportAway, WandTeleportTo, WandCancellation:
y := p.Pos.Y
x := p.Pos.X
for stepOk(g.Level.VisibleChar(y, x)) {
y += g.Delta.Y
x += g.Delta.X
}
if tp := g.Level.MonsterAt(y, x); tp != nil {
monster := tp.Type
if monster == 'F' {
p.Flags.Clear(Held)
}
switch obj.WandKind() {
case WandInvisibility:
tp.Flags.Set(Invisible)
if g.cansee(y, x) {
g.mvaddch(y, x, tp.OldCh)
}
case WandPolymorph:
pp := tp.Pack
detachMon(&g.Level.Monsters, tp)
if g.seeMonst(tp) {
g.mvaddch(y, x, g.Level.Char(y, x))
}
oldch := tp.OldCh
g.Delta.Y = y
g.Delta.X = x
monster = byte(g.rnd(26) + 'A')
g.newMonster(tp, monster, g.Delta)
if g.seeMonst(tp) {
g.mvaddch(y, x, monster)
}
tp.OldCh = oldch
tp.Pack = pp
if g.seeMonst(tp) {
g.Items.Sticks[WandPolymorph].Know = true
}
case WandCancellation:
tp.Flags.Set(Cancelled)
tp.Flags.Clear(Invisible | CanConfuse)
tp.Disguise = tp.Type
if g.seeMonst(tp) {
g.mvaddch(y, x, tp.Disguise)
}
case WandTeleportAway, WandTeleportTo:
var newPos Coord
if obj.WandKind() == WandTeleportAway {
for {
newPos, _ = g.findFloor(nil, 0, true)
if newPos != p.Pos {
break
}
}
} else {
newPos.Y = p.Pos.Y + g.Delta.Y
newPos.X = p.Pos.X + g.Delta.X
}
tp.Dest = &p.Pos
tp.Flags.Set(Awake)
g.relocate(tp, newPos)
}
}
case WandMagicMissile:
g.Items.Sticks[WandMagicMissile].Know = true
bolt := newObject()
bolt.Kind = KindGold // C set o_type='*': draws a '*' and is not a weapon
bolt.HurlDmg = dice("1x4")
bolt.HPlus = 100
bolt.DPlus = 1
bolt.Flags = Missile
if p.CurWeapon != nil {
bolt.Launch = WeaponKind(p.CurWeapon.Which)
}
g.doMotion(bolt, g.Delta.Y, g.Delta.X)
if tp := g.Level.MonsterAt(bolt.Pos.Y, bolt.Pos.X); tp != nil &&
!g.saveThrow(VsMagic, &tp.Stats) {
g.hitMonster(bolt.Pos, bolt)
} else if g.Options.Terse {
g.msg("missle vanishes")
} else {
g.msg("the missle vanishes with a puff of smoke")
}
case WandHasteMonster, WandSlowMonster:
y := p.Pos.Y
x := p.Pos.X
for stepOk(g.Level.VisibleChar(y, x)) {
y += g.Delta.Y
x += g.Delta.X
}
if tp := g.Level.MonsterAt(y, x); tp != nil {
if obj.WandKind() == WandHasteMonster {
if tp.On(Slowed) {
tp.Flags.Clear(Slowed)
} else {
tp.Flags.Set(Hasted)
}
} else {
if tp.On(Hasted) {
tp.Flags.Clear(Hasted)
} else {
tp.Flags.Set(Slowed)
}
tp.Turn = true
}
g.Delta.Y = y
g.Delta.X = x
g.runto(g.Delta)
}
case WandLightning, WandFire, WandCold:
var name string
switch obj.WandKind() {
case WandLightning:
name = "bolt"
case WandFire:
name = "flame"
default:
name = "ice"
}
g.fireBolt(p.Pos, &g.Delta, name)
g.Items.Sticks[obj.Which].Know = true
case WandNothing:
case obj.hasValidWhich(): // WS_NOP
default:
g.msg("what a bizarre schtick!")
}
obj.Charges--
}
// zapRayMonster walks the zap ray from the hero to the first blocking
// spot and returns the monster standing there, if any (the shared
// preamble of the C monster-affecting zap cases).
func (g *RogueGame) zapRayMonster() *Monster {
p := &g.Player
y := p.Pos.Y
x := p.Pos.X
for stepOk(g.Level.VisibleChar(y, x)) {
y += g.Delta.Y
x += g.Delta.X
}
return g.Level.MonsterAt(y, x)
}
// zapVictim is zapRayMonster plus the flytrap release the C code does
// before the invisibility-family effects.
func (g *RogueGame) zapVictim() *Monster {
tp := g.zapRayMonster()
if tp != nil && tp.Type == 'F' {
g.Player.Flags.Clear(Held)
}
return tp
}
// The per-wand effect handlers, dispatched through gameData.zapHandlers.
// Each is one case of the C do_zap switch; returning false aborts the
// zap without using a charge.
func (g *RogueGame) zapLight(*Object) bool {
// Reddy Kilowatt wand. Light up the room
p := &g.Player
g.Items.Sticks[WandLight].Know = true
if p.Room.Flags.Has(Gone) {
g.msg("the corridor glows and then fades")
} else {
p.Room.Flags.Clear(Dark)
// Light the room and put the player back up
g.enterRoom(p.Pos)
g.addmsgf("the room is lit")
if !g.Options.Terse {
g.addmsgf(" by a shimmering %s light", g.pickColor("blue"))
}
g.endmsg()
}
return true
}
func (g *RogueGame) zapDrainLife(*Object) bool {
// take away 1/2 of hero's hit points, then take it away evenly
// from the monsters in the room (or next to hero if he is in a
// passage)
if g.Player.Stats.HP < 2 {
g.msg("you are too weak to use it")
return false
}
g.drain()
return true
}
func (g *RogueGame) zapInvisibility(*Object) bool {
if tp := g.zapVictim(); tp != nil {
tp.Flags.Set(Invisible)
if g.canSee(tp.Pos.Y, tp.Pos.X) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.OldCh)
}
}
return true
}
func (g *RogueGame) zapPolymorph(*Object) bool {
tp := g.zapVictim()
if tp == nil {
return true
}
y, x := tp.Pos.Y, tp.Pos.X
pp := tp.Pack
g.Level.RemoveMonster(tp)
if g.seeMonst(tp) {
g.mvaddch(y, x, g.Level.Char(y, x))
}
oldch := tp.OldCh
g.Delta.Y = y
g.Delta.X = x
monster := g.randomMonsterLetter()
g.newMonster(tp, monster, g.Delta)
if g.seeMonst(tp) {
g.mvaddch(y, x, monster)
}
tp.OldCh = oldch
tp.Pack = pp
if g.seeMonst(tp) {
g.Items.Sticks[WandPolymorph].Know = true
}
return true
}
func (g *RogueGame) zapCancellation(*Object) bool {
if tp := g.zapVictim(); tp != nil {
tp.Flags.Set(Cancelled)
tp.Flags.Clear(Invisible | CanConfuse)
tp.Disguise = tp.Type
if g.seeMonst(tp) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.Disguise)
}
}
return true
}
func (g *RogueGame) zapTeleport(obj *Object) bool {
p := &g.Player
tp := g.zapVictim()
if tp == nil {
return true
}
var newPos Coord
if obj.WandKind() == WandTeleportAway {
for {
newPos, _ = g.findFloor(true)
if newPos != p.Pos {
break
}
}
} else {
newPos.Y = p.Pos.Y + g.Delta.Y
newPos.X = p.Pos.X + g.Delta.X
}
tp.Dest = &p.Pos
tp.Flags.Set(Awake)
g.relocate(tp, newPos)
return true
}
func (g *RogueGame) zapMagicMissile(*Object) bool {
p := &g.Player
g.Items.Sticks[WandMagicMissile].Know = true
bolt := newObject()
bolt.Kind = KindGold // C set o_type='*': draws a '*' and is not a weapon
bolt.HurlDmg = dice("1x4")
bolt.HPlus = 100
bolt.DPlus = 1
bolt.Flags = Missile
if p.CurWeapon != nil {
bolt.Launch = WeaponKind(p.CurWeapon.Which)
}
g.doMotion(bolt, g.Delta.Y, g.Delta.X)
if tp := g.Level.MonsterAt(bolt.Pos.Y, bolt.Pos.X); tp != nil &&
!g.saveThrow(VsMagic, &tp.Stats) {
g.hitMonster(bolt.Pos, bolt)
} else if g.Options.Terse {
g.msg("missle vanishes") //nolint:misspell // C's spelling, kept faithfully
} else {
g.msg("the missle vanishes with a puff of smoke") //nolint:misspell // C's spelling
}
return true
}
func (g *RogueGame) zapSpeed(obj *Object) bool {
tp := g.zapRayMonster()
if tp == nil {
return true
}
if obj.WandKind() == WandHasteMonster {
hasteTarget(tp)
} else {
slowTarget(tp)
}
g.Delta.Y = tp.Pos.Y
g.Delta.X = tp.Pos.X
g.runTo(g.Delta)
return true
}
// hasteTarget cancels a slow or applies a haste (the WS_HASTE_M arm of
// do_zap).
func hasteTarget(tp *Monster) {
if tp.On(Slowed) {
tp.Flags.Clear(Slowed)
} else {
tp.Flags.Set(Hasted)
}
}
// slowTarget cancels a haste or applies a slow (the WS_SLOW_M arm of
// do_zap).
func slowTarget(tp *Monster) {
if tp.On(Hasted) {
tp.Flags.Clear(Hasted)
} else {
tp.Flags.Set(Slowed)
}
tp.Turn = true
}
func (g *RogueGame) zapBolt(obj *Object) bool {
var name string
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.WandKind() {
case WandLightning:
name = "bolt"
case WandFire:
name = "flame"
default:
name = "ice"
}
g.fireBolt(g.Player.Pos, &g.Delta, name)
g.Items.Sticks[obj.Which].Know = true
return true
}
// drain does the drain-hit-points-from-player schtick (sticks.c drain).
func (g *RogueGame) drain() {
p := &g.Player
@@ -178,20 +317,24 @@ func (g *RogueGame) drain() {
if g.Level.Char(p.Pos.Y, p.Pos.X) == Door {
corp = &g.Level.Passages[*g.Level.FlagsAt(p.Pos.Y, p.Pos.X)&FPassNum]
}
inpass := p.Room.Flags.Has(Gone)
var drainee []*Monster
for _, mp := range g.Level.Monsters {
if mp.Room == p.Room || mp.Room == corp ||
(inpass && g.Level.Char(mp.Pos.Y, mp.Pos.X) == Door &&
&g.Level.Passages[*g.Level.FlagsAt(mp.Pos.Y, mp.Pos.X)&FPassNum] == p.Room) {
if g.drainReaches(mp, corp, inpass) {
drainee = append(drainee, mp)
}
}
cnt := len(drainee)
if cnt == 0 {
g.msg("you have a tingling feeling")
return
}
p.Stats.HP /= 2
cnt = p.Stats.HP / cnt
// Now zot all of the monsters
@@ -199,11 +342,23 @@ func (g *RogueGame) drain() {
if mp.Stats.HP -= cnt; mp.Stats.HP <= 0 {
g.killed(mp, g.seeMonst(mp))
} else {
g.runto(mp.Pos)
g.runTo(mp.Pos)
}
}
}
// drainReaches reports whether the drain-life wand reaches this monster:
// the hero's room, the passage behind the door he stands on, or — when
// he is in a passage — a door of that same passage (the drainee
// condition of sticks.c drain).
func (g *RogueGame) drainReaches(mp *Monster, corp *Room, inpass bool) bool {
p := &g.Player
return mp.Room == p.Room || mp.Room == corp ||
(inpass && g.Level.Char(mp.Pos.Y, mp.Pos.X) == Door &&
&g.Level.Passages[*g.Level.FlagsAt(mp.Pos.Y, mp.Pos.X)&FPassNum] == p.Room)
}
// fireBolt fires a bolt in a given direction from a specific starting
// place (sticks.c fire_bolt).
func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
@@ -217,99 +372,45 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
bolt.HPlus = 100
bolt.DPlus = 0
g.Items.Weapons[WeaponFlame].Name = name
var dirch byte
switch dir.Y + dir.X {
case 0:
dirch = '/'
case 1, -1:
if dir.Y == 0 {
dirch = '-'
} else {
dirch = '|'
}
case 2, -2:
dirch = '\\'
}
dirch := boltDirChar(*dir)
pos := start
hitHero := !fromHero
used := false
changed := false
var spotpos []Coord
for len(spotpos) < BoltLength && !used {
pos.Y += dir.Y
pos.X += dir.X
spotpos = append(spotpos, pos)
ch := g.Level.VisibleChar(pos.Y, pos.X)
bounce := false
switch ch {
case Door:
// this code is necessary if the hero is on a door and he
// fires at the wall the door is in, it would otherwise loop
// infinitely
if p.Pos != pos {
bounce = true
}
case '|', '-', ' ':
bounce = true
}
if bounce {
if boltBounces(ch, p.Pos, pos) {
if !changed {
hitHero = !hitHero
}
changed = false
dir.Y = -dir.Y
dir.X = -dir.X
spotpos = spotpos[:len(spotpos)-1]
g.msg("the %s bounces", name)
continue
}
if tp := g.Level.MonsterAt(pos.Y, pos.X); !hitHero && tp != nil {
hitHero = true
changed = !changed
tp.OldCh = g.Level.Char(pos.Y, pos.X)
if !g.saveThrow(VsMagic, &tp.Stats) {
bolt.Pos = pos
used = true
if tp.Type == 'D' && name == "flame" {
g.addmsg("the flame bounces")
if !g.Options.Terse {
g.addmsg(" off the dragon")
}
g.endmsg()
} else {
g.hitMonster(pos, bolt)
}
} else if ch != 'M' || tp.Disguise == 'M' {
if fromHero {
g.runto(pos)
}
if g.Options.Terse {
g.msg("%s misses", name)
} else {
g.msg("the %s whizzes past %s", name, g.setMname(tp))
}
}
used = g.boltStrikesMonster(tp, bolt, pos, ch, name, fromHero)
} else if hitHero && pos == p.Pos {
hitHero = false
changed = !changed
if !g.save(VsMagic) {
if p.Stats.HP -= g.roll(6, 6); p.Stats.HP <= 0 {
if fromHero {
g.death('b')
} else {
g.death(g.Level.MonsterAt(start.Y, start.X).Type)
}
}
used = true
if g.Options.Terse {
g.msg("the %s hits", name)
} else {
g.msg("you are hit by the %s", name)
}
} else {
g.msg("the %s whizzes by you", name)
}
used = g.boltStrikesHero(start, name, fromHero)
}
g.mvaddch(pos.Y, pos.X, dirch)
g.refresh()
}
@@ -319,15 +420,121 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
}
}
// boltDirChar picks the character a traveling bolt is drawn with for its
// direction (the dirch switch of sticks.c fire_bolt).
func boltDirChar(dir Coord) byte {
switch dir.Y + dir.X {
case 0:
return '/'
case 1, -1:
if dir.Y == 0 {
return '-'
}
return '|'
case 2, -2:
return '\\'
}
return 0 // unreachable for the eight legal directions, as in C
}
// boltBounces reports whether a bolt bounces off this spot: walls, and
// any door except the one the hero stands on (which would otherwise loop
// infinitely, per the C comment in fire_bolt).
func boltBounces(ch byte, heroPos, pos Coord) bool {
switch ch {
case Door:
return heroPos != pos
case '|', '-', ' ':
return true
}
return false
}
// boltStrikesMonster resolves a bolt arriving on a monster's square (the
// monster arm of the fire_bolt loop). It reports whether the bolt was
// used up.
func (g *RogueGame) boltStrikesMonster(
tp *Monster, bolt *Object, pos Coord, ch byte, name string, fromHero bool,
) bool {
tp.OldCh = g.Level.Char(pos.Y, pos.X)
if !g.saveThrow(VsMagic, &tp.Stats) {
bolt.Pos = pos
if tp.Type == 'D' && name == "flame" {
g.addmsgf("the flame bounces")
if !g.Options.Terse {
g.addmsgf(" off the dragon")
}
g.endmsg()
} else {
g.hitMonster(pos, bolt)
}
return true
}
if ch != 'M' || tp.Disguise == 'M' {
if fromHero {
g.runTo(pos)
}
if g.Options.Terse {
g.msg("%s misses", name)
} else {
g.msg("the %s whizzes past %s", name, g.setMname(tp))
}
}
return false
}
// boltStrikesHero resolves a bolt arriving on the hero (the hero arm of
// the fire_bolt loop). It reports whether the bolt was used up.
func (g *RogueGame) boltStrikesHero(start Coord, name string, fromHero bool) bool {
p := &g.Player
if g.save(VsMagic) {
g.msg("the %s whizzes by you", name)
return false
}
if p.Stats.HP -= g.roll(6, 6); p.Stats.HP <= 0 {
if fromHero {
g.death('b')
} else {
g.death(g.Level.MonsterAt(start.Y, start.X).Type)
}
}
if g.Options.Terse {
g.msg("the %s hits", name)
} else {
g.msg("you are hit by the %s", name)
}
return true
}
// fixStick sets up a new wand or staff (sticks.c fix_stick).
func (g *RogueGame) fixStick(cur *Object) {
if g.Items.WandType[cur.Which] == "staff" {
// ws_type[] is indexed by Which; a malformed one is treated as a wand,
// which is the branch the C string compare would take against any
// value that is not literally "staff". The charge switch below already
// funnels everything but WandLight into its default arm.
if cur.hasValidWhich() && g.Items.WandType[cur.Which] == staffName {
cur.Damage = dice("2x3")
} else {
cur.Damage = dice("1x1")
}
cur.HurlDmg = dice("1x1")
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch cur.WandKind() {
case WandLight:
cur.Charges = g.rnd(10) + 10
@@ -342,8 +549,10 @@ func chargeStr(g *RogueGame, obj *Object) string {
if !obj.Flags.Has(Known) {
return ""
}
if g.Options.Terse {
return fmt.Sprintf(" [%d]", obj.Charges)
}
return fmt.Sprintf(" [%d 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")
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,31 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import "testing"
// badcheck from init.c: every probability table must sum to exactly 100.
func TestProbabilitiesSumTo100(t *testing.T) {
t.Parallel()
sum := func(info []ObjInfo) int {
s := 0
for _, oi := range info {
s += oi.Prob
}
return s
}
data := newGameData()
tables := map[string][]ObjInfo{
"things": baseThings[:],
"potions": basePotInfo[:],
"scrolls": baseScrInfo[:],
"rings": baseRingInfo[:],
"sticks": baseWsInfo[:],
"weapons": baseWeapInfo[:NumWeaponTypes], // excludes the flame entry
"armor": baseArmInfo[:],
"things": data.baseThings[:],
"potions": data.basePotInfo[:],
"scrolls": data.baseScrInfo[:],
"rings": data.baseRingInfo[:],
"sticks": data.baseWsInfo[:],
"weapons": data.baseWeapInfo[:NumWeaponTypes], // excludes the flame entry
"armor": data.baseArmInfo[:],
}
for name, tab := range tables {
if s := sum(tab); s != 100 {
@@ -28,11 +35,15 @@ func TestProbabilitiesSumTo100(t *testing.T) {
}
func TestInitProbsCumulative(t *testing.T) {
g := NewGame(Config{Seed: 1})
t.Parallel()
g := New(Params{Seed: 1})
last := g.Items.Potions[NumPotionTypes-1].Prob
if last != 100 {
t.Errorf("cumulative potion probability ends at %d, want 100", last)
}
for i := PotionKind(1); i < NumPotionTypes; i++ {
if g.Items.Potions[i].Prob < g.Items.Potions[i-1].Prob {
t.Errorf("potion probs not nondecreasing at %d", i)
@@ -41,46 +52,81 @@ func TestInitProbsCumulative(t *testing.T) {
}
func TestNewGameRandomizesAppearances(t *testing.T) {
g := NewGame(Config{Seed: 12345})
seen := map[string]bool{}
for i, c := range g.Items.PotColors {
if c == "" {
t.Fatalf("potion %d has no color", i)
}
if seen[c] {
t.Errorf("potion color %q assigned twice", c)
}
seen[c] = true
}
for i, n := range g.Items.ScrNames {
if n == "" {
t.Fatalf("scroll %d has no name", i)
}
if len(n) > MaxNameLen+1 {
t.Errorf("scroll name %q longer than C buffer allows", n)
}
}
for i := range g.Items.WandType {
if g.Items.WandType[i] != "wand" && g.Items.WandType[i] != "staff" {
t.Errorf("stick %d has type %q", i, g.Items.WandType[i])
}
if g.Items.WandMade[i] == "" {
t.Errorf("stick %d has no material", i)
}
}
t.Parallel()
g := New(Params{Seed: 12345})
checkPotionColors(t, g)
checkScrollNames(t, g)
checkWandMaterials(t, g)
// Determinism: same seed, same appearances.
h := NewGame(Config{Seed: 12345})
h := New(Params{Seed: 12345})
if h.Items != g.Items {
t.Error("two games with the same seed produced different item lore")
}
}
// checkPotionColors verifies every potion has a distinct color.
func checkPotionColors(t *testing.T, g *RogueGame) {
t.Helper()
seen := map[string]bool{}
for i, c := range g.Items.PotColors {
if c == "" {
t.Fatalf("potion %d has no color", i)
}
if seen[c] {
t.Errorf("potion color %q assigned twice", c)
}
seen[c] = true
}
}
// checkScrollNames verifies every scroll has a name within the C buffer
// limit.
func checkScrollNames(t *testing.T, g *RogueGame) {
t.Helper()
for i, n := range g.Items.ScrNames {
if n == "" {
t.Fatalf("scroll %d has no name", i)
}
if len(n) > MaxNameLen+1 {
t.Errorf("scroll name %q longer than C buffer allows", n)
}
}
}
// checkWandMaterials verifies every stick has a wand/staff type and a
// material.
func checkWandMaterials(t *testing.T, g *RogueGame) {
t.Helper()
for i := range g.Items.WandType {
if g.Items.WandType[i] != wandName && g.Items.WandType[i] != staffName {
t.Errorf("stick %d has type %q", i, g.Items.WandType[i])
}
if g.Items.WandMade[i] == "" {
t.Errorf("stick %d has no material", i)
}
}
}
func TestMonsterTable(t *testing.T) {
if monsterTable[0].Name != "aquator" || monsterTable[25].Name != "zombie" {
t.Parallel()
data := newGameData()
if data.monsterTable[0].Name != "aquator" || data.monsterTable[25].Name != "zombie" {
t.Error("monster table order broken")
}
if monsterTable['D'-'A'].Name != "dragon" {
if data.monsterTable['D'-'A'].Name != "dragon" {
t.Error("letter indexing broken")
}
}

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
// testTerm is a headless Terminal for tests: rendering is a no-op and
@@ -7,19 +8,36 @@ type testTerm struct {
input []byte
pos int
tick int
// repaints counts forced full redraws. Rendering is a no-op here, so
// counting is the only way a headless test can tell that CTRL-R asked
// for a repaint rather than an ordinary refresh — the two are
// indistinguishable in the window contents, which is the whole reason
// the bug this replaces went unnoticed.
repaints int
}
func (t *testTerm) Render(*Window) {}
func (t *testTerm) ReadChar() byte {
func (t *testTerm) Repaint() { t.repaints++ }
func (t *testTerm) Fini() {}
// 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) {
c := t.input[t.pos]
t.pos++
return c
return c, true
}
t.tick++
if t.tick%2 == 0 {
return '\n'
return '\n', true
}
return ' '
return ' ', true
}

27
game/testdata/README.md vendored Normal file
View File

@@ -0,0 +1,27 @@
# Seed-compatibility golden
`item_tables.golden` is the per-seed item appearance tables (potion colors,
scroll names, ring stones, wand/staff materials) captured from the **C
reference** on the `modern-rogue` branch, for the seeds in the `seeds` list in
`TestSeedCompatItemTables`. That test regenerates the same tables from the Go
port and checks they match byte for byte — proving the LCG and its consumption
order through the whole init sequence (`init_probs``init_player`
`init_names``init_colors``init_stones``init_materials`) agree with C.
## Regenerating the golden
`c_seedcompat.patch` adds a `DUMP` mode to the C `main.c`: with `DUMP` set it
forces the RNG seed from `SEED`, runs the item-table init in the normal order,
prints the tables, and exits before `initscr` (so no terminal is needed).
```sh
# from a checkout of the C reference (modern-rogue branch):
git archive modern-rogue | tar -x -C /tmp/rogue-c
cd /tmp/rogue-c
patch -p1 < .../game/testdata/c_seedcompat.patch
./configure && make
for s in 1 42 12345 99999; do DUMP=1 SEED=$s ./rogue; done \
> .../game/testdata/item_tables.golden
```
The seed list must match the `seeds` slice in `TestSeedCompatItemTables`.

37
game/testdata/c_seedcompat.patch vendored Normal file
View File

@@ -0,0 +1,37 @@
--- a/main.c 2026-07-24 03:02:38
+++ b/main.c 2026-07-24 02:48:31
@@ -63,6 +63,34 @@
#endif
dnum = lowtime + md_getpid();
seed = dnum;
+
+ /* SEEDCOMPAT: dump the per-game item appearance tables for a fixed
+ * seed and exit, without initscr. The init sequence and everything
+ * it consumes from rnd() mirror the normal startup (main.c), so the
+ * tables are exactly what a real game with SEED would show. */
+ if (getenv("DUMP") != NULL)
+ {
+ int di;
+ char *sv = getenv("SEED");
+ if (sv != NULL)
+ seed = atoi(sv);
+ printf("SEED %d\n", seed);
+ init_probs();
+ init_player();
+ init_names();
+ init_colors();
+ init_stones();
+ init_materials();
+ printf("POTIONS\n");
+ for (di = 0; di < MAXPOTIONS; di++) printf("%s\n", p_colors[di]);
+ printf("SCROLLS\n");
+ for (di = 0; di < MAXSCROLLS; di++) printf("%s\n", s_names[di]);
+ printf("RINGS\n");
+ for (di = 0; di < MAXRINGS; di++) printf("%s\n", r_stones[di]);
+ printf("STICKS\n");
+ for (di = 0; di < MAXSTICKS; di++) printf("%s %s\n", ws_type[di], ws_made[di]);
+ exit(0);
+ }
open_score();

260
game/testdata/item_tables.golden vendored Normal file
View File

@@ -0,0 +1,260 @@
SEED 1
POTIONS
tangerine
white
ecru
gold
amber
violet
vermilion
pink
aquamarine
plaid
clear
orange
cyan
tan
SCROLLS
miwhon garsnanih
xomimi roke eshwedshu
potwexrol ipbjorod turs evsnelg
bekornan oxyfatox
iv wexpo wun
ha sefnelgtue whon pay
alari wedit
zantmon umzonski umwhonjo yot
bluoxun rokkho yottrol sta
vomarg microgcomp iteulkshu mung
jo urokeep yuskiun
ox xozantaks klisstaevs ag
ipnih bek
shu ami erk
nejti zim
iprol mic ishoxyvom fagan
reacreti oodrol
bytsri solsa tabu fri
RINGS
agate
zircon
jade
tiger eye
onyx
germanium
lapis lazuli
emerald
taaffeite
kryptonite
garnet
ruby
turquoise
pearl
STICKS
wand steel
wand platinum
staff redwood
staff pine
wand silicon
staff spruce
staff pecan
wand bone
staff maple
wand zinc
wand iron
wand pewter
wand electrum
staff dogwood
SEED 42
POTIONS
blue
green
grey
amber
violet
gold
pink
tan
purple
yellow
plaid
magenta
turquoise
cyan
SCROLLS
bek itod oxytaod oxy
tarhovzant cre sname oxroy
plemik ganod hyd wergerkpot
hyd sol um bekzok
esh eep ganmung
anera ishsa ingala mon
alasniklech viv
yunejorn garro con nej
dotrolther gopum eltitrol trolmonsri
nes alazum
itegopmung ti
ere haeta wergla
nejerecre poipi iprea
ha falechrhov
monskiwex sabitla frido
rhovmar sno
mar bekurzant satbuzum
somon sri
RINGS
carnelian
onyx
jade
granite
stibotantalite
kryptonite
lapis lazuli
germanium
garnet
tiger eye
opal
topaz
agate
peridot
STICKS
staff birch
staff ebony
staff redwood
wand gold
wand copper
wand aluminum
wand titanium
wand mercury
staff cypress
staff bamboo
staff dogwood
wand silicon
staff zebrawood
wand beryllium
SEED 12345
POTIONS
purple
black
grey
brown
plaid
violet
vermilion
ecru
orange
turquoise
tan
magenta
silver
gold
SCROLLS
readalf shuplu ivnin
plelaiv solel skibyt monha
xo wun
wedyfri o ewhonxo favompay
eep zantreanelg
plu buxo
un zontabdan
bie snik
ulkitzant bluri
apporg ash posnevly dennepwex
u urval rol
arzepotsno snovly pay snoropay
pottox erewed faoxro
ther sun ulkipo mik
argzebfri elgrekli tuenepzon sehturssef
isheep blumur
wedash yuzimsun
plupofri ski rejo fa
RINGS
onyx
tiger eye
alexandrite
turquoise
pearl
emerald
germanium
sapphire
zircon
ruby
granite
stibotantalite
opal
diamond
STICKS
wand silicon
staff ironwood
staff holly
wand gold
staff mahogany
wand iron
wand brass
wand pewter
staff hemlock
staff cherry
staff elm
wand mercury
staff banyan
staff dogwood
SEED 99999
POTIONS
aquamarine
plaid
gold
black
vermilion
red
cyan
tan
orange
violet
brown
clear
silver
green
SCROLLS
zant jocompan vomervly
mur shusat prok
prokmurklis oxysriklis
ingcre prokbu whonengarg kli
kli bot
rokcoswerg ipsolsan klisvlypay
glen yot whontox
lechme markho fazim
dalfsunbie micjosef cre comp
vlyfumi bjorzantbot werg
po argfidcos klipones
ashtemarg ycrezim dalfiv whon
turs unmisa zimpo therdo
miccompuni uni neswex sef
odwexing elwergmur mung
itcon rhov nejmic lech
garturs engseh ganish
oodta whonorgsno monabmik vomyeng
RINGS
obsidian
moonstone
jade
carnelian
tiger eye
taaffeite
turquoise
stibotantalite
agate
ruby
onyx
topaz
germanium
granite
STICKS
wand titanium
wand brass
wand silicon
staff zebrawood
wand mercury
staff dogwood
wand pewter
staff cinnibar
staff kukui wood
staff banyan
wand magnesium
wand gold
staff maple
wand nickel

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import (
@@ -9,105 +10,175 @@ import (
// invName returns the name of something as it would appear in an inventory
// (things.c inv_name).
func (g *RogueGame) invName(obj *Object, drop bool) string {
func (g *RogueGame) inventoryName(obj *Object, drop bool) string {
var pb strings.Builder
// Every arm below reaches into a per-kind name table at obj.Which:
// the potion colors, ring stones, wand material/type, scroll titles,
// and the weapon and armor name tables. An object with an out-of-range
// Which cannot reach here (createObj and Restore both reject one), but
// if one ever did, naming it must not take the process down — so it
// falls back to the bare category name from C's type_name() vocabulary.
if !obj.hasValidWhich() {
return obj.Kind.String()
}
which := obj.Which
it := &g.Items
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindPotion:
g.nameit(&pb, obj, "potion", it.PotColors[which], &it.Potions[which], nullstr)
g.nameit(&pb, obj, potionName, it.PotColors[which], &it.Potions[which], nullstr)
case KindRing:
g.nameit(&pb, obj, "ring", it.RingStones[which], &it.Rings[which], ringNum)
g.nameit(&pb, obj, ringName, it.RingStones[which], &it.Rings[which], ringNum)
case KindWand:
g.nameit(&pb, obj, it.WandType[which], it.WandMade[which], &it.Sticks[which], chargeStr)
g.nameit(&pb, obj, it.WandType[which], it.WandMade[which],
&it.Sticks[which], chargeStr)
case KindScroll:
if obj.Count == 1 {
pb.WriteString("A scroll ")
} else {
fmt.Fprintf(&pb, "%d scrolls ", obj.Count)
}
op := &it.Scrolls[which]
if op.Know {
fmt.Fprintf(&pb, "of %s", op.Name)
} else if op.Guess != "" {
fmt.Fprintf(&pb, "called %s", op.Guess)
} else {
fmt.Fprintf(&pb, "titled '%s'", it.ScrNames[which])
}
g.nameScroll(&pb, obj)
case KindFood:
if which == 1 {
if obj.Count == 1 {
fmt.Fprintf(&pb, "A%s %s", vowelstr(g.Fruit), g.Fruit)
} else {
fmt.Fprintf(&pb, "%d %ss", obj.Count, g.Fruit)
}
} else {
if obj.Count == 1 {
pb.WriteString("Some food")
} else {
fmt.Fprintf(&pb, "%d rations of food", obj.Count)
}
}
g.nameFood(&pb, obj)
case KindWeapon:
sp := it.Weapons[which].Name
if obj.Count > 1 {
fmt.Fprintf(&pb, "%d ", obj.Count)
} else {
fmt.Fprintf(&pb, "A%s ", vowelstr(sp))
}
if obj.Flags.Has(Known) {
fmt.Fprintf(&pb, "%s %s", num(obj.HPlus, obj.DPlus, Weapon), sp)
} else {
pb.WriteString(sp)
}
if obj.Count > 1 {
pb.WriteString("s")
}
if obj.Label != "" {
fmt.Fprintf(&pb, " called %s", obj.Label)
}
g.nameWeapon(&pb, obj)
case KindArmor:
sp := it.Armors[which].Name
if obj.Flags.Has(Known) {
fmt.Fprintf(&pb, "%s %s [", num(aClass[which]-obj.ArmorClass, 0, Armor), sp)
if !g.Options.Terse {
pb.WriteString("protection ")
}
fmt.Fprintf(&pb, "%d]", 10-obj.ArmorClass)
} else {
pb.WriteString(sp)
}
if obj.Label != "" {
fmt.Fprintf(&pb, " called %s", obj.Label)
}
g.nameArmor(&pb, obj)
case KindAmulet:
pb.WriteString("The Amulet of Yendor")
case KindGold:
fmt.Fprintf(&pb, "%d Gold pieces", obj.GoldValue)
}
out := pb.String()
if g.InvDescribe {
p := &g.Player
if obj == p.CurArmor {
out += " (being worn)"
}
if obj == p.CurWeapon {
out += " (weapon in hand)"
}
if obj == p.CurRing[Left] {
out += " (on left hand)"
} else if obj == p.CurRing[Right] {
out += " (on right hand)"
}
return fixNameCase(g.describeWorn(obj, pb.String()), drop)
}
// nameScroll writes a scroll's inventory name (things.c inv_name).
func (g *RogueGame) nameScroll(pb *strings.Builder, obj *Object) {
if obj.Count == 1 {
pb.WriteString("A scroll ")
} else {
fmt.Fprintf(pb, "%d scrolls ", obj.Count)
}
if out != "" {
if drop && isUpper(out[0]) {
out = string(toLower(out[0])) + out[1:]
} else if !drop && isLower(out[0]) {
out = string(toUpper(out[0])) + out[1:]
}
op := &g.Items.Scrolls[obj.Which]
switch {
case op.Know:
fmt.Fprintf(pb, "of %s", op.Name)
case op.Guess != "":
fmt.Fprintf(pb, "called %s", op.Guess)
default:
fmt.Fprintf(pb, "titled '%s'", g.Items.ScrNames[obj.Which])
}
}
// nameFood writes a food item's inventory name; which 1 is the fruit
// (things.c inv_name).
func (g *RogueGame) nameFood(pb *strings.Builder, obj *Object) {
if obj.Which == 1 {
if obj.Count == 1 {
fmt.Fprintf(pb, "A%s %s", vowelstr(g.Fruit), g.Fruit)
} else {
fmt.Fprintf(pb, "%d %ss", obj.Count, g.Fruit)
}
return
}
if obj.Count == 1 {
pb.WriteString("Some food")
} else {
fmt.Fprintf(pb, "%d rations of food", obj.Count)
}
}
// nameWeapon writes a weapon's inventory name (things.c inv_name).
func (g *RogueGame) nameWeapon(pb *strings.Builder, obj *Object) {
sp := g.Items.Weapons[obj.Which].Name
if obj.Count > 1 {
fmt.Fprintf(pb, "%d ", obj.Count)
} else {
fmt.Fprintf(pb, "A%s ", vowelstr(sp))
}
if obj.Flags.Has(Known) {
fmt.Fprintf(pb, "%s %s", num(obj.HPlus, obj.DPlus, Weapon), sp)
} else {
pb.WriteString(sp)
}
if obj.Count > 1 {
pb.WriteString("s")
}
if obj.Label != "" {
fmt.Fprintf(pb, " called %s", obj.Label)
}
}
// nameArmor writes an armor's inventory name (things.c inv_name).
func (g *RogueGame) nameArmor(pb *strings.Builder, obj *Object) {
sp := g.Items.Armors[obj.Which].Name
if obj.Flags.Has(Known) {
fmt.Fprintf(pb, "%s %s [",
num(g.data.armorClass(obj.Which)-obj.ArmorClass, 0, Armor), sp)
if !g.Options.Terse {
pb.WriteString("protection ")
}
fmt.Fprintf(pb, "%d]", 10-obj.ArmorClass)
} else {
pb.WriteString(sp)
}
if obj.Label != "" {
fmt.Fprintf(pb, " called %s", obj.Label)
}
}
// describeWorn appends the equipped-status notes to an inventory name
// (things.c inv_name).
func (g *RogueGame) describeWorn(obj *Object, out string) string {
if !g.InvDescribe {
return out
}
p := &g.Player
if obj == p.CurArmor {
out += " (being worn)"
}
if obj == p.CurWeapon {
out += " (weapon in hand)"
}
switch obj {
case p.CurRing[Left]:
out += " (on left hand)"
case p.CurRing[Right]:
out += " (on right hand)"
}
return out
}
// fixNameCase upper- or lowercases the leading letter to suit the
// sentence it will land in (things.c inv_name).
func fixNameCase(out string, drop bool) string {
if out == "" {
return out
}
if drop && isUpper(out[0]) {
return string(toLower(out[0])) + out[1:]
}
if !drop && isLower(out[0]) {
return string(toUpper(out[0])) + out[1:]
}
return out
}
@@ -115,29 +186,36 @@ func (g *RogueGame) invName(obj *Object, drop bool) string {
// leavePack/detach vocabulary collision).
func (g *RogueGame) dropIt() {
p := &g.Player
ch := g.Level.Char(p.Pos.Y, p.Pos.X)
if ch != Floor && ch != Passage {
g.After = false
g.msg("there is something there already")
return
}
obj := g.getItem("drop", KindNone)
if obj == nil {
obj, ok := g.promptPackItem("drop", KindNone)
if !ok {
return
}
if !g.dropCheck(obj) {
return
}
obj = g.leavePack(obj, true, !obj.Kind.MergesInPack())
// Link it into the level object list
attachObj(&g.Level.Objects, obj)
g.Level.AddObject(obj)
g.Level.SetChar(p.Pos.Y, p.Pos.X, obj.Kind.Glyph())
g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Set(FDropped)
obj.Pos = p.Pos
if obj.Kind == KindAmulet {
g.HasAmulet = false
}
g.msg("dropped %s", g.invName(obj, true))
g.msg("dropped %s", g.inventoryName(obj, true))
}
// dropCheck does special checks for dropping or unwielding|unwearing|
@@ -146,37 +224,55 @@ func (g *RogueGame) dropCheck(obj *Object) bool {
if obj == nil {
return true
}
p := &g.Player
if obj != p.CurArmor && obj != p.CurWeapon &&
obj != p.CurRing[Left] && obj != p.CurRing[Right] {
return true
}
if obj.Flags.Has(Cursed) {
g.msg("you can't. It appears to be cursed")
return false
}
if obj == p.CurWeapon {
switch obj {
case p.CurWeapon:
p.CurWeapon = nil
} else if obj == p.CurArmor {
case p.CurArmor:
g.wasteTime()
p.CurArmor = nil
} else {
hand := Right
if obj == p.CurRing[Left] {
hand = Left
}
p.CurRing[hand] = nil
switch obj.RingKind() {
case RingAddStrength:
g.chgStr(-obj.Bonus)
case RingSeeInvisible:
g.unsee(0)
g.Extinguish(DUnsee)
}
default:
g.dropRing(obj)
}
return true
}
// dropRing takes a worn ring off with its side effects (things.c
// dropcheck).
func (g *RogueGame) dropRing(obj *Object) {
p := &g.Player
hand := Right
if obj == p.CurRing[Left] {
hand = Left
}
p.CurRing[hand] = nil
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingAddStrength:
g.changeStrength(-obj.Bonus)
case RingSeeInvisible:
g.unsee(0)
g.Extinguish(DUnsee)
}
}
// newThing returns a new random thing for the dungeon (things.c new_thing).
func (g *RogueGame) newThing() *Object {
cur := newObject()
@@ -193,6 +289,7 @@ func (g *RogueGame) newThing() *Object {
} else {
kind = pickOne(g, g.Items.Things[:])
}
switch kind {
case 0:
cur.Kind = KindPotion
@@ -201,51 +298,80 @@ func (g *RogueGame) newThing() *Object {
cur.Kind = KindScroll
cur.Which = pickOne(g, g.Items.Scrolls[:])
case 2:
cur.Kind = KindFood
g.Player.NoFood = 0
if g.rnd(10) != 0 {
cur.Which = 0
} else {
cur.Which = 1
}
g.newFoodThing(cur)
case 3:
g.initWeapon(cur, WeaponKind(pickOne(g, g.Items.Weapons[:NumWeaponTypes])))
if r := g.rnd(100); r < 10 {
cur.Flags.Set(Cursed)
cur.HPlus -= g.rnd(3) + 1
} else if r < 15 {
cur.HPlus += g.rnd(3) + 1
}
g.newWeaponThing(cur)
case 4:
cur.Kind = KindArmor
cur.Which = pickOne(g, g.Items.Armors[:])
cur.ArmorClass = aClass[cur.Which]
if r := g.rnd(100); r < 20 {
cur.Flags.Set(Cursed)
cur.ArmorClass += g.rnd(3) + 1
} else if r < 28 {
cur.ArmorClass -= g.rnd(3) + 1
}
g.newArmorThing(cur)
case 5:
cur.Kind = KindRing
cur.Which = pickOne(g, g.Items.Rings[:])
switch cur.RingKind() {
case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage:
if cur.Bonus = g.rnd(3); cur.Bonus == 0 {
cur.Bonus = -1
cur.Flags.Set(Cursed)
}
case RingAggravateMonsters, RingTeleportation:
cur.Flags.Set(Cursed)
}
g.newRingThing(cur)
case 6:
cur.Kind = KindWand
cur.Which = pickOne(g, g.Items.Sticks[:])
g.fixStick(cur)
}
return cur
}
// newFoodThing rolls food, one time in ten the fruit (things.c
// new_thing).
func (g *RogueGame) newFoodThing(cur *Object) {
cur.Kind = KindFood
g.Player.NoFood = 0
if g.rnd(10) != 0 {
cur.Which = 0
} else {
cur.Which = 1
}
}
// newWeaponThing rolls a weapon, sometimes cursed or blessed (things.c
// new_thing).
func (g *RogueGame) newWeaponThing(cur *Object) {
g.initWeapon(cur, WeaponKind(pickOne(g, g.Items.Weapons[:NumWeaponTypes])))
if r := g.rnd(100); r < 10 {
cur.Flags.Set(Cursed)
cur.HPlus -= g.rnd(3) + 1
} else if r < 15 {
cur.HPlus += g.rnd(3) + 1
}
}
// newArmorThing rolls armor, sometimes cursed or blessed (things.c
// new_thing).
func (g *RogueGame) newArmorThing(cur *Object) {
cur.Kind = KindArmor
cur.Which = pickOne(g, g.Items.Armors[:])
cur.ArmorClass = g.data.aClass[cur.Which]
if r := g.rnd(100); r < 20 {
cur.Flags.Set(Cursed)
cur.ArmorClass += g.rnd(3) + 1
} else if r < 28 {
cur.ArmorClass -= g.rnd(3) + 1
}
}
// newRingThing rolls a ring, cursing the bad ones (things.c new_thing).
func (g *RogueGame) newRingThing(cur *Object) {
cur.Kind = KindRing
cur.Which = pickOne(g, g.Items.Rings[:])
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch cur.RingKind() {
case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage:
if cur.Bonus = g.rnd(3); cur.Bonus == 0 {
cur.Bonus = -1
cur.Flags.Set(Cursed)
}
case RingAggravateMonsters, RingTeleportation:
cur.Flags.Set(Cursed)
}
}
// pickOne picks an item out of a list of possible objects using their
// cumulative probabilities (things.c pick_one).
func pickOne(g *RogueGame, info []ObjInfo) int {
@@ -255,6 +381,7 @@ func pickOne(g *RogueGame, info []ObjInfo) int {
return idx
}
}
return 0 // bad pick_one: C resets to the start of the table
}
@@ -272,20 +399,27 @@ type invPage struct {
// (things.c discovered).
func (g *RogueGame) discovered() {
var ch byte
for {
discList := false
if !g.Options.Terse {
g.addmsg("for ")
g.addmsgf("for ")
}
g.addmsg("what type")
g.addmsgf("what type")
if !g.Options.Terse {
g.addmsg(" of object do you want a list")
g.addmsgf(" of object do you want a list")
}
g.msg("? (* for all)")
ch = g.readchar()
switch ch {
case Escape:
g.msg("")
return
case Potion, Scroll, Ring, Stick, '*':
discList = true
@@ -297,10 +431,12 @@ func (g *RogueGame) discovered() {
Potion, Scroll, Ring, Stick)
}
}
if discList {
break
}
}
if ch == '*' {
g.printDisc(Potion)
g.addLine("")
@@ -320,6 +456,7 @@ func (g *RogueGame) discovered() {
// (things.c print_disc).
func (g *RogueGame) printDisc(typ byte) {
var info []ObjInfo
switch typ {
case Scroll:
info = g.Items.Scrolls[:]
@@ -330,18 +467,23 @@ func (g *RogueGame) printDisc(typ byte) {
case Stick:
info = g.Items.Sticks[:]
}
order := make([]int, len(info))
g.setOrder(order)
obj := Object{Count: 1}
numFound := 0
for i := range info {
if info[order[i]].Know || info[order[i]].Guess != "" {
obj.Kind = objectKindForGlyph(typ)
obj.Which = order[i]
g.addLine("%s", g.invName(&obj, false))
g.addLine("%s", g.inventoryName(&obj, false))
numFound++
}
}
if numFound == 0 {
g.addLine("%s", g.nothing(typ))
}
@@ -353,6 +495,7 @@ func (g *RogueGame) setOrder(order []int) {
for i := range order {
order[i] = i
}
for i := len(order); i > 0; i-- {
r := g.rnd(i)
order[i-1], order[r] = order[r], order[i-1]
@@ -366,8 +509,8 @@ const flushSentinel = "\x00"
func (g *RogueGame) addLine(format string, a ...any) int {
pg := &g.invPage
prompt := "--Press space to continue--"
isFlush := format == flushSentinel
var line string
if !isFlush {
line = fmt.Sprintf(format, a...)
@@ -375,64 +518,107 @@ func (g *RogueGame) addLine(format string, a ...any) int {
if pg.lineCnt == 0 {
g.scr.Hw.Clear()
if g.Options.InvType == InvSlow {
g.Msgs.Mpos = 0
}
}
if g.Options.InvType == InvSlow {
if !isFlush && line != "" {
if g.msg("%s", line) == Escape {
return Escape
}
}
pg.lineCnt++
} else {
if !pg.init {
pg.maxlen = len(prompt)
pg.init = true
}
if pg.lineCnt >= NumLines-1 || isFlush {
if g.Options.InvType == InvOver && isFlush && !pg.newpage {
// Overlay the accumulated list in a box at the top right
// of the screen, prompt, and restore what was beneath.
g.msg("")
g.refresh()
saved := NewWindow(NumLines, NumCols)
saved.CopyFrom(g.scr.Std)
lx := NumCols - pg.maxlen - 2
for y := 0; y <= pg.lineCnt; y++ {
for x := 0; x <= pg.maxlen; x++ {
g.scr.Std.MvAddCh(y, lx+x, g.scr.Hw.MvInch(y, x))
}
}
g.scr.Std.MvAddStr(pg.lineCnt, lx, prompt)
g.refresh()
g.waitFor(' ')
g.scr.Std.CopyFrom(saved)
g.refresh()
} else {
g.scr.Hw.MvAddStr(NumLines-1, 0, prompt)
g.scr.RefreshWin(g.scr.Hw)
g.waitFor(' ')
g.scr.Hw.Clear()
g.refresh()
}
pg.newpage = true
pg.lineCnt = 0
pg.maxlen = len(prompt)
}
if !isFlush && !(pg.lineCnt == 0 && line == "") {
g.scr.Hw.MvAddStr(pg.lineCnt, 0, line)
pg.lineCnt++
if pg.maxlen < len(line) {
pg.maxlen = len(line)
}
pg.lastLine = line
return g.addLineSlow(line, isFlush)
}
g.addLinePaged(line, isFlush)
return ^Escape
}
// addLineSlow shows one discovery line as a message (the slow-inventory
// arm of things.c add_line).
func (g *RogueGame) addLineSlow(line string, isFlush bool) int {
if !isFlush && line != "" {
if g.msg("%s", line) == Escape {
return Escape
}
}
g.invPage.lineCnt++
return ^Escape
}
// addLinePaged accumulates discovery lines into the paged window,
// prompting between full pages (the windowed arm of things.c add_line).
func (g *RogueGame) addLinePaged(line string, isFlush bool) {
pg := &g.invPage
prompt := "--Press space to continue--"
if !pg.init {
pg.maxlen = len(prompt)
pg.init = true
}
if pg.lineCnt >= NumLines-1 || isFlush {
g.addLinePageBreak(prompt, isFlush)
}
if !isFlush && (pg.lineCnt != 0 || line != "") {
g.scr.Hw.MvAddStr(pg.lineCnt, 0, line)
pg.lineCnt++
if pg.maxlen < len(line) {
pg.maxlen = len(line)
}
pg.lastLine = line
}
}
// addLinePageBreak prompts at a full page and starts a fresh one
// (things.c add_line).
func (g *RogueGame) addLinePageBreak(prompt string, isFlush bool) {
pg := &g.invPage
if g.Options.InvType == InvOver && isFlush && !pg.newpage {
g.addLineOverlay(prompt)
} else {
g.scr.Hw.MvAddStr(NumLines-1, 0, prompt)
g.scr.RefreshWin(g.scr.Hw)
g.waitFor(' ')
g.scr.Hw.Clear()
g.refresh()
}
pg.newpage = true
pg.lineCnt = 0
pg.maxlen = len(prompt)
}
// addLineOverlay draws the accumulated list in a box at the top right
// of the screen, prompts, and restores what was beneath (things.c
// add_line).
func (g *RogueGame) addLineOverlay(prompt string) {
pg := &g.invPage
g.msg("")
g.refresh()
saved := NewWindow(NumLines, NumCols)
saved.CopyFrom(g.scr.Std)
lx := NumCols - pg.maxlen - 2
for y := 0; y <= pg.lineCnt; y++ {
for x := 0; x <= pg.maxlen; x++ {
g.scr.Std.MvAddCh(y, lx+x, g.scr.Hw.MvInch(y, x))
}
}
g.scr.Std.MvAddStr(pg.lineCnt, lx, prompt)
g.refresh()
g.waitFor(' ')
g.scr.Std.CopyFrom(saved)
g.refresh()
}
// flushLine is add_line(NULL): force out the accumulated page.
func (g *RogueGame) flushLine() int { return g.addLine(flushSentinel) }
@@ -447,6 +633,7 @@ func (g *RogueGame) endLine() {
g.flushLine()
}
}
pg.lineCnt = 0
pg.newpage = false
}
@@ -459,20 +646,24 @@ func (g *RogueGame) nothing(typ byte) string {
} else {
out = "Haven't discovered anything"
}
if typ != '*' {
var tystr string
switch typ {
case Potion:
tystr = "potion"
tystr = potionName
case Scroll:
tystr = "scroll"
tystr = scrollName
case Ring:
tystr = "ring"
tystr = ringName
case Stick:
tystr = "stick"
}
out += fmt.Sprintf(" about any %ss", tystr)
}
return out
}
@@ -480,20 +671,22 @@ func (g *RogueGame) nothing(typ byte) string {
// (things.c nameit).
func (g *RogueGame) nameit(pb *strings.Builder, obj *Object, typ, which string,
op *ObjInfo, prfunc func(*RogueGame, *Object) string) {
if op.Know || op.Guess != "" {
switch {
case op.Know || op.Guess != "":
if obj.Count == 1 {
fmt.Fprintf(pb, "A %s ", typ)
} else {
fmt.Fprintf(pb, "%d %ss ", obj.Count, typ)
}
if op.Know {
fmt.Fprintf(pb, "of %s%s(%s)", op.Name, prfunc(g, obj), which)
} else {
fmt.Fprintf(pb, "called %s%s(%s)", op.Guess, prfunc(g, obj), which)
}
} else if obj.Count == 1 {
case obj.Count == 1:
fmt.Fprintf(pb, "A%s %s %s", vowelstr(which), which, typ)
} else {
default:
fmt.Fprintf(pb, "%d %s %ss", obj.Count, which, typ)
}
}
@@ -505,13 +698,17 @@ func nullstr(*RogueGame, *Object) string { return "" }
// pr_list).
func (g *RogueGame) prList() {
if !g.Options.Terse {
g.addmsg("for ")
g.addmsgf("for ")
}
g.addmsg("what type")
g.addmsgf("what type")
if !g.Options.Terse {
g.addmsg(" of object do you want a list")
g.addmsgf(" of object do you want a list")
}
g.msg("? ")
ch := g.readchar()
switch ch {
case Potion:
@@ -533,14 +730,17 @@ func (g *RogueGame) prList() {
// (things.c pr_spec).
func (g *RogueGame) prSpec(info []ObjInfo) {
lastprob := 0
i := byte('0')
for idx := range info {
if i == '9'+1 {
i = 'a'
}
g.addLine("%c: %s (%d%%)", i, info[idx].Name, info[idx].Prob-lastprob)
lastprob = info[idx].Prob
i++
}
g.endLine()
}

1140
game/traps_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -90,54 +90,68 @@ const (
VsMagic = 3
)
// Flags for rooms (rogue.h)
// RoomFlags are the room state bits (rogue.h room flags).
type RoomFlags int16
// Room state bits (rogue.h ISDARK/ISGONE/ISMAZE).
const (
Dark RoomFlags = 1 << iota // room is dark
Gone // room is gone (a corridor)
Maze // room is a maze
)
func (f RoomFlags) Has(b RoomFlags) bool { return f&b != 0 }
func (f *RoomFlags) Set(b RoomFlags) { *f |= b }
func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *RoomFlags) Has(b RoomFlags) bool { return *f&b != 0 }
// Flags for objects (rogue.h)
// Set turns the given bits on.
func (f *RoomFlags) Set(b RoomFlags) { *f |= b }
// Clear turns the given bits off.
func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b }
// ObjFlags are the object state bits (rogue.h object flags).
type ObjFlags int32
// Object state bits (rogue.h).
const (
Cursed ObjFlags = 1 << iota // ISCURSED: object is cursed
Known // ISKNOW: player knows details about the object
Missile // ISMISL: object is a missile type
Stackable // ISMANY: object comes in groups
WasFound // ISFOUND (objects): object has been seen (ISFOUND shares the bit with creatures)
WasFound // ISFOUND (objects): seen; bit shared with creatures
Protected // ISPROT: armor is permanently protected
)
func (f ObjFlags) Has(b ObjFlags) bool { return f&b != 0 }
func (f *ObjFlags) Set(b ObjFlags) { *f |= b }
func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *ObjFlags) Has(b ObjFlags) bool { return *f&b != 0 }
// Flags for creatures (rogue.h). The C bit collisions are deliberate and
// preserved: one name of each pair applies to monsters, the other to the
// hero, and they never coexist on one creature.
// Set turns the given bits on.
func (f *ObjFlags) Set(b ObjFlags) { *f |= b }
// Clear turns the given bits off.
func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b }
// CreatureFlags are the creature state bits (rogue.h creature flags). The
// C bit collisions are deliberate and preserved: one name of each pair
// applies to monsters, the other to the hero, and they never coexist on
// one creature.
type CreatureFlags int32
// Creature state bits (rogue.h).
const (
CanConfuse CreatureFlags = 0o000001 // CANHUH: creature can confuse
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: creature can see invisible creatures
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: can see invisible creatures
Blind CreatureFlags = 0o000004 // ISBLIND: creature is blind
Cancelled CreatureFlags = 0o000010 // ISCANC: creature has special qualities cancelled
Cancelled CreatureFlags = 0o000010 // ISCANC: special qualities cancelled
Levitating CreatureFlags = 0o000010 // ISLEVIT: hero is levitating
Found CreatureFlags = 0o000020 // ISFOUND: creature has been seen
Greedy CreatureFlags = 0o000040 // ISGREED: creature runs to protect gold
Hasted CreatureFlags = 0o000100 // ISHASTE: creature has been hastened
Targeted CreatureFlags = 0o000200 // ISTARGET: creature is the target of an 'f' command
Targeted CreatureFlags = 0o000200 // ISTARGET: target of an 'f' command
Held CreatureFlags = 0o000400 // ISHELD: creature has been held
Confused CreatureFlags = 0o001000 // ISHUH: creature is confused
Invisible CreatureFlags = 0o002000 // ISINVIS: creature is invisible
Mean CreatureFlags = 0o004000 // ISMEAN: creature can wake when player enters room
Mean CreatureFlags = 0o004000 // ISMEAN: wakes when player enters room
Hallucinating CreatureFlags = 0o004000 // ISHALU: hero is on acid trip
Regenerates CreatureFlags = 0o010000 // ISREGEN: creature can regenerate
Awake CreatureFlags = 0o020000 // ISRUN: creature is running at the player
@@ -146,13 +160,20 @@ const (
Slowed CreatureFlags = 0o100000 // ISSLOW: creature has been slowed
)
func (f CreatureFlags) Has(b CreatureFlags) bool { return f&b != 0 }
func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b }
func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *CreatureFlags) Has(b CreatureFlags) bool { return *f&b != 0 }
// Flags for the level map (rogue.h)
// Set turns the given bits on.
func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b }
// Clear turns the given bits off.
func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b }
// PlaceFlags are the per-map-cell bits (rogue.h level map flags). The low
// bits double as the passage number (FPassNum) or trap kind (FTrapMask).
type PlaceFlags uint8
// Map cell bits (rogue.h).
const (
FPassage PlaceFlags = 0x80 // F_PASS: is a passageway
FSeen PlaceFlags = 0x40 // have seen this spot before
@@ -163,14 +184,20 @@ const (
FTrapMask PlaceFlags = 0x07 // F_TMASK: trap number mask
)
func (f PlaceFlags) Has(b PlaceFlags) bool { return f&b != 0 }
func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b }
func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b }
// Has reports whether any of the given bits are set.
func (f *PlaceFlags) Has(b PlaceFlags) bool { return *f&b != 0 }
// Set turns the given bits on.
func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b }
// Clear turns the given bits off.
func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b }
// TrapKind identifies a trap (rogue.h trap types). The kind is stored in
// the low bits of a map cell's PlaceFlags (FTrapMask).
type TrapKind int
// Trap kinds (rogue.h T_* constants).
const (
TrapDoor TrapKind = 0
TrapArrow TrapKind = 1
@@ -183,15 +210,6 @@ const (
NumTrapTypes = 8
)
// String returns the trap's display name, article included, as the C
// tr_name table had it.
func (t TrapKind) String() string {
if t < 0 || t >= NumTrapTypes {
return "a bizarre trap"
}
return trName[t]
}
// PotionKind identifies a potion (rogue.h potion types).
type PotionKind int
@@ -214,14 +232,6 @@ const (
NumPotionTypes
)
// String returns the potion's true name ("healing", "haste self", ...).
func (p PotionKind) String() string {
if p < 0 || p >= NumPotionTypes {
return "strange potion"
}
return basePotInfo[p].Name
}
// ScrollKind identifies a scroll (rogue.h scroll types).
type ScrollKind int
@@ -248,14 +258,6 @@ const (
NumScrollTypes
)
// String returns the scroll's true name ("magic mapping", ...).
func (s ScrollKind) String() string {
if s < 0 || s >= NumScrollTypes {
return "strange scroll"
}
return baseScrInfo[s].Name
}
// WeaponKind identifies a weapon (rogue.h weapon types).
type WeaponKind int
@@ -270,17 +272,12 @@ const (
WeaponDart
WeaponShuriken
WeaponSpear
WeaponFlame // fake entry for dragon breath (ick)
NumWeaponTypes = WeaponFlame
WeaponFlame // fake entry for dragon breath (ick)
)
// String returns the weapon's name ("mace", "two handed sword", ...).
func (w WeaponKind) String() string {
if w < 0 || w > WeaponFlame {
return "strange weapon"
}
return baseWeapInfo[w].Name
}
// NumWeaponTypes counts the real weapons; the flame pseudo-weapon sits
// just past them in the tables (C's MAXWEAPONS == FLAME).
const NumWeaponTypes = WeaponFlame
// ArmorKind identifies a suit of armor (rogue.h armor types).
type ArmorKind int
@@ -298,14 +295,6 @@ const (
NumArmorTypes
)
// String returns the armor's name ("ring mail", "plate mail", ...).
func (a ArmorKind) String() string {
if a < 0 || a >= NumArmorTypes {
return "strange armor"
}
return baseArmInfo[a].Name
}
// RingKind identifies a ring (rogue.h ring types).
type RingKind int
@@ -328,14 +317,6 @@ const (
NumRingTypes
)
// String returns the ring's true name ("add strength", "stealth", ...).
func (r RingKind) String() string {
if r < 0 || r >= NumRingTypes {
return "strange ring"
}
return baseRingInfo[r].Name
}
// WandKind identifies a wand or staff (rogue.h rod/wand/staff types).
type WandKind int
@@ -358,14 +339,6 @@ const (
NumWandTypes
)
// String returns the wand/staff's true name ("lightning", ...).
func (w WandKind) String() string {
if w < 0 || w >= NumWandTypes {
return "strange stick"
}
return baseWsInfo[w].Name
}
// Coord is a position on the level (rogue.h coord). A value type: the C
// ce(a,b) macro is plain == here.
type Coord struct {
@@ -419,12 +392,13 @@ type Stone struct {
}
// CTRL maps a letter to its control character, as the C CTRL() macro.
func CTRL(c byte) byte { return c & 0o37 }
func CTRL(c byte) byte { return c & 0o37 } //nolint:mnd // the C CTRL() mask
// distance returns the squared distance between two points (chase.c dist).
func distance(y1, x1, y2, x2 int) int {
dx := x2 - x1
dy := y2 - y1
return dx*dx + dy*dy
}
@@ -439,5 +413,6 @@ func sign(nm int) int {
case nm > 0:
return 1
}
return 0
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
import "fmt"
@@ -9,13 +10,15 @@ const noWeapon WeaponKind = -1
// missile fires a missile in a given direction (weapons.c missile).
func (g *RogueGame) missile(ydelta, xdelta int) {
// Get which thing we are hurling
obj := g.getItem("throw", KindWeapon)
if obj == nil {
obj, ok := g.promptPackItem("throw", KindWeapon)
if !ok {
return
}
if !g.dropCheck(obj) || g.isCurrent(obj) {
return
}
obj = g.leavePack(obj, true, false)
g.doMotion(obj, ydelta, xdelta)
// AHA! Here it has hit something. If it is a wall or a door, or if
@@ -33,51 +36,64 @@ func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) {
// Come fly with us ...
obj.Pos = p.Pos
for {
// Erase the old one
if obj.Pos != p.Pos && g.cansee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
if ch == Floor && !g.showFloor() {
ch = ' '
}
g.mvaddch(obj.Pos.Y, obj.Pos.X, ch)
}
g.eraseFlight(obj, p.Pos)
// Get the new position
obj.Pos.Y += ydelta
obj.Pos.X += xdelta
ch := g.Level.VisibleChar(obj.Pos.Y, obj.Pos.X)
if stepOk(ch) && ch != Door {
// It hasn't hit anything yet, so display it if it's alright.
if g.cansee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
g.refresh()
}
continue
if !stepOk(ch) || ch == Door {
break
}
// It hasn't hit anything yet, so display it if it's alright.
if g.canSee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
g.refresh()
}
break
}
}
// eraseFlight erases a flying object from its current square, unless it
// still sits on the hero (the erase step of weapons.c do_motion).
func (g *RogueGame) eraseFlight(obj *Object, heroPos Coord) {
if obj.Pos == heroPos || !g.canSee(obj.Pos.Y, obj.Pos.X) || g.Options.Terse {
return
}
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
if ch == Floor && !g.showFloor() {
ch = ' '
}
g.mvaddch(obj.Pos.Y, obj.Pos.X, ch)
}
// fall drops an item someplace around here (weapons.c fall).
func (g *RogueGame) fall(obj *Object, pr bool) {
if fpos, ok := g.fallpos(obj.Pos); ok {
pp := g.Level.At(fpos.Y, fpos.X)
pp.Ch = obj.Kind.Glyph()
obj.Pos = fpos
if g.cansee(fpos.Y, fpos.X) {
if g.canSee(fpos.Y, fpos.X) {
if pp.Monst != nil {
pp.Monst.OldCh = obj.Kind.Glyph()
} else {
g.mvaddch(fpos.Y, fpos.X, obj.Kind.Glyph())
}
}
attachObj(&g.Level.Objects, obj)
g.Level.AddObject(obj)
return
}
if pr {
if g.HasHit {
g.endmsg()
g.HasHit = false
}
g.msg("the %s vanishes as it hits the ground",
g.Items.Weapons[obj.Which].Name)
}
@@ -92,56 +108,67 @@ func (g *RogueGame) hitMonster(mp Coord, obj *Object) bool {
// wield pulls out a certain weapon (weapons.c wield).
func (g *RogueGame) wield() {
p := &g.Player
oweapon := p.CurWeapon
if !g.dropCheck(p.CurWeapon) {
p.CurWeapon = oweapon
return
}
p.CurWeapon = oweapon
obj := g.getItem("wield", KindWeapon)
if obj == nil {
obj, ok := g.promptPackItem("wield", KindWeapon)
if !ok {
g.After = false
return
}
if obj.Kind == KindArmor {
g.msg("you can't wield armor")
g.After = false
return
}
if g.isCurrent(obj) {
g.After = false
return
}
sp := g.invName(obj, true)
p.CurWeapon = obj
if !g.Options.Terse {
g.addmsg("you are now ")
if g.isCurrent(obj) {
g.After = false
return
}
sp := g.inventoryName(obj, true)
p.CurWeapon = obj
if !g.Options.Terse {
g.addmsgf("you are now ")
}
g.msg("wielding %s (%c)", sp, obj.PackCh)
}
// initWeaps is the weapons.c init_dam[] table.
var initWeaps = [NumWeaponTypes]struct {
// weaponSetup is one row of the weapons.c init_dam[] table (see
// gameData.initWeaps).
type weaponSetup struct {
dam DiceSpec // damage when wielded
hrl DiceSpec // damage when thrown
launch WeaponKind // launching weapon
flags ObjFlags
}{
{dice("2x4"), dice("1x3"), noWeapon, 0}, // WeaponMace
{dice("3x4"), dice("1x2"), noWeapon, 0}, // Long sword
{dice("1x1"), dice("1x1"), noWeapon, 0}, // WeaponBow
{dice("1x1"), dice("2x3"), WeaponBow, Stackable | Missile}, // WeaponArrow
{dice("1x6"), dice("1x4"), noWeapon, Missile}, // WeaponDagger
{dice("4x4"), dice("1x2"), noWeapon, 0}, // 2h sword
{dice("1x1"), dice("1x3"), noWeapon, Stackable | Missile}, // WeaponDart
{dice("1x2"), dice("2x4"), noWeapon, Stackable | Missile}, // Shuriken
{dice("2x3"), dice("1x6"), noWeapon, Missile}, // WeaponSpear
}
// initWeapon sets up a new weapon (weapons.c init_weapon).
func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) {
iwp := &initWeaps[which]
// init_dam[] has a row only for the real weapons: WeaponFlame (dragon
// breath) and anything past it have none. createObj rejects such a
// choice before calling here, so this arm is unreachable in practice;
// it exists so a malformed kind leaves the weapon untouched instead of
// panicking on the table read.
if which < 0 || int(which) >= int(NumWeaponTypes) {
return
}
iwp := &g.data.initWeaps[which]
weap.Kind = KindWeapon
weap.Which = int(which)
weap.Damage = iwp.dam
@@ -149,16 +176,19 @@ func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) {
weap.Launch = iwp.launch
weap.Flags = iwp.flags
weap.HPlus = 0
weap.DPlus = 0
if which == WeaponDagger {
switch {
case which == WeaponDagger:
weap.Count = g.rnd(4) + 2
weap.Group = g.Items.Group
g.Items.Group++
} else if weap.Flags.Has(Stackable) {
case weap.Flags.Has(Stackable):
weap.Count = g.rnd(8) + 8
weap.Group = g.Items.Group
g.Items.Group++
} else {
default:
weap.Count = 1
weap.Group = 0
}
@@ -170,6 +200,7 @@ func num(n1, n2 int, typ byte) string {
if typ == Weapon {
out += fmt.Sprintf(",%+d", n2)
}
return out
}
@@ -177,7 +208,9 @@ func num(n1, n2 int, typ byte) string {
// (weapons.c fallpos).
func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
var newpos Coord
cnt := 0
for y := pos.Y - 1; y <= pos.Y+1; y++ {
for x := pos.X - 1; x <= pos.X+1; x++ {
// check to make certain the spot is empty, if it is, put the
@@ -187,6 +220,7 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
y < 0 || x < 0 {
continue
}
ch := g.Level.Char(y, x)
if ch == Floor || ch == Passage {
if cnt++; g.rnd(cnt) == 0 {
@@ -196,5 +230,6 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
}
}
}
return newpos, cnt != 0
}

View File

@@ -1,3 +1,4 @@
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
package game
// wizard.c — special wizard commands, some of which are also non-wizard
@@ -8,87 +9,136 @@ package game
// create_obj).
func (g *RogueGame) createObj() {
obj := newObject()
g.msg("type of item: ")
obj.Kind = objectKindForGlyph(g.readchar())
g.Msgs.Mpos = 0
g.msg("which %c do you want? (0-f)", obj.Kind.Glyph())
ch := g.readchar()
if isDigit(ch) {
obj.Which = int(ch - '0')
} else {
obj.Which = int(ch-'a') + 10
}
obj.Group = 0
obj.Count = 1
g.Msgs.Mpos = 0
switch {
case obj.Kind == KindWeapon || obj.Kind == KindArmor:
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(Cursed)
}
if obj.Kind == KindWeapon {
g.initWeapon(obj, WeaponKind(obj.Which))
if bless == '-' {
obj.HPlus -= g.rnd(3) + 1
}
if bless == '+' {
obj.HPlus += g.rnd(3) + 1
}
} else {
obj.ArmorClass = aClass[obj.Which]
if bless == '-' {
obj.ArmorClass += g.rnd(3) + 1
}
if bless == '+' {
obj.ArmorClass -= g.rnd(3) + 1
}
}
case obj.Kind == KindRing:
switch obj.RingKind() {
case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage:
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(Cursed)
obj.Bonus = -1
} else {
obj.Bonus = g.rnd(2) + 1
}
case RingAggravateMonsters, RingTeleportation:
obj.Flags.Set(Cursed)
}
case obj.Kind == KindWand:
// Deliberate divergence from 5.4.4: C stored this nibble unchecked, so
// 'a'-'f' indexed straight past the ends of the per-kind static
// tables. Input outside '0'-'9' and 'a'-'f' overshoots much further:
// readchar returns a byte, so ch-'a' above is byte arithmetic and
// wraps instead of going negative ('A' gives 234, '!' gives 202).
// Reading past a static array was undefined behavior C happened to
// survive by picking up adjacent memory; in Go it is a panic that
// kills the process with the terminal still in raw mode. C had no
// defined behavior here to be faithful to, so the choice is rejected
// outright rather than emulating a garbage read. The check precedes
// every rnd() call below, so the RNG sequence is untouched either way.
if !obj.wizardCanCreate() {
g.msg("there is no such %s", obj.Kind)
return
}
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindWeapon, KindArmor:
g.createWeaponArmor(obj)
case KindRing:
g.createRing(obj)
case KindWand:
g.fixStick(obj)
case obj.Kind == KindGold:
case KindGold:
g.msg("how much?")
buf := ""
if g.getStr(&buf, g.scr.Std) == Norm {
obj.GoldValue = cAtoi(buf)
}
}
g.addPack(obj, false)
}
// createWeaponArmor sets up a wizard-created weapon or armor with an
// optional blessing (the weapon/armor arm of wizard.c create_obj).
func (g *RogueGame) createWeaponArmor(obj *Object) {
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(Cursed)
}
if obj.Kind == KindWeapon {
g.initWeapon(obj, WeaponKind(obj.Which))
if bless == '-' {
obj.HPlus -= g.rnd(3) + 1
}
if bless == '+' {
obj.HPlus += g.rnd(3) + 1
}
return
}
obj.ArmorClass = g.data.armorClass(obj.Which)
if bless == '-' {
obj.ArmorClass += g.rnd(3) + 1
}
if bless == '+' {
obj.ArmorClass -= g.rnd(3) + 1
}
}
// createRing sets up a wizard-created ring, prompting for a bonus on
// the bonus rings (the ring arm of wizard.c create_obj).
func (g *RogueGame) createRing(obj *Object) {
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.RingKind() {
case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage:
g.msg("blessing? (+,-,n)")
bless := g.readchar()
g.Msgs.Mpos = 0
if bless == '-' {
obj.Flags.Set(Cursed)
obj.Bonus = -1
} else {
obj.Bonus = g.rnd(2) + 1
}
case RingAggravateMonsters, RingTeleportation:
obj.Flags.Set(Cursed)
}
}
// showMap prints out the whole map for the wizard (wizard.c show_map).
func (g *RogueGame) showMap() {
hw := g.scr.Hw
hw.Clear()
for y := 1; y < NumLines-1; y++ {
for x := 0; x < NumCols; x++ {
real := g.Level.FlagsAt(y, x).Has(FReal)
if !real {
for x := range NumCols {
isReal := g.Level.FlagsAt(y, x).Has(FReal)
if !isReal {
hw.Standout(true)
}
hw.MvAddCh(y, x, g.Level.Char(y, x))
if !real {
if !isReal {
hw.Standout(false)
}
}
}
g.showWin("---More (level map)---")
}
@@ -97,33 +147,16 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) {
p := &g.Player
if len(p.Pack) == 0 {
g.msg("you don't have anything in your pack to identify")
return
}
var obj *Object
for {
obj = g.getItem("identify", kind)
if !insist {
break
}
if g.NObjs == 0 {
return
}
if obj == nil {
g.msg("you must identify something")
} else if kind != KindNone && obj.Kind != kind &&
!(kind == KindRingOrStick &&
(obj.Kind == KindRing || obj.Kind == KindWand)) {
g.msg("you must identify a %s", kind)
} else {
break
}
}
if obj == nil {
obj, ok := g.whatisPick(insist, kind)
if !ok {
return
}
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
switch obj.Kind {
case KindScroll:
setKnow(obj, g.Items.Scrolls[:])
@@ -136,7 +169,39 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) {
case KindRing:
setKnow(obj, g.Items.Rings[:])
}
g.msg("%s", g.invName(obj, false))
g.msg("%s", g.inventoryName(obj, false))
}
// whatisPick prompts for the item to identify, re-asking until a
// matching one is chosen when insist is set; ok is false when the
// player gives up (the prompt loop of wizard.c whatis).
func (g *RogueGame) whatisPick(insist bool, kind ObjectKind) (*Object, bool) {
for {
obj, _ := g.promptPackItem("identify", kind)
if !insist {
return obj, obj != nil
}
if g.NObjs == 0 {
return nil, false
}
if obj == nil {
g.msg("you must identify something")
continue
}
if !matchesFilter(kind, obj) {
g.msg("you must identify a %s", kind)
continue
}
return obj, true
}
}
// setKnow sets things up when we really know what a thing is (wizard.c
@@ -154,15 +219,18 @@ func setKnow(obj *Object, info []ObjInfo) {
func (g *RogueGame) teleport() {
p := &g.Player
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt())
c, _ := g.findFloor(nil, 0, true)
if g.roomin(c) != p.Room {
c, _ := g.findFloor(true)
if g.roomIn(c) != p.Room {
g.leaveRoom(p.Pos)
p.Pos = c
g.enterRoom(p.Pos)
} else {
p.Pos = c
g.look(true)
}
g.mvaddch(p.Pos.Y, p.Pos.X, PlayerCh)
// turn off ISHELD in case teleportation was done while fighting a
// Flytrap
@@ -171,6 +239,7 @@ func (g *RogueGame) teleport() {
p.VfHit = 0
g.Monsters['F'-'A'].Stats.Dmg = dice("000x0")
}
g.NoMove = 0
g.Count = 0
g.Running = false

532
game/wizard_test.go Normal file
View File

@@ -0,0 +1,532 @@
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
package game
import (
"encoding/gob"
"errors"
"os"
"path/filepath"
"testing"
)
// mustNotPanic runs fn and turns a panic into an ordinary test failure.
// The bug these tests cover (issue #10) panicked out of an array index,
// and an unrecovered panic would take the whole test binary down instead
// of reporting which dispatch regressed.
func mustNotPanic(t *testing.T, what string, fn func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Errorf("%s panicked: %v", what, r)
}
}()
fn()
}
// TestCreateObjWandFReproducer is the exact reported crash: wizard mode,
// C, '/' for a wand, 'f' for which. 'f' is nibble 15 and there are only
// NumWandTypes (14) wands, so fixStick used to index two past the end of
// ws_type[] and panic.
func TestCreateObjWandFReproducer(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
g.Wizard = true
before := len(g.Player.Pack)
setInput(t, g, '/', 'f')
mustNotPanic(t, "createObj with wand 'f'", g.createObj)
if len(g.Player.Pack) != before {
t.Errorf("out-of-range wand was added to the pack: %d items, want %d",
len(g.Player.Pack), before)
}
}
// TestCreateObjRejectsOutOfRangeWhich sweeps the rejection across every
// kind whose Which is a table index, including input outside '0'-'f'.
// isDigit is false for such input, so it takes the letter branch, where
// ch-'a' is byte arithmetic and wraps rather than going negative: 'A'
// (65) gives int(224)+10 == 234 and '!' (33) gives int(192)+10 == 202.
// Those far-past-the-end values, not negative ones, are what the guard
// has to catch on the keyboard path.
func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) {
t.Parallel()
cases := []struct {
name string
typ byte
which byte
}{
{"wand f is past NumWandTypes", Stick, 'f'},
{"potion f is past NumPotionTypes", Potion, 'f'},
{"ring f is past NumRingTypes", Ring, 'f'},
// NumScrollTypes is 18, past the 'f' the prompt tops out at, so a
// scroll can only be driven out of range by input that wraps.
{"scroll 'A' wraps to 234", Scroll, 'A'},
{"armor 9 is past NumArmorTypes", Armor, '9'},
{"weapon 9 is the flame pseudo-weapon", Weapon, '9'},
{"wand 'A' wraps to 234", Stick, 'A'},
{"wand '!' wraps to 202", Stick, '!'},
{"armor 'A' wraps to 234", Armor, 'A'},
{"weapon '!' wraps to 202", Weapon, '!'},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
g.Wizard = true
before := len(g.Player.Pack)
setInput(t, g, tc.typ, tc.which)
mustNotPanic(t, tc.name, g.createObj)
if len(g.Player.Pack) != before {
t.Errorf("pack grew to %d items, want %d: a rejected item was created",
len(g.Player.Pack), before)
}
})
}
}
// TestCreateObjAcceptsValidWhich pins the other half of the contract: the
// bounds check must not touch any in-range choice.
func TestCreateObjAcceptsValidWhich(t *testing.T) {
t.Parallel()
cases := []struct {
name string
typ byte
which byte
kind ObjectKind
want int
}{
{"wand of light", Stick, '0', KindWand, int(WandLight)},
{"potion 0", Potion, '0', KindPotion, int(PotionConfusion)},
{"scroll 9", Scroll, '9', KindScroll, int(ScrollIdentifyRingOrStick)},
{"ring d, the last ring", Ring, 'd', KindRing, int(NumRingTypes) - 1},
{"armor 7, the last armor", Armor, '7', KindArmor, int(NumArmorTypes) - 1},
{"weapon 8, the last real weapon", Weapon, '8', KindWeapon, int(WeaponSpear)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
g.Wizard = true
before := make(map[*Object]bool, len(g.Player.Pack))
for _, o := range g.Player.Pack {
before[o] = true
}
// The armor and weapon arms read one more character for the
// blessing prompt; 'n' means neither cursed nor blessed.
setInput(t, g, tc.typ, tc.which, 'n')
g.createObj()
if len(g.Player.Pack) != len(before)+1 {
t.Fatalf("pack has %d items, want %d: valid item not created",
len(g.Player.Pack), len(before)+1)
}
// addPack files the new item in kind order, so find it by
// identity rather than assuming it landed at the end.
var made *Object
for _, o := range g.Player.Pack {
if !before[o] {
made = o
}
}
if made.Kind != tc.kind || made.Which != tc.want {
t.Errorf("created %v which %d, want %v which %d",
made.Kind, made.Which, tc.kind, tc.want)
}
})
}
}
// TestCreateObjKeepsRNGSequence proves the guard costs no RNG draws: a
// rejected creation must leave the generator exactly where it was, or
// every later roll in the game would shift.
func TestCreateObjKeepsRNGSequence(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
g.Wizard = true
before := g.Rng.Seed
setInput(t, g, Stick, 'f')
g.createObj()
if g.Rng.Seed != before {
t.Errorf("rejected creation consumed RNG: seed %d, want %d",
g.Rng.Seed, before)
}
}
// malformed builds an object of the given kind whose Which sits one past
// the end of that kind's table — the state the wizard bug used to leave
// behind, and the state a corrupt save file could still describe.
func malformed(kind ObjectKind) *Object {
obj := newObject()
obj.Kind = kind
obj.Which = whichLimit(kind)
obj.Count = 1
return obj
}
// TestZapMalformedWandDoesNotPanic covers the sticks.go dispatch. C's
// do_zap matched no case and still ran o_charges--, so the charge must be
// spent even though the zap did nothing. What it says while doing nothing
// belongs to TestZapUnhandledWandSaysBizarreSchtick.
func TestZapMalformedWandDoesNotPanic(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
wand := malformed(KindWand)
wand.Charges = 3
ch := give(g, wand)
setInput(t, g, ch)
mustNotPanic(t, "doZap on a malformed wand", g.doZap)
if wand.Charges != 2 {
t.Errorf("charges = %d after zapping, want 2", wand.Charges)
}
}
// TestQuaffMalformedPotionDoesNotPanic covers the potions.go dispatch and
// the callIt lookup that follows it.
func TestQuaffMalformedPotionDoesNotPanic(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
before := len(g.Player.Pack)
ch := give(g, malformed(KindPotion))
setInput(t, g, ch)
mustNotPanic(t, "quaff of a malformed potion", g.quaff)
if len(g.Player.Pack) != before {
t.Errorf("pack has %d items, want %d: the potion was not consumed",
len(g.Player.Pack), before)
}
}
// TestReadMalformedScrollDoesNotPanic covers the scrolls.go dispatch and
// its callIt lookup.
func TestReadMalformedScrollDoesNotPanic(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
before := len(g.Player.Pack)
ch := give(g, malformed(KindScroll))
setInput(t, g, ch)
mustNotPanic(t, "readScroll of a malformed scroll", g.readScroll)
if len(g.Player.Pack) != before {
t.Errorf("pack has %d items, want %d: the scroll was not consumed",
len(g.Player.Pack), before)
}
}
// TestMalformedArmorDoesNotPanic covers the a_class[] reads: pricing at
// death, the identified-armor name, and the detect-magic test.
func TestMalformedArmorDoesNotPanic(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
armor := malformed(KindArmor)
armor.Flags.Set(Known)
mustNotPanic(t, "naming a malformed suit of armor", func() {
if got := g.inventoryName(armor, false); got != armor.Kind.String() {
t.Errorf("inventoryName = %q, want %q", got, armor.Kind.String())
}
})
mustNotPanic(t, "isMagic on a malformed suit of armor", func() {
g.isMagic(armor)
})
mustNotPanic(t, "appraising a malformed suit of armor", func() {
if worth := g.objectWorth(armor); worth != 0 {
t.Errorf("objectWorth = %d, want 0", worth)
}
})
if got := g.data.armorClass(armor.Which); got != 0 {
t.Errorf("armorClass(%d) = %d, want 0", armor.Which, got)
}
}
// TestMalformedWeaponDoesNotPanic covers the init_dam[] read. WeaponFlame
// is the first kind with no table row, so initWeapon must leave the
// object alone rather than index past the end.
func TestMalformedWeaponDoesNotPanic(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
weap := newObject()
mustNotPanic(t, "initWeapon with the flame pseudo-weapon", func() {
g.initWeapon(weap, WeaponFlame)
})
mustNotPanic(t, "initWeapon with a negative weapon kind", func() {
g.initWeapon(weap, WeaponKind(-1))
})
if weap.Kind != KindNone {
t.Errorf("weapon was initialized from a missing table row: kind %v",
weap.Kind)
}
}
// TestFixStickMalformedWhichDoesNotPanic covers the ws_type[] read that
// the reported reproducer actually crashed on.
func TestFixStickMalformedWhichDoesNotPanic(t *testing.T) {
t.Parallel()
g := mkGameInput(t)
wand := malformed(KindWand)
mustNotPanic(t, "fixStick on a malformed wand", func() {
g.fixStick(wand)
})
if wand.Damage.String() != "1x1" {
t.Errorf("damage = %q, want the wand damage %q",
wand.Damage.String(), "1x1")
}
}
// TestRestoreRejectsOutOfRangeWhich is the save-file half of the fix: a
// malformed object must not be able to sneak past the keyboard guard by
// arriving in a snapshot.
//
// A decoded save is also the only place a *negative* Which can come
// from. On the keyboard path createObj's ch-'a' is byte arithmetic and
// wraps, so 'A' and '!' land at 234 and 202; Which is a plain int in the
// gob stream, so a tampered file can carry any value at all. Both shapes
// are covered here, and the negative case is what exercises the
// Which >= 0 arm of hasValidWhich.
func TestRestoreRejectsOutOfRangeWhich(t *testing.T) {
t.Parallel()
cases := []struct {
name string
which int
}{
{"one past the wand table", int(NumWandTypes)},
{"the value 'A' wraps to on the keyboard path", 234},
{"the value '!' wraps to on the keyboard path", 202},
{"negative, reachable only from a tampered file", -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
g := mkGame(t, 11)
st := g.snapshot()
if len(st.Player.Body.Pack) == 0 {
t.Fatal("starting pack is empty; nothing to corrupt")
}
st.Player.Body.Pack[0].Kind = KindWand
st.Player.Body.Pack[0].Which = tc.which
path := filepath.Join(t.TempDir(), "rogue.save")
writeSnapshot(t, path, st)
_, restoreErr := Restore(path, Params{Term: &testTerm{}})
if !errors.Is(restoreErr, ErrSaveCorrupt) {
t.Errorf("Restore error = %v, want ErrSaveCorrupt", restoreErr)
}
_, statErr := os.Stat(path)
if statErr != nil {
t.Error("a rejected save file was deleted; it should be left alone")
}
})
}
}
// writeSnapshot gob-encodes a snapshot to path the way saveFile does.
func writeSnapshot(t *testing.T, path string, st *SaveState) {
t.Helper()
f, err := os.Create(path) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatal(err)
}
encErr := gob.NewEncoder(f).Encode(st)
if encErr != nil {
t.Fatal(encErr)
}
closeErr := f.Close()
if closeErr != nil {
t.Fatal(closeErr)
}
}
// TestWhichLimitCoversEveryIndexedTable pins the bounds table itself
// against the per-kind arrays it has to agree with.
func TestWhichLimitCoversEveryIndexedTable(t *testing.T) {
t.Parallel()
// len() of an array field is a compile-time constant, so the zero
// value is enough to read the table sizes off.
var it ItemLore
cases := []struct {
kind ObjectKind
size int
}{
{KindPotion, len(it.Potions)},
{KindScroll, len(it.Scrolls)},
{KindRing, len(it.Rings)},
{KindWand, len(it.Sticks)},
{KindArmor, len(it.Armors)},
{KindWeapon, len(it.Weapons)},
}
for _, tc := range cases {
if got := whichLimit(tc.kind); got != tc.size {
t.Errorf("whichLimit(%v) = %d, want the table size %d",
tc.kind, got, tc.size)
}
}
// Kinds whose Which is not a table index accept anything, as in C.
for _, kind := range []ObjectKind{KindFood, KindAmulet, KindGold, KindNone} {
obj := &Object{Kind: kind, Which: 99}
if !obj.hasValidWhich() {
t.Errorf("%v should not be bounds-checked on Which", kind)
}
}
}
// TestWizardToggleOffRehidesSensedMonsters drives '+' through command
// dispatch in wizard mode (command.c 317-338). Clearing the flag is the
// cheap half; the substantive half is turn_see(TRUE) — leaving wizard
// mode has to put the screen back, or there is no way out of wizard
// sight once it is on.
func TestWizardToggleOffRehidesSensedMonsters(t *testing.T) {
t.Parallel()
g := mkGame(t, 11)
g.Wizard = true
// A phantom carries ISINVIS straight from the monster table, so
// seeMonst is false for it and it is on screen only because wizard
// sight put it there.
tp := spawnAdjacent(g, 'P')
if !tp.On(Invisible) {
t.Fatal("phantom is not invisible; this test needs an unseeable monster")
}
if g.seeMonst(tp) {
t.Fatal("monster is ordinarily visible; wizard sight would reveal nothing")
}
if tp.OldCh == tp.Type {
t.Fatalf("map char under the monster is also %q; the redraw "+
"assertion would prove nothing", tp.Type)
}
g.turnSee(false)
if !g.Player.On(SenseMonsters) {
t.Fatal("turnSee(false) did not set SenseMonsters")
}
if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.Type {
t.Fatalf("wizard sight did not draw the monster: cell is %q, want %q",
ch, tp.Type)
}
if !g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout {
t.Fatal("wizard-sighted monster was not drawn in standout")
}
g.Msgs.Mpos = 0
g.After = true
g.dispatch('+')
if g.Wizard {
t.Error("'+' did not clear the wizard flag")
}
if g.Player.On(SenseMonsters) {
t.Error("'+' left SenseMonsters set: turn_see(TRUE) was not performed")
}
if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.OldCh {
t.Errorf("monster still on screen after leaving wizard mode: cell is "+
"%q, want the map char under it, %q", ch, tp.OldCh)
}
if g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout {
t.Error("cell left in standout after leaving wizard mode")
}
if g.Msgs.Huh != "not wizard any more" {
t.Errorf("message = %q, want %q", g.Msgs.Huh, "not wizard any more")
}
if g.After {
t.Error("'+' consumed a turn; C sets after = FALSE")
}
}
// TestWizardToggleWithoutWizardSaysSorry pins the other arm. C ran
// wizard = passwd() and said "sorry" when the answer was wrong; the
// password machinery is dropped, so that is the only outcome left. What
// it must not be any more is "illegal command '+'".
func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) {
t.Parallel()
g := mkGame(t, 12)
g.Wizard = false
g.Msgs.Mpos = 0
g.After = true
g.dispatch('+')
if g.Wizard {
t.Error("'+' entered wizard mode with no password check to pass")
}
if g.Player.On(SenseMonsters) {
t.Error("'+' turned on monster sense outside wizard mode")
}
if g.Msgs.Huh != "sorry" {
t.Errorf("message = %q, want %q", g.Msgs.Huh, "sorry")
}
if g.After {
t.Error("'+' consumed a turn; C sets after = FALSE")
}
}

View File

@@ -5,6 +5,8 @@
package term
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
@@ -21,6 +23,9 @@ type Tcell struct {
last *game.Window // last rendered window, for resize redraws
}
// ErrScreenTooSmall reports a terminal below the required 80x24.
var ErrScreenTooSmall = errors.New("screen too small")
// New initializes the terminal. The screen must be at least 80x24, as the
// C game required.
func New() (*Tcell, error) {
@@ -28,16 +33,22 @@ func New() (*Tcell, error) {
if err != nil {
return nil, err
}
if err := s.Init(); err != nil {
return nil, err
initErr := s.Init()
if initErr != nil {
return nil, initErr
}
w, h := s.Size()
if h < game.NumLines || w < game.NumCols {
s.Fini()
return nil, fmt.Errorf("sorry, the screen must be at least %dx%d",
game.NumLines, game.NumCols)
return nil, fmt.Errorf("sorry, %w: %dx%d required",
ErrScreenTooSmall, game.NumCols, game.NumLines)
}
s.HideCursor()
return &Tcell{screen: s}, nil
}
@@ -49,23 +60,38 @@ func (t *Tcell) Fini() {
// Render blits a game window to the terminal (curses refresh).
func (t *Tcell) Render(w *game.Window) {
t.last = w
rows, cols := w.Size()
for y := 0; y < rows; y++ {
for x := 0; x < cols; x++ {
for y := range rows {
for x := range cols {
ch, standout := w.CellAt(y, x)
style := tcell.StyleDefault
if standout {
style = style.Reverse(true)
}
t.screen.SetContent(x, y, rune(ch), nil, style)
}
}
t.screen.Show()
}
// Repaint redraws the whole physical screen from tcell's content buffer
// — which holds what Render last blitted, so this is C's clearok(curscr,
// TRUE) + wrefresh(curscr) (command.c, the CTRL('R') arm) rather than a
// fresh draw of stdscr. Sync throws away tcell's record of what the
// terminal is showing, so unlike Show it repaints cells it believes are
// already correct, which is what makes it fix a corrupted screen.
func (t *Tcell) Repaint() {
t.screen.Sync()
}
// ReadChar blocks for the next key, translated to the byte codes the C
// game reads: arrows become hjkl, control keys their C0 codes.
func (t *Tcell) ReadChar() byte {
// game reads: arrows become hjkl, control keys their C0 codes. ok is
// false when Interrupt woke the read instead of a key arriving.
func (t *Tcell) ReadChar() (byte, bool) {
for {
ev := t.screen.PollEvent()
switch ev := ev.(type) {
@@ -73,63 +99,135 @@ func (t *Tcell) ReadChar() byte {
if t.last != nil {
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:
switch ev.Key() {
case tcell.KeyUp:
return 'k'
case tcell.KeyDown:
return 'j'
case tcell.KeyLeft:
return 'h'
case tcell.KeyRight:
return 'l'
case tcell.KeyHome:
return 'y'
case tcell.KeyPgUp:
return 'u'
case tcell.KeyEnd:
return 'b'
case tcell.KeyPgDn:
return 'n'
case tcell.KeyEnter:
return '\n'
case tcell.KeyEscape:
return game.Escape
case tcell.KeyBackspace, tcell.KeyBackspace2:
return 8
case tcell.KeyDelete:
return 0x7f
case tcell.KeyTab:
return '\t'
case tcell.KeyCtrlC:
return 3
default:
if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ {
return byte(ev.Key())
}
if r := ev.Rune(); r > 0 && r < 0x80 {
return byte(r)
}
if b, ok := translateKey(ev); ok {
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
// for keys the C game does not understand.
func translateKey(ev *tcell.EventKey) (byte, bool) {
if b, ok := namedKey(ev.Key()); ok {
return b, true
}
if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ {
return byte(ev.Key()), true //nolint:gosec // G115: 1..26 fits
}
if r := ev.Rune(); r > 0 && r < 0x80 {
return byte(r), true
}
return 0, false
}
// namedKey translates tcell's navigation and editing keys to the single
// bytes the C game reads (arrows become hjkl, etc.); ok is false for
// keys handled elsewhere.
func namedKey(k tcell.Key) (byte, bool) {
if b, ok := motionKey(k); ok {
return b, true
}
return editingKey(k)
}
// motionKey translates the arrow and paging keys to Rogue's movement
// letters (tcell.go ReadChar).
func motionKey(k tcell.Key) (byte, bool) {
//nolint:exhaustive // translation table: all other keys fall through
switch k {
case tcell.KeyUp:
return 'k', true
case tcell.KeyDown:
return 'j', true
case tcell.KeyLeft:
return 'h', true
case tcell.KeyRight:
return 'l', true
case tcell.KeyHome:
return 'y', true
case tcell.KeyPgUp:
return 'u', true
case tcell.KeyEnd:
return 'b', true
case tcell.KeyPgDn:
return 'n', true
}
return 0, false
}
// editingKey translates the editing and control keys to their C0 codes
// (tcell.go ReadChar).
func editingKey(k tcell.Key) (byte, bool) {
//nolint:exhaustive // translation table: all other keys fall through
switch k {
case tcell.KeyEnter:
return '\n', true
case tcell.KeyEscape:
return game.Escape, true
case tcell.KeyBackspace, tcell.KeyBackspace2:
return '\b', true
case tcell.KeyDelete:
return '\x7f', true
case tcell.KeyTab:
return '\t', true
case tcell.KeyCtrlC:
return '\x03', true
}
return 0, false
}
// ShellEscape suspends the screen and runs the user's shell (main.c
// shell + md_shellescape).
func (t *Tcell) ShellEscape() {
if err := t.screen.Suspend(); err != nil {
err := t.screen.Suspend()
if err != nil {
return
}
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
fmt.Println("[Entering shell; exit to return to the game]")
cmd := exec.Command(shell)
_, _ = fmt.Fprintln(os.Stdout,
"[Entering shell; exit to return to the game]")
// The shell session has no deadline by design; Background context.
//nolint:gosec // G204: the user's own $SHELL
cmd := exec.CommandContext(context.Background(), shell)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
t.screen.Resume()
_ = cmd.Run() // best effort: the shell is the user's business
resumeErr := t.screen.Resume()
if resumeErr != nil {
panic(resumeErr) // terminal resume failure is unrecoverable
}
}