Compare commits
1 Commits
next
...
f602ecdbbf
| Author | SHA1 | Date | |
|---|---|---|---|
| f602ecdbbf |
@@ -1,8 +0,0 @@
|
|||||||
# Part of the lint gate: only what reaches the container is linted, so
|
|
||||||
# excluding a self-contained Go source here drops it from the lint silently.
|
|
||||||
# Never exclude Go sources, go.mod/go.sum or .golangci.yml.
|
|
||||||
.git
|
|
||||||
|
|
||||||
# Generated artifacts only; `make build` puts a multi-megabyte binary here
|
|
||||||
# and it would otherwise be shipped into the build context.
|
|
||||||
/build/
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,4 @@
|
|||||||
*.log
|
*.log
|
||||||
*.out
|
*.out
|
||||||
*.test
|
*.test
|
||||||
/build/
|
|
||||||
/rogue
|
/rogue
|
||||||
|
|||||||
134
ARCHITECTURE.md
134
ARCHITECTURE.md
@@ -1485,11 +1485,7 @@ needed function pointers for — so it is also what the save format stores.
|
|||||||
// The real game uses term.Tcell; tests use a scripted testTerm.
|
// The real game uses term.Tcell; tests use a scripted testTerm.
|
||||||
type Terminal interface {
|
type Terminal interface {
|
||||||
Render(w *Window) // blit a window to the device
|
Render(w *Window) // blit a window to the device
|
||||||
ReadChar() (byte, bool) // event loop → C char codes (arrows→hjkl, ^C→quit);
|
ReadChar() byte // event loop → C char codes (arrows→hjkl, ^C→quit)
|
||||||
// ok is false when Interrupt woke the read
|
|
||||||
Interrupt() // wake a blocked ReadChar (signal goroutine only)
|
|
||||||
Repaint() // forced full redraw for CTRL-R
|
|
||||||
// (curses clearok(curscr,TRUE)+wrefresh(curscr))
|
|
||||||
Fini() // restore the device (curses endwin) on exit
|
Fini() // restore the device (curses endwin) on exit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1509,97 +1505,26 @@ func (w *Window) Printwf(format string, a ...any); func (w *Window) Inch(y, x in
|
|||||||
func (w *Window) Clear(); func (w *Window) Clrtoeol(); func (w *Window) Standout(on bool)
|
func (w *Window) Clear(); func (w *Window) Clrtoeol(); func (w *Window) Standout(on bool)
|
||||||
func (s *Screen) Refresh() // blit stdscr to the device
|
func (s *Screen) Refresh() // blit stdscr to the device
|
||||||
func (s *Screen) RefreshWin(w *Window) // blit any window
|
func (s *Screen) RefreshWin(w *Window) // blit any window
|
||||||
func (s *Screen) Repaint() // force a full redraw of the device
|
|
||||||
func (s *Screen) Fini() // tear the device down
|
func (s *Screen) Fini() // tear the device down
|
||||||
```
|
```
|
||||||
|
|
||||||
Ported drawing code keeps its structure: `mvaddch(y, x, ch)` →
|
Ported drawing code keeps its structure: `mvaddch(y, x, ch)` →
|
||||||
`g.scr.Std.MvAddCh(y, x, ch)`. Curses `mvinch` reads come from the Window
|
`g.scr.Std.MvAddCh(y, x, ch)`. Curses `mvinch` reads come from the Window
|
||||||
buffer, preserving the "screen is a data structure" idiom without touching the
|
buffer, preserving the "screen is a data structure" idiom without touching the
|
||||||
real terminal. `Repaint` is the one drawing call that is not a blit: it is the
|
real terminal. `md_readchar`'s escape decoding is deleted; tcell's `EventKey`
|
||||||
`CTRL('R')` command's `clearok(curscr, TRUE)` plus `wrefresh(curscr)`,
|
provides decoded keys and we translate to the byte codes `command()` already
|
||||||
implemented as tcell's `Screen.Sync`, and it exists because `Render` cannot do
|
handles (KeyUp → 'k', etc.). Resize is handled by tcell, which registers only
|
||||||
its job. Both diff a new frame against the device's record of the old one and
|
SIGWINCH — SIGTSTP is not among the signals it takes, and is dropped (§9).
|
||||||
send nothing where they agree, which is the wrong answer precisely when the
|
|
||||||
screen has been corrupted by something else's output — the only reason a player
|
|
||||||
types `CTRL('R')`. Like C's, it repaints what was last drawn rather than
|
|
||||||
re-blitting `stdscr`, so it takes no window. `md_readchar`'s escape decoding is
|
|
||||||
deleted; tcell's `EventKey` provides decoded keys and we translate to the byte
|
|
||||||
codes `command()` already handles (KeyUp → 'k', etc.). Resize is handled by
|
|
||||||
tcell, which registers only SIGWINCH — SIGTSTP is not among the signals it
|
|
||||||
takes, and is dropped (§9).
|
|
||||||
|
|
||||||
Signals are handled in `cmd/rogue/main.go`: one `os/signal` channel read by one
|
Signals are handled in `cmd/rogue/main.go`: one `os/signal` channel read by one
|
||||||
goroutine that reads exactly one signal, so a second signal can never call
|
goroutine that reads exactly one signal, so a second signal can never call
|
||||||
`os.Exit` out from under an in-flight save. SIGHUP and SIGTERM autosave on the
|
`os.Exit` out from under an in-flight save. SIGHUP and SIGTERM `AutoSave` on the
|
||||||
way out (save.c `auto_save`); SIGINT and SIGQUIT restore the terminal and exit
|
way out (save.c `auto_save`); SIGINT and SIGQUIT restore the terminal and exit
|
||||||
without saving, matching C — where `auto_save` is reserved for HUP/TERM and
|
without saving, matching C — where `auto_save` is reserved for HUP/TERM and
|
||||||
neither `leave()` nor `quit()` nor `endit()` writes a save file — and keeping a
|
neither `leave()` nor `quit()` nor `endit()` writes a save file — and keeping a
|
||||||
deliberate interrupt from becoming a free checkpoint.
|
deliberate interrupt from becoming a free checkpoint. Every path restores the
|
||||||
|
terminal via `Terminal.Fini` before exiting, which is the whole point of C's
|
||||||
The signal goroutine does not write the save itself. It calls
|
`leave()`, "leave quickly but curteously".
|
||||||
`RogueGame.AutoSaveOnSignal`, which posts a request, wakes the input read
|
|
||||||
through `Terminal.Interrupt`, and waits for the game goroutine to take it or for
|
|
||||||
a deadline to run out; the encode happens on the game goroutine, the only one
|
|
||||||
that touches game state. It answers between turns (`command`), on waking from a
|
|
||||||
blocked `readchar`, and while parked in the `!` shell escape (`runShellEscape`)
|
|
||||||
— the three places it can sit for any length of time. Before issue #24 the
|
|
||||||
handler gob-encoded the live game tree from its own goroutine after removing the
|
|
||||||
save file, which raced every mutation the game was making and left a window with
|
|
||||||
no save file at all; `saveFile` now writes a temporary file in the save's own
|
|
||||||
directory and renames it over the target, so the previous save survives any
|
|
||||||
failure, including the deadline expiring with nothing written.
|
|
||||||
|
|
||||||
What that guarantees precisely, and what it does not: the encode runs on the one
|
|
||||||
goroutine that owns the state, so the snapshot is internally consistent and
|
|
||||||
always restorable. It is not guaranteed to be a between-commands snapshot. Only
|
|
||||||
one of the three service points gives that: the check at the top of `command`,
|
|
||||||
which runs after the previous command returned and before this turn's
|
|
||||||
`DoDaemons(Before)`/`DoFuses(Before)`. The other two are both reached from
|
|
||||||
inside a `command` call already under way. `readchar` is reached from prompts
|
|
||||||
raised part-way through a command (`--More--`, `askOverwrite`, `getStr`, the
|
|
||||||
direction and pack prompts), and the command has already mutated state by then —
|
|
||||||
`fight` sets `Count`/`Quiet` and runs `runTo` before any message, `revealXeroc`
|
|
||||||
writes `Disguise` before emitting one; the ordinary top-of-turn key read is
|
|
||||||
inside `command` too, after that turn's BEFORE daemons and `turnUpkeep`.
|
|
||||||
`runShellEscape` is no safer: `shell` is an ordinary command handler (`'!'` in
|
|
||||||
the dispatch table), reached through `executeCommand`, so a goroutine parked in
|
|
||||||
the shell escape has already run this turn's `DoDaemons(Before)`,
|
|
||||||
`DoFuses(Before)`, `turnUpkeep` and the last-command bookkeeping, and has not
|
|
||||||
yet run `DoDaemons(After)`, `DoFuses(After)` or `ringTurnEffects`. Restoring
|
|
||||||
re-enters `playit` at the top of `command` in either case, so the rest of that
|
|
||||||
command never runs — its AFTER daemons and fuses and its ring effects are lost —
|
|
||||||
and the restored game opens with a fresh BEFORE pass on top of the one already
|
|
||||||
in the snapshot: `rollwand`, a live BEFORE daemon once `swander` has fired,
|
|
||||||
ticks again, and any BEFORE fuse is decremented again. Not every consequence of
|
|
||||||
that pass is shared by both service points, though. `visuals` returns
|
|
||||||
immediately unless `g.After`, and `After` is part of the snapshot, so `DVisuals`
|
|
||||||
never re-ticks after a shell-escape save — `shell` sets `g.After = false` as its
|
|
||||||
first statement, before it parks — whereas after a `readchar` save it usually
|
|
||||||
does, because `turnUpkeep` sets `g.After = true` just before the top-of-turn
|
|
||||||
read; the exception is a handler that clears `After` before prompting, as
|
|
||||||
`identifyTrapCommand` does ahead of `promptDirection`. The result is a coherent
|
|
||||||
state one turn's worth of effects off, which is the price of being able to save
|
|
||||||
at all for a player whose line dropped mid-prompt or who is away in a shell.
|
|
||||||
|
|
||||||
The shell escape runs the shell on a helper goroutine so that the game goroutine
|
|
||||||
stays free to answer, but a panic out of `Terminal.ShellEscape` (which is how a
|
|
||||||
failed `Screen.Resume` is reported) is recovered there and re-raised on the game
|
|
||||||
goroutine. A panic reaching the top of a helper goroutine would kill the process
|
|
||||||
without running the main goroutine's `defer t.Fini()`, leaving the tty raw — the
|
|
||||||
failure issue #12 removed, on the one path where the terminal is already broken.
|
|
||||||
Re-raising keeps the invariant below true.
|
|
||||||
|
|
||||||
The handlers are installed immediately after `term.New()`, which is the call
|
|
||||||
that puts the tty in raw mode, and before the game exists — the saver is handed
|
|
||||||
over afterwards through `pendingSaver`. That ordering is what makes "every path
|
|
||||||
restores the terminal via `Terminal.Fini` before exiting" actually true: both
|
|
||||||
the save-restoring path and `-d`'s `DeathDemo()` run with the tty already raw,
|
|
||||||
and `DeathDemo()` never returns (it blocks in `waitFor` inside `death()`), so
|
|
||||||
handlers installed after them would leave that whole stretch unarmed. Restoring
|
|
||||||
the terminal is the part that must be armed the instant the tty goes raw, and it
|
|
||||||
needs no game; a signal arriving before the game is built restores and exits
|
|
||||||
with nothing to save. That is C's `leave()`, "leave quickly but curteously".
|
|
||||||
|
|
||||||
### 5.4 Messaging
|
### 5.4 Messaging
|
||||||
|
|
||||||
@@ -1678,7 +1603,7 @@ Tombstone/victory screens port verbatim from rip.c.
|
|||||||
## 6. C construct → Go construct map
|
## 6. C construct → Go construct map
|
||||||
|
|
||||||
| C construct | Go translation |
|
| C construct | Go translation |
|
||||||
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
| global variable | `RogueGame` field (or Player/Level/subsystem field) |
|
| global variable | `RogueGame` field (or Player/Level/subsystem field) |
|
||||||
| file-scope static | unexported field on `RogueGame` or subsystem struct |
|
| file-scope static | unexported field on `RogueGame` or subsystem struct |
|
||||||
| `THING` union | `Creature` / `Object` structs |
|
| `THING` union | `Creature` / `Object` structs |
|
||||||
@@ -1691,7 +1616,7 @@ Tombstone/victory screens port verbatim from rip.c.
|
|||||||
| damage strings `"3x4/1x2"` | parsed once into a `DiceSpec` (`[]DiceRoll`) by `ParseDice` at table time |
|
| damage strings `"3x4/1x2"` | parsed once into a `DiceSpec` (`[]DiceRoll`) by `ParseDice` at table time |
|
||||||
| curses stdscr/hw windows | `Window` cell buffers over tcell |
|
| curses stdscr/hw windows | `Window` cell buffers over tcell |
|
||||||
| `mvinch` screen reads | `Window.Inch` from the buffer |
|
| `mvinch` screen reads | `Window.Inch` from the buffer |
|
||||||
| signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine; the autosave is handed to the game goroutine and taken between turns or on waking a blocked `ReadChar`; tcell handles resize only |
|
| signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine → channel checked in ReadChar; tcell handles TSTP/resize |
|
||||||
| `setjmp`-free exits (`my_exit`, `exit()` everywhere) | `myExit` restores the terminal and calls `os.Exit(0)`; one game run is one process |
|
| `setjmp`-free exits (`my_exit`, `exit()` everywhere) | `myExit` restores the terminal and calls `os.Exit(0)`; one game run is one process |
|
||||||
| `vsprintf` message building | `fmt.Sprintf` |
|
| `vsprintf` message building | `fmt.Sprintf` |
|
||||||
| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) |
|
| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) |
|
||||||
@@ -1795,7 +1720,7 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7").
|
|||||||
| `md_readchar` escape decoding | tcell decodes keys | key-event translation table |
|
| `md_readchar` escape decoding | tcell decodes keys | key-event translation table |
|
||||||
| XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) |
|
| XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) |
|
||||||
| save-file symlink/hardlink checks | single-user era anti-cheat | none |
|
| save-file symlink/hardlink checks | single-user era anti-cheat | none |
|
||||||
| DES crypt wizard password (`passwd()`, the `'+'` enter arm) | ditto | `ROGUE_WIZARD` env var |
|
| DES crypt wizard password | ditto | `ROGUE_WIZARD` env var |
|
||||||
| load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none |
|
| load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none |
|
||||||
| tty dsusp/ltc character juggling | tcell owns the tty | none |
|
| tty dsusp/ltc character juggling | tcell owns the tty | none |
|
||||||
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell |
|
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell |
|
||||||
@@ -1804,16 +1729,6 @@ exit. Those are the steps referenced above (e.g. "step 5", "step 7").
|
|||||||
| SIGINT → the interactive `quit()` prompt | see below | `Q`; SIGINT exits cleanly |
|
| SIGINT → the interactive `quit()` prompt | see below | `Q`; SIGINT exits cleanly |
|
||||||
| `auto_save` on SIGILL/TRAP/FPE/BUS/SEGV/SYS | see below | none |
|
| `auto_save` on SIGILL/TRAP/FPE/BUS/SEGV/SYS | see below | none |
|
||||||
|
|
||||||
Only half of C's `'+'` command (`command.c` 317-338) goes with the password row.
|
|
||||||
The leave arm is ported in full as `wizardToggleCommand`: it clears the wizard
|
|
||||||
flag, calls `turnSee(true)` — C's `turn_see(TRUE)`, without which there is no
|
|
||||||
way back out of wizard sight once it is on — and prints "not wizard any more".
|
|
||||||
What `passwd()` takes with it is the enter arm. A password check that no longer
|
|
||||||
exists is a password check that can never succeed, so `'+'` outside wizard mode
|
|
||||||
reduces to what C did when the answer was wrong: the message "sorry", with no
|
|
||||||
prompt, since nothing typed into one could change the outcome, and none of the
|
|
||||||
`noscore`/`turn_see(FALSE)` bookkeeping of C's success branch.
|
|
||||||
|
|
||||||
The three signal rows warrant more than a table cell.
|
The three signal rows warrant more than a table cell.
|
||||||
|
|
||||||
**SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game
|
**SIGTSTP / `tstp()`.** Not handled, deliberately. Ctrl-Z cannot reach the game
|
||||||
@@ -1823,24 +1738,13 @@ clears `ISIG`, which is what makes `VSUSP` live), so Ctrl-Z arrives as key byte
|
|||||||
same effect. Only an explicit `kill -TSTP` can deliver it, which is not a player
|
same effect. Only an explicit `kill -TSTP` can deliver it, which is not a player
|
||||||
action. Handling it properly would mean calling `Screen.Suspend`/`Resume` from
|
action. Handling it properly would mean calling `Screen.Suspend`/`Resume` from
|
||||||
the signal goroutine while the game goroutine may be inside `Render` or
|
the signal goroutine while the game goroutine may be inside `Render` or
|
||||||
`PollEvent`. That is not a _data_ race — tcell guards `Suspend`/`Resume` and
|
`PollEvent` — a data race — so a correct port would have to plumb the signal
|
||||||
`Fini` alike with the screen mutex, which is also why the `Fini` this port does
|
through the input loop and handle it synchronously, a design change well beyond
|
||||||
call from the signal goroutine is safe — it is a _logical_ race over the screen
|
a signal-safety fix. C's own wiring here is vestigial: `tstp` is armed only by
|
||||||
state: the game goroutine can redraw into a screen the handler has just
|
`md_tstpresume()`, which runs after a successful `restore()`, so a freshly
|
||||||
suspended, or resume under a half-finished frame. Getting it right means
|
started C game never had a SIGTSTP handler either. `term.Tcell.ShellEscape` (the
|
||||||
plumbing the signal through the input loop and handling it synchronously. Half
|
`!` command) already covers getting to a shell and back, doing the same
|
||||||
of that plumbing now exists — `Terminal.Interrupt` wakes a blocked `ReadChar` so
|
suspend/resume dance synchronously on the game goroutine where it is safe.
|
||||||
the game goroutine can act on a signal (§5.3, issue #24) — but the suspend and
|
|
||||||
the resume still have to be sequenced against the game's own drawing, which is
|
|
||||||
the part that remains a design change rather than a signal-safety fix. C's own
|
|
||||||
wiring here is vestigial: `tstp` is armed only by `md_tstpresume()`, which runs
|
|
||||||
after a successful `restore()`, so a freshly started C game never had a SIGTSTP
|
|
||||||
handler either. `term.Tcell.ShellEscape` (the `!` command) already covers
|
|
||||||
getting to a shell and back. Since issue #24 the shell itself runs on a helper
|
|
||||||
goroutine (`runShellEscape`), so a hangup arriving while the player is away in
|
|
||||||
the shell still rescues the game; the game goroutine waits there without
|
|
||||||
drawing, so the suspend/resume dance is as undisturbed as it was when the call
|
|
||||||
ran inline.
|
|
||||||
|
|
||||||
**SIGINT → `quit()`.** Under `md_onsignal_autosave` C routed SIGINT into the
|
**SIGINT → `quit()`.** Under `md_onsignal_autosave` C routed SIGINT into the
|
||||||
interactive "really quit?" prompt. The port exits instead (after restoring the
|
interactive "really quit?" prompt. The port exits instead (after restoring the
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# Lint image, built by script/lint: golangci-lint runs as a build step, so
|
|
||||||
# a successful build is a clean lint.
|
|
||||||
|
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# This stage must stay the one that runs golangci-lint, and its name must
|
|
||||||
# match $stage in script/lint. --target halts the build at this stage, so
|
|
||||||
# moving the lint step to another stage, or adding a stage after this one,
|
|
||||||
# is not caught.
|
|
||||||
FROM deps AS lint
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
|
||||||
37
MEMORY.md
37
MEMORY.md
@@ -10,42 +10,7 @@ unlikely error returns through game code — e.g. write-side Close/encode failur
|
|||||||
where continuing would mean corrupt state. Return errors where a caller
|
where continuing would mean corrupt state. Return errors where a caller
|
||||||
genuinely handles them (save-file prompts, restore validation). Reserve
|
genuinely handles them (save-file prompts, restore validation). Reserve
|
||||||
deliberate `_ =` discards for true best-effort paths (scorefile writes,
|
deliberate `_ =` discards for true best-effort paths (scorefile writes,
|
||||||
`Terminal.Interrupt`'s post to a full event queue), always with a comment saying
|
signal-time autosave), always with a comment saying why.
|
||||||
why.
|
|
||||||
|
|
||||||
Signal-time autosave used to be on that list and no longer is (issue #24). It is
|
|
||||||
best effort in the sense that nothing can be reported to a player whose terminal
|
|
||||||
is already going away, but the outcome is a value, not a discard: the signal
|
|
||||||
goroutine calls `AutoSaveOnSignal`, which hands the save to the game goroutine —
|
|
||||||
the only one allowed to touch game state — and returns whether it was taken
|
|
||||||
before the deadline. The game answers between turns (`command`), while parked
|
|
||||||
waiting for a key (`readchar`), and while parked in the `!` shell escape
|
|
||||||
(`runShellEscape`). `saveFile` writes a temporary file and renames it over the
|
|
||||||
target, so a save that fails or never happens leaves the player's previous save
|
|
||||||
whole; never reintroduce a `Remove` before the write in `autoSave`, and never
|
|
||||||
encode game state from any goroutine but the game's.
|
|
||||||
|
|
||||||
Do not upgrade that into "the snapshot is always taken between commands" — it is
|
|
||||||
not. What is true is that the encode runs on the state-owning goroutine, so the
|
|
||||||
snapshot is internally consistent and restorable. Only the check at the top of
|
|
||||||
`command` is a between-commands snapshot; the other two service points both sit
|
|
||||||
inside a `command` call already under way. `readchar` is reached from
|
|
||||||
mid-command prompts (`--More--`, `askOverwrite`, `getStr`, direction and pack
|
|
||||||
prompts) with the command's mutations already applied, and `runShellEscape` is
|
|
||||||
reached from `shell`, an ordinary `'!'` command handler, with that turn's
|
|
||||||
`DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER pass not yet.
|
|
||||||
Restoring re-enters `playit` at the top of `command`, so either way the rest of
|
|
||||||
that command is lost and a fresh BEFORE pass runs on top of the one already in
|
|
||||||
the snapshot. That is acceptable and documented; two successive false claims —
|
|
||||||
first that `readchar` was safe, then that two of the three service points were
|
|
||||||
between-commands — were caught in review of PR #26, and neither may come back.
|
|
||||||
|
|
||||||
Related, and easy to reintroduce: work moved onto a helper goroutine must not be
|
|
||||||
allowed to panic there. A panic at the top of any goroutine kills the process
|
|
||||||
without running the other goroutines' defers, including `cmd/rogue/main.go`'s
|
|
||||||
`defer t.Fini()`, which is what leaves a raw tty (issue #12). `runShellEscape`
|
|
||||||
recovers its helper's panic and re-raises it on the game goroutine for exactly
|
|
||||||
that reason.
|
|
||||||
|
|
||||||
C's exit() calls are not unwound: one game run is one process, so myExit
|
C's exit() calls are not unwound: one game run is one process, so myExit
|
||||||
(game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and
|
(game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and
|
||||||
|
|||||||
47
Makefile
47
Makefile
@@ -1,47 +1,18 @@
|
|||||||
# Development convenience targets. This repo is exempt from the standard
|
# Development convenience targets. This repo is exempt from the standard
|
||||||
# policy scaffold (no CI config, no REPO_POLICIES.md, no application
|
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
|
||||||
# Dockerfile) except for the lint container: per sneak's 2026-08-09
|
# is only a thin wrapper around the Go toolchain, golangci-lint, and
|
||||||
# ruling, linting runs in docker only, so Dockerfile.lint and script/lint
|
# prettier so `make fmt` / `make check` behave the same as in sneak's
|
||||||
# are part of this repo. This Makefile is otherwise only a thin wrapper
|
# other repos.
|
||||||
# around the Go toolchain and prettier so `make fmt` / `make check` behave
|
|
||||||
# the same as in sneak's other repos.
|
|
||||||
|
|
||||||
GO_PKGS := ./...
|
GO_PKGS := ./...
|
||||||
MD_FILES := $(shell git ls-files '*.md')
|
MD_FILES := $(shell git ls-files '*.md')
|
||||||
PRETTIER := prettier --tab-width 4 --prose-wrap always
|
PRETTIER := prettier --tab-width 4 --prose-wrap always
|
||||||
|
|
||||||
# Every generated artifact goes here, and the whole directory is
|
.PHONY: check fmt fmt-check lint test
|
||||||
# git-ignored. Targets that write outside it can commit their output.
|
|
||||||
BUILD_DIR := build
|
|
||||||
BIN := $(BUILD_DIR)/rogue
|
|
||||||
COVERPROF := $(BUILD_DIR)/coverage.out
|
|
||||||
COVERHTML := $(BUILD_DIR)/coverage.html
|
|
||||||
|
|
||||||
.PHONY: build check cover cover-html fmt fmt-check lint test
|
# Format, lint, and test — the full local pre-commit gate.
|
||||||
|
|
||||||
# Format, lint, and test — the full local pre-commit gate. Keep this list
|
|
||||||
# to targets that write nothing into the working tree.
|
|
||||||
check: fmt-check lint test
|
check: fmt-check lint test
|
||||||
|
|
||||||
# Build the executable into $(BUILD_DIR). `go build -o` does not create the
|
|
||||||
# parent directory.
|
|
||||||
build:
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
go build -o $(BIN) ./cmd/rogue
|
|
||||||
|
|
||||||
# Per-function coverage, for finding which functions are untested. The
|
|
||||||
# percentage `make test` prints is a per-package total and cannot answer
|
|
||||||
# that. Writes files, so it stays out of `check`.
|
|
||||||
cover:
|
|
||||||
@mkdir -p $(BUILD_DIR)
|
|
||||||
go test -timeout 30s -coverprofile=$(COVERPROF) $(GO_PKGS)
|
|
||||||
go tool cover -func=$(COVERPROF)
|
|
||||||
|
|
||||||
# Render the same profile as annotated source.
|
|
||||||
cover-html: cover
|
|
||||||
go tool cover -html=$(COVERPROF) -o $(COVERHTML)
|
|
||||||
@echo "wrote $(COVERHTML)"
|
|
||||||
|
|
||||||
# Format Go and Markdown in place.
|
# Format Go and Markdown in place.
|
||||||
fmt:
|
fmt:
|
||||||
gofmt -w .
|
gofmt -w .
|
||||||
@@ -55,11 +26,9 @@ fmt-check:
|
|||||||
fi
|
fi
|
||||||
$(PRETTIER) --check $(MD_FILES)
|
$(PRETTIER) --check $(MD_FILES)
|
||||||
|
|
||||||
# Run the house linter. golangci-lint is never installed on the host: the
|
# Run the house linter (config in .golangci.yml).
|
||||||
# work happens inside the pinned container built by Dockerfile.lint, and
|
|
||||||
# this target is a thin shim over the script that builds it.
|
|
||||||
lint:
|
lint:
|
||||||
./script/lint
|
golangci-lint run $(GO_PKGS)
|
||||||
|
|
||||||
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
|
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
|
||||||
# full output and still fail the target (the first run already proved the
|
# full output and still fail the target (the first run already proved the
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -21,19 +21,19 @@ original program structure and the design of this port.
|
|||||||
Requires Go 1.25 or later and a terminal at least 80x24.
|
Requires Go 1.25 or later and a terminal at least 80x24.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build
|
go build ./cmd/rogue
|
||||||
./build/rogue
|
./rogue
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Restore a saved game
|
# Restore a saved game
|
||||||
./build/rogue ~/rogue.save
|
./rogue ~/rogue.save
|
||||||
|
|
||||||
# View high scores
|
# View high scores
|
||||||
./build/rogue -s
|
./rogue -s
|
||||||
|
|
||||||
# Test the death screen (demo mode)
|
# Test the death screen (demo mode)
|
||||||
./build/rogue -d
|
./rogue -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## In-game commands
|
## In-game commands
|
||||||
@@ -57,7 +57,7 @@ Press `?` in game for the full list.
|
|||||||
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
||||||
|
|
||||||
# Wizard (debug) mode, with a reproducible dungeon
|
# Wizard (debug) mode, with a reproducible dungeon
|
||||||
ROGUE_WIZARD=1 SEED=12345 ./build/rogue
|
ROGUE_WIZARD=1 SEED=12345 ./rogue
|
||||||
```
|
```
|
||||||
|
|
||||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
||||||
@@ -77,14 +77,10 @@ sequences, dungeon-generation golden checks, and an RNG compatibility test
|
|||||||
against the original C generator.
|
against the original C generator.
|
||||||
|
|
||||||
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
||||||
prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
|
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race
|
||||||
pinned container built from `Dockerfile.lint` — it is never installed on the
|
detector with coverage and a timeout), and `make check` (all three). Use the
|
||||||
host, so docker is required), `make test` (the suite, under the race detector
|
targets rather than invoking `go test` directly — they carry the flags the
|
||||||
with coverage and a timeout), `make check` (all three), `make build` (the
|
project relies on.
|
||||||
executable), and `make cover` / `make cover-html` (per-function coverage, and
|
|
||||||
the same profile as annotated source at `build/coverage.html`). Everything they
|
|
||||||
generate lands in the git-ignored `build/`. Use the targets rather than the
|
|
||||||
toolchain directly — they carry the flags the project relies on.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
656
TODO.md
656
TODO.md
@@ -29,582 +29,11 @@ Refactor ground rules:
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Tag a release once a full game (Amulet retrieval and score entry) completes
|
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
|
||||||
without defects. Promoted from Future Steps now that the coverage step above it
|
wizard commands).
|
||||||
is finished.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-10 `make cover` added (https://git.eeqj.de/sneak/rgoue/issues/17).
|
|
||||||
`make cover` writes `build/coverage.out` and prints the per-function report;
|
|
||||||
`make cover-html` renders the same profile to `build/coverage.html`. The
|
|
||||||
per-package percentage `make test` prints cannot say _which_ function is
|
|
||||||
untested, which is how the coverage gaps closed so far had to be found — by
|
|
||||||
grepping test files for identifiers.
|
|
||||||
|
|
||||||
Neither target is in `check`, and neither may be added to it: both write
|
|
||||||
files, and `make check` must not modify the working tree.
|
|
||||||
|
|
||||||
- 2026-08-10 `make build` added (https://git.eeqj.de/sneak/rgoue/issues/19). The
|
|
||||||
executable is built to `build/rogue`; `README.md` no longer contains a raw
|
|
||||||
`go` invocation anywhere. `build` is in neither `check` nor `test` —
|
|
||||||
`make check` stays `fmt-check lint test` and still writes nothing into the
|
|
||||||
working tree.
|
|
||||||
|
|
||||||
Generated artifacts now all live under `build/`, which `.gitignore` covers
|
|
||||||
as a whole. Anything written outside it is committable, so a target that
|
|
||||||
puts its output elsewhere reintroduces the stray-artifact problem.
|
|
||||||
|
|
||||||
- 2026-08-10 Linting moved into a container
|
|
||||||
(https://git.eeqj.de/sneak/rgoue/issues/41). `golangci-lint` is no longer
|
|
||||||
invoked on the host anywhere in the repo: `Dockerfile.lint` pins
|
|
||||||
`golangci/golangci-lint:v2.12.2` by digest and runs the linter as a build
|
|
||||||
step, so a successful build is a clean lint, and `make lint` is now a shim
|
|
||||||
over `script/lint`. This is what killed the false green seen earlier, where a
|
|
||||||
branch that was genuinely red with a `goconst` finding reported `0 issues` off
|
|
||||||
the shared host cache; a container per run has its own cache and lock.
|
|
||||||
|
|
||||||
`script/lint` builds with `--target "$stage"`, `--no-cache-filter="$stage"`
|
|
||||||
and `--output=type=cacheonly`. The durable property to check when touching
|
|
||||||
any of this: the lint stage executes on every run and is never served from
|
|
||||||
cache. Three things no tooling checks, left to whoever edits the gate —
|
|
||||||
`$stage` must match the stage name in `Dockerfile.lint`; that stage must
|
|
||||||
stay the one running `golangci-lint`, since `--target` halts the build
|
|
||||||
there; and `.dockerignore` governs what reaches the container, so excluding
|
|
||||||
a self-contained Go source drops it from the lint silently.
|
|
||||||
|
|
||||||
Verified rather than assumed, since a green docker build is the classic
|
|
||||||
false green: two consecutive runs on an unchanged tree each showed the
|
|
||||||
`golangci-lint run` layer executing and reporting `0 issues.` while the
|
|
||||||
`deps` layers reported `CACHED`; the same build with `--no-cache-filter`
|
|
||||||
removed reported that layer `CACHED`, so the re-execution is attributable to
|
|
||||||
the flag rather than to a changed context; deliberate violations failed the
|
|
||||||
build naming the specific finding and reverted clean; a stage-name typo
|
|
||||||
failed loudly at exit 1; and a Go file excluded via `.dockerignore` reported
|
|
||||||
`0 issues.` at exit 0 with the violation still in the tree. Wall-clock
|
|
||||||
durations vary per host and per run, so they are not recorded here.
|
|
||||||
|
|
||||||
- 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause
|
|
||||||
(`fix/autosave-turn-budget-36`, closes #36). The failure text was captured
|
|
||||||
before anything was changed and it is **not** a data race: the assertion was
|
|
||||||
`driveUntilDone`'s
|
|
||||||
`t.Fatal("the turn loop ran out of turns before the saves were taken")`, with
|
|
||||||
no `WARNING: DATA RACE` anywhere in the log. The handoff fixed in #24 was
|
|
||||||
working; the test's own drive loop was running out of its fixed 1000-turn
|
|
||||||
budget first.
|
|
||||||
|
|
||||||
Confirmed rather than taken on trust. Instrumenting the loop to report the
|
|
||||||
turns it actually used showed the count tracking scheduling pressure and
|
|
||||||
nothing else: about 60-120 turns at host load ~57 with the whole machine to
|
|
||||||
spread over, 418 at `GOMAXPROCS=4`, 539 and 655 at 2 and 1, and past 1000 —
|
|
||||||
the recorded failure — under the doubled load of the verbose rerun that the
|
|
||||||
test target performs after a failure. The turns between one save being
|
|
||||||
answered and the next request arriving are not work; they are the saving
|
|
||||||
goroutine's wake-up latency, so a fixed turn count is a wall-clock
|
|
||||||
assumption in disguise, which is why raising it would have hidden the flake
|
|
||||||
rather than fixed it.
|
|
||||||
|
|
||||||
So the budget is gone rather than larger. `driveUntilDone` now drives until
|
|
||||||
the saving goroutine finishes and nothing else. Termination is not lost, it
|
|
||||||
just belongs to the code under test instead of to the test: every
|
|
||||||
`AutoSaveOnSignal` returns within the timeout it is handed, so the saving
|
|
||||||
goroutine always finishes. A handoff that has stopped answering costs one
|
|
||||||
`autoSaveWait` in total — `g.sigSave` is one deep, so an unserviced request
|
|
||||||
stays in the channel and every later call finds it full and fails at once —
|
|
||||||
and the failure is then the real assertion (`saves taken = 0, want 25`)
|
|
||||||
instead of "out of turns". The worst case is not that one: a handoff that
|
|
||||||
drains each request but slower than `autoSaveWait` costs one timeout per
|
|
||||||
save, `wantSaves × autoSaveWait` = 250s, which would run past the 30s
|
|
||||||
package timeout instead of reaching the assertion. It takes ~10s of
|
|
||||||
scheduler starvation per save against a measured 0.12s per 1000 turns, so it
|
|
||||||
is remote, and the turn cap did not bound it either. The comment in the test
|
|
||||||
states that bound rather than the optimistic one.
|
|
||||||
|
|
||||||
Removing the cap exposed a second assumption underneath it, which is the
|
|
||||||
reason this is not a one-line diff. `testTerm` answers space and newline for
|
|
||||||
ever once its script is exhausted, and neither key takes a turn, so
|
|
||||||
`command()` — which loops until the player consumes one — never returns; the
|
|
||||||
old cap was silently sized to the script (4000 characters, two per turn,
|
|
||||||
against 1000 turns). An uncapped drive wedged inside a single `command()`
|
|
||||||
call. The two drive tests therefore use a new `driveTerm`, a headless
|
|
||||||
terminal whose script repeats. Repeating is necessary but not sufficient,
|
|
||||||
and the test says so: `' '` clears `After` outright and all eight movement
|
|
||||||
keys clear it on a refused step, so a script of only those keys wedges just
|
|
||||||
as `testTerm`'s tail did. What makes the wedge impossible is that the cycle
|
|
||||||
always holds an _unconditional_ turn-taker, and these scripts hold two —
|
|
||||||
`'.'` (empty handler) and `'s'` (`search`, which writes `After` on no path),
|
|
||||||
neither refusable by blocked-in-all-directions, `Held`, a bear trap, or
|
|
||||||
`NoCommand > 0`. Removing both would bring the wedge back.
|
|
||||||
|
|
||||||
Both halves of the definition of done were demonstrated by mutation, with
|
|
||||||
the deliberately-broken tree reverted afterwards and `.golangci.yml` left
|
|
||||||
byte-identical (sha256 `021cc83f...46bcb`). Reverting #24 —
|
|
||||||
`AutoSaveOnSignal` replaced by a direct `g.autoSave()`, encoding on the
|
|
||||||
calling goroutine — still fails the test with 139 `WARNING: DATA RACE`
|
|
||||||
reports naming `snapshotHeader` reading what `executeCommand` writes, so the
|
|
||||||
guard is undiminished. Removing the `serviceAutoSaveRequest` call from
|
|
||||||
`command()` still fails it too, now in 10s with `saves taken = 0, want 25`
|
|
||||||
rather than by hanging.
|
|
||||||
|
|
||||||
Under load, an A/B at `GOMAXPROCS=2` on a 48-core host at load ~150, with an
|
|
||||||
unrelated deliberate failure in the tree so that every run took the verbose
|
|
||||||
rerun: the old code failed 8 of 8 runs with "ran out of turns"; the new code
|
|
||||||
failed 0 of 8, the only failure being the planted one. Also green across 24
|
|
||||||
concurrent unconstrained runs at load ~120, 10 runs alongside a spinner
|
|
||||||
load, and 5 runs each at `GOMAXPROCS` 1, 2 and 4. `make check` green, lint 0
|
|
||||||
issues.
|
|
||||||
|
|
||||||
- 2026-08-09 Wizard commands under test (`test/wizard-coverage`, closes #7): the
|
|
||||||
last of the three thin spots, so the coverage step is now closed rather than
|
|
||||||
narrowed. `game/wizard.go`'s eight functions had no tests of their own, and
|
|
||||||
the file is not purely a debug surface — `set_know` writes the per-game
|
|
||||||
discovered tables that name items in ordinary play, and `teleport` is what the
|
|
||||||
teleport ring calls every fiftieth turn. Package coverage 60.6% -> 62.4%.
|
|
||||||
Everything expected was transcribed from `wizard.c`, `command.c` (the
|
|
||||||
`CTRL('I')` kit), `extern.c` (`a_class[]`), `weapons.c` (`init_dam[]`) and
|
|
||||||
`rogue.h`; 30 mutations were tried and all 30 were caught.
|
|
||||||
|
|
||||||
Two findings came out of the reading. (1) **A wizard-created cursed weapon
|
|
||||||
is not cursed, in C or here.** `create_obj` sets `ISCURSED` and then calls
|
|
||||||
`init_weapon`, which _assigns_ `weap->o_flags = iwp->iw_flags` and so
|
|
||||||
overwrites the bit it just set; only the `o_hplus` penalty survives, and the
|
|
||||||
"cursed" weapon can still be dropped and unwielded. The port reproduces this
|
|
||||||
exactly. The test asserts the whole flag word comes back as the `init_dam[]`
|
|
||||||
row's value whatever blessing was answered, and deleting the `ISCURSED` line
|
|
||||||
from the port leaves every weapon test green — which is the evidence that
|
|
||||||
the line is dead for weapons. The armor arm has no such clobber and does
|
|
||||||
keep the curse. (2) **`show_map`'s standout is asymmetric in C and symmetric
|
|
||||||
here.** C tests `!(real & F_REAL)` before drawing and `!real` — the whole
|
|
||||||
flag word — after. `new_level` seeds every square with `p_flags = F_REAL`,
|
|
||||||
and exactly three sites clear that bit. `passages.c putpass` sets `F_PASS`
|
|
||||||
first, so its secret passage is left at `0x80`. `passages.c door`'s
|
|
||||||
secret-door arm clears it on a room-wall exit whose flags are still exactly
|
|
||||||
`F_REAL` (`rooms.c` writes no `p_flags` at all), leaving `p_flags == 0`; its
|
|
||||||
per-square gate is `rnd(5) == 0` against `putpass`'s `rnd(40) == 0`, and
|
|
||||||
`game/passages.go`'s `door` reproduces it. `new_level`'s trap loop then ORs
|
|
||||||
in `rnd(NTRAPS)`, which is `abs((int) RN) % 8` and so yields `0..7`, and
|
|
||||||
`T_DOOR` is `00` — an unsprung trapdoor square is also exactly zero
|
|
||||||
(`be_trapped` is what later ORs `F_SEEN` into it). So C _does_ turn standout
|
|
||||||
off again, at secret doors and unsprung trapdoors; what it gets wrong is
|
|
||||||
leaking the attribute forward from a secret passage or a non-trapdoor trap
|
|
||||||
until it reaches one of those. Intermittent bands of reverse video, not a
|
|
||||||
permanently reversed map. `game/wizard.go` tests `isReal` both times and
|
|
||||||
highlights the one square. That is a display-only difference in a
|
|
||||||
wizard-only command and was reported on the issue rather than changed here;
|
|
||||||
the test asserts the map characters unconditionally but the standout
|
|
||||||
attribute only up to the first secret square, so it pins nothing that C
|
|
||||||
contradicts.
|
|
||||||
|
|
||||||
Two things the tests had to be built around. The `insist` arm of `whatis` is
|
|
||||||
a loop whose only exits are picking a matching item and `n_objs == 0`, so a
|
|
||||||
script that runs dry hangs instead of failing — every sequence that can
|
|
||||||
re-prompt ends in an abort tail, the `n_objs == 0` exit is reached the way a
|
|
||||||
player reaches it (`*` for a list with nothing appropriate in the pack)
|
|
||||||
rather than by poking the counter, and the one mutation that deletes that
|
|
||||||
exit is the only one of the 30 that fails by timeout instead of fast,
|
|
||||||
necessarily so. And `show_map` does **not** mark squares seen — it writes
|
|
||||||
into `hw` and touches no `PLACE` at all — so the issue's wording for it
|
|
||||||
could not be tested as written; the loop bounds are asserted instead by
|
|
||||||
planting a marker in the rows C's loop excludes, since those rows are blank
|
|
||||||
on a real level and copying blanks over blanks would have made the bound
|
|
||||||
unfalsifiable.
|
|
||||||
|
|
||||||
- 2026-08-09 Wands and staffs under test (`test/sticks-coverage`, closes #6):
|
|
||||||
the second of the three thin spots the Next Step names. `game/sticks.go` was
|
|
||||||
the largest under-tested file in the repo — 534 lines, 23 functions, one test
|
|
||||||
— and now has `game/sticks_test.go` (the zap handlers, `drain`, `fix_stick`,
|
|
||||||
`charge_str`) and `game/bolt_test.go` (the `fire_bolt` geometry). Every
|
|
||||||
expectation was read out of `sticks.c` rather than off the Go code; **no
|
|
||||||
divergence from C was found**, and three things worth knowing came out of the
|
|
||||||
reading. (1) **The bolt trail is the test instrument.** `fire_bolt` paints
|
|
||||||
each square with `dirch` and then paints `chat()` back over every square it
|
|
||||||
recorded, so on a screen nothing else has drawn on, the non-blank cells
|
|
||||||
afterwards are exactly the squares the bolt occupied — and the walls it
|
|
||||||
bounced off are absent, because C undoes the record with `c1--` and `break`s
|
|
||||||
before the `mvaddch`. That gives an exact assertion of the path and the
|
|
||||||
resting place without touching game code, and it is why the tests fire from a
|
|
||||||
square that is not the hero's (which is what `chase.c` does for dragon
|
|
||||||
breath): with the hero off the ray the run produces one message and the screen
|
|
||||||
stays readable. (2) **A bounce reverses both components of the direction, not
|
|
||||||
one.** A bolt entering a wall at 45 degrees goes back the way it came instead
|
|
||||||
of reflecting off the surface, so the diagonal-into-a-vertical-wall case is
|
|
||||||
the one that separates C's rule from the plausible wrong one, and it is
|
|
||||||
tested. (3) **The `ch != 'M'` guard on the miss message is a tautology.** `ch`
|
|
||||||
comes from `winat`, and `winat` _is_ `t_disguise` when a monster stands there
|
|
||||||
(`rogue.h` 57), so `ch == 'M'` implies `t_disguise == 'M'` and the arm can
|
|
||||||
never go quiet; it is vestigial from when 'M' was the mimic, and the test pins
|
|
||||||
the port to speaking, so nobody "tidies" it into a real silence. The
|
|
||||||
door-under-hero exception has no assertion of its own because it cannot have
|
|
||||||
one: without it the bolt bounces on the hero's own square forever, recording
|
|
||||||
nothing, and `fire_bolt` never returns — the test for it hangs rather than
|
|
||||||
fails, which the comment on it says. Determinism comes from a `pinRng` helper
|
|
||||||
that searches for a seed whose next draw is the wanted value (running the real
|
|
||||||
`Rng`, never predicting it) and from a level the tests carve themselves
|
|
||||||
through `drawRoom`, since bounce geometry and `drain`'s room/passage/door
|
|
||||||
reach only mean something against known walls and a known passage number. All
|
|
||||||
27 mutations tried against the new tests were caught.
|
|
||||||
|
|
||||||
- 2026-08-09 Trap unit-test coverage (`test/traps-coverage`, closes #14):
|
|
||||||
`trapHandlers` had eight entries and **zero** direct tests, on the one
|
|
||||||
subsystem besides combat that can kill the hero outright. New
|
|
||||||
`game/traps_test.go` (19 tests, 15 subtests) covers all eight arms of
|
|
||||||
`move.c be_trapped`, the prologue every trap runs through, and the
|
|
||||||
`rust_armor` tail `T_RUST` calls. Package coverage 56.2% -> 57.9% measured on
|
|
||||||
`main` at `bf820e3`, the branch point, before the sticks tests landed. Every
|
|
||||||
expected value is transcribed from `origin/c-master` and quoted in the file.
|
|
||||||
**No divergence from C was found.**
|
|
||||||
|
|
||||||
The issue body's trap list was wrong and the correction is the first thing
|
|
||||||
worth recording: there is no separate "poison dart" trap — `T_DART` **is**
|
|
||||||
the poisoned dart, its death message being "a poisoned dart killed you" —
|
|
||||||
and the list omitted `T_MYST`, the mystery trap, whose arm is an eleven-way
|
|
||||||
`rnd(11)` message switch. `rogue.h` 192-200 is the authority
|
|
||||||
(`T_DOOR`/`T_ARROW`/`T_SLEEP`/`T_BEAR`/`T_TELEP`/`T_DART`/`T_RUST`/`T_MYST`,
|
|
||||||
`NTRAPS` 8) and the Go `TrapKind` iota matches it index-for-index.
|
|
||||||
|
|
||||||
Three C details the tests are built around. (1) `BEARTIME` and `SLEEPTIME`
|
|
||||||
are `spread(3)` and `spread(5)` (`rogue.h` 108-109), and `spread` is
|
|
||||||
`nm - nm/20 + rnd(nm/10)`; for both, `nm/10` is 0 and C's `rnd` short
|
|
||||||
circuits a zero range without touching the generator, so each is an exact
|
|
||||||
constant that costs **no** random number — and the tests assert the no-draw
|
|
||||||
half as well as the value, because a stray draw desynchronises the
|
|
||||||
seed-compatible stream. (2) `T_ARROW` swings at `s_lvl - 1` and `T_DART` at
|
|
||||||
`s_lvl + 1`: opposite signs, which is exactly the kind of detail a
|
|
||||||
transliterating port drops. (3) The strength loss is gated on
|
|
||||||
`!ISWEARING(R_SUSTSTR) && !save(VS_POISON)`, and the `&&` is load-bearing —
|
|
||||||
with the ring on, C never rolls the save, so the arm must spend two random
|
|
||||||
numbers and not three.
|
|
||||||
|
|
||||||
Two shapes worth keeping, both forced by mutation results rather than
|
|
||||||
foresight. Damage dice are checked by a **sweep**, not one shot: `rnd(n)` is
|
|
||||||
"raw value % n", so a single draw agrees between a d6 and a d5 five times in
|
|
||||||
six and leaves the generator identical either way — the first draft's
|
|
||||||
single-trial arrow test passed with `roll(1,6)` mutated to `roll(1,5)`.
|
|
||||||
Likewise the swing arguments are pinned by a 200-trial boundary sweep at a
|
|
||||||
mid-range to-hit target: a forced hit and a forced miss cannot see a wrong
|
|
||||||
`at_lvl` or a dropped `op_arm`, because both arms are reachable at any level
|
|
||||||
and swing spends one `rnd(20)` regardless.
|
|
||||||
|
|
||||||
Mutation-proved, 33 mutations, each reverted, and every one of them is now
|
|
||||||
caught. Three were **not** caught on the first pass and the tests were
|
|
||||||
strengthened until they were, which is the useful part of the record. (a)
|
|
||||||
Deleting `new_level()` from `T_DOOR` left the suite green: `be_trapped`'s
|
|
||||||
own prologue stamps the trap glyph into the cell the hero fell through, so
|
|
||||||
"the map changed" is true even with no new level dug. The test now counts
|
|
||||||
differing cells — exactly one can change that way — and also requires the
|
|
||||||
staircase to move and the hero to be re-placed. (b) The `roll(1,6)` case
|
|
||||||
above.
|
|
||||||
|
|
||||||
(c) **`be_trapped` takes a coordinate, and which coordinate decides whether
|
|
||||||
`T_TELEP`'s `mvaddch(tc, TRAP)` does anything.** Deleting that line first
|
|
||||||
left the suite green, and the first draft wrote that off as an unavoidable
|
|
||||||
redundancy — wrongly, because the test only exercised one of the two call
|
|
||||||
sites. `move.go` 105-108 (`case Floor`) springs a trap under the hero and
|
|
||||||
passes `p.Pos`; there `tc` **is** the hero's square, the prologue has
|
|
||||||
already set its `p_ch` to `TRAP`, and `teleport()` opens by drawing
|
|
||||||
`floor_at()` — which returns `chat(hero)` — over it, so the glyph is on
|
|
||||||
screen before the line runs. But `move.go` 94-98 (`case Trap`), the ordinary
|
|
||||||
walk onto a hidden trap, passes `nh`, the square being stepped **onto**,
|
|
||||||
with the hero still on the previous square: `teleport()`'s opening `mvaddch`
|
|
||||||
paints the old square, `leave_room` writes blanks and never `TRAP`, and
|
|
||||||
nothing calls `look()` afterwards because the `case Trap` arm returns before
|
|
||||||
`finishMove` for a teleporter. There `mvaddch(tc, TRAP)` is the only writer,
|
|
||||||
exactly as C's comment says.
|
|
||||||
`TestTrapTeleportDrawsTheTrapOnTheSquareSteppedOnto` springs the trap at a
|
|
||||||
floor square next to the hero and pins it: unmutated the screen at `tc`
|
|
||||||
reads `^`, with the line deleted it reads `.`.
|
|
||||||
|
|
||||||
The other 30 each failed their own test and only their own; two also moved
|
|
||||||
`TestAutoSaveOnSignalRacesTurnLoop`, which drives real turns and is
|
|
||||||
legitimately sensitive to `BEARTIME` and to armor rusting.
|
|
||||||
|
|
||||||
Deliberately uncovered: the two death messages, "an arrow killed you" and "a
|
|
||||||
poisoned dart killed you". Each is printed immediately before `death()`,
|
|
||||||
which reaches `myExit` and `os.Exit`, so provoking either would take the
|
|
||||||
test binary with it; the hero is pinned with `fortify()` and the damage
|
|
||||||
rolls are checked by replaying C's arithmetic instead of by letting HP reach
|
|
||||||
zero. They are the only two: `rust_armor`'s `|| ISWEARING(R_SUSTARM)`
|
|
||||||
operand and its `if (!to_death)` suppression of the rust-vanishes message,
|
|
||||||
the last predicates that had no assertion, are pinned by
|
|
||||||
`TestTrapRustHonoursTheRingAndTheToDeathFlag`. This entry does **not**
|
|
||||||
rotate `Next Step`: #14 was an out-of-band gap found while surveying, not
|
|
||||||
part of the rings/sticks/wizard step.
|
|
||||||
|
|
||||||
- 2026-08-09 Ring unit-test coverage (`test/rings-coverage`, closes #5): the
|
|
||||||
first third of the standing coverage step. `game/rings.go` had **zero** tests
|
|
||||||
— not one of the 32 in the suite touched wear, removal, hand choice, or the
|
|
||||||
ring contribution to the hunger clock. New `game/rings_test.go` (17 tests, 44
|
|
||||||
subtests) covers `ringOn`, `pickRingHand`, `ringOff`, `gethand`, `ringEat` and
|
|
||||||
`ringNum`, plus the ring arm of `things.c dropcheck` (`dropRing`), which is
|
|
||||||
what actually takes a ring off. Package coverage 53.7% -> 56.2%. `Next Step`
|
|
||||||
narrowed rather than rotated: #6 and #7 are the other two thirds.
|
|
||||||
|
|
||||||
Every expected value is transcribed from `origin/c-master` (`rings.c`,
|
|
||||||
`rogue.h`, `things.c`), never from what the port returns, and the C is
|
|
||||||
quoted in the file. **No divergence from C was found**, which is the result
|
|
||||||
and is worth recording as a negative: `ringEat` is the one function here
|
|
||||||
whose being wrong would be invisible — it feeds `daemons.c`'s hunger clock,
|
|
||||||
so a bad entry is a slow drift in when the hero starves rather than anything
|
|
||||||
a playtest would notice — and it now has all fourteen ring kinds pinned to
|
|
||||||
C's table.
|
|
||||||
|
|
||||||
Three C details the tests were written around. (1) `ring_eat`'s `uses[]`
|
|
||||||
holds negatives, and a negative is **not** a cost: C computes
|
|
||||||
`eat = (rnd(-eat) == 0)`, a one-in-n chance of a single unit. (2) `R_DIGEST`
|
|
||||||
then flips the sign, so slow digestion returns 0 or **-1** and is the only
|
|
||||||
ring that gives food back. (3) `ring_num`'s switch closes with the
|
|
||||||
`otherwise` macro, which `rogue.h` 53 defines as `break;default` — so its
|
|
||||||
four labels fall through to one `sprintf` and every other kind returns `""`
|
|
||||||
from a default arm, not by falling off the end. The `RingKind` iota matches
|
|
||||||
C's `R_` numbering index-for-index, so a `uses[]` index and a `RingKind` are
|
|
||||||
the same number; `R_ADDHIT` is `RingDexterity` and `R_ADDDAM` is
|
|
||||||
`RingIncreaseDamage`.
|
|
||||||
|
|
||||||
The chance rings are checked two ways at once. Each call snapshots the
|
|
||||||
generator, runs `ringEat`, and replays C's own expression from the identical
|
|
||||||
state — which pins the one-in-n denominator, the sign flip, and the fact
|
|
||||||
that exactly one `rnd` call is spent — and a frequency check over 4000
|
|
||||||
trials backs it with a number a human can read. The non-negative entries
|
|
||||||
assert the reverse: the generator must be **untouched**, because C never
|
|
||||||
reaches `rnd` on that path and a stray call there would desynchronise the
|
|
||||||
whole game's RNG stream from C's and cost seed compatibility. That assertion
|
|
||||||
is what caught the one real bug in this work, which was in the test and not
|
|
||||||
the game: `g.Rng` is a pointer, so the first draft's snapshots aliased
|
|
||||||
instead of copying.
|
|
||||||
|
|
||||||
Two shapes worth keeping. Scripted hand answers carry an abort tail (a space
|
|
||||||
for the reprompt's `--More--`, then ESCAPE): without it a port that stopped
|
|
||||||
accepting a key would loop forever on the headless terminal's filler input
|
|
||||||
and the test would die of the 30s timeout instead of failing on its
|
|
||||||
assertion — which is exactly what the first draft did, and it was only
|
|
||||||
visible because the mutation run was inspected rather than trusted. And the
|
|
||||||
"only one hand free" case scripts the _wrong_ hand key deliberately: a port
|
|
||||||
that asked anyway consumes it and lands the ring on the wrong side, so the
|
|
||||||
test fails on a hand rather than on a hang.
|
|
||||||
|
|
||||||
Mutation-proved, 23 mutations, each reverted: breaking `pickRingHand`'s
|
|
||||||
ask/auto/reject arms, `ring_on`'s type guard, `is_current` guard and all
|
|
||||||
three effect arms, `ring_off`'s no-rings message, hand selection and ESCAPE
|
|
||||||
abort, `gethand`'s uppercase keys, ESCAPE and reprompt, `dropRing`'s hand
|
|
||||||
clearing and both effect arms, `dropcheck`'s cursed gate, three `ringUses`
|
|
||||||
entries, the `R_DIGEST` sign flip, the one-in-n roll, the empty-hand zero,
|
|
||||||
and `ring_num`'s `ISKNOW` guard, label set and `RING`-vs-`WEAPON`
|
|
||||||
formatting. Each failed its own test and only its own; stripping all three
|
|
||||||
`ring_on` effect arms failed 3 of 3. All fourteen ring kinds are exercised;
|
|
||||||
the eleven with no wear-time effect in C are documented at the foot of the
|
|
||||||
file as deliberately not given a wear/remove test, with the files their
|
|
||||||
powers actually live in, and `ring_off`'s unreachable "not wearing such a
|
|
||||||
ring" arm is documented as unreachable rather than left looking untested.
|
|
||||||
|
|
||||||
- 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes
|
|
||||||
#31): checked every case label in C's `command.c` against this port's
|
|
||||||
dispatch, and left the audit behind as a standing test
|
|
||||||
(`game/dispatch_test.go`) so the two cannot silently drift again. **No further
|
|
||||||
missing keys were found** — `'+'` (#11) was the only one. That is the result,
|
|
||||||
and it is worth recording as a negative: the class of bug exists, it has now
|
|
||||||
been searched for exhaustively rather than stumbled upon, and the search came
|
|
||||||
back empty. Three tables transcribe C's labels with their line numbers:
|
|
||||||
main-switch keys answered from `commandHandlers`, main-switch keys whose arms
|
|
||||||
need `dispatchKey`'s own switch (the `goto over` re-dispatches, `F`-to-`f`,
|
|
||||||
`a`, `m`), and the `if (wizard)` sub-switch. `commandHandlers` is pinned by
|
|
||||||
set equality in **both** directions: a missing key is the `'+'` bug, and an
|
|
||||||
extra key is the same bug mirrored — a MASTER debug command leaking into
|
|
||||||
ordinary play. Two traps make this audit harder than it sounds and are
|
|
||||||
documented in the file: `rogue.h` 52-53 defines `when` as `break;case`, so a
|
|
||||||
grep for `case ` finds ten of the eighty labels; and the main/wizard split is
|
|
||||||
load-bearing, since `'+'` was a divergence in ordinary play precisely because
|
|
||||||
it is a main-switch key. Confirms the port targets the MASTER build — all four
|
|
||||||
`#ifdef MASTER` sites in `command.c` are ported unconditionally, as is
|
|
||||||
`sticks.c` 237.
|
|
||||||
|
|
||||||
- 2026-08-09 Three small lost C behaviors (`fix/lost-c-behaviors`, closes #13):
|
|
||||||
grouped because each is a few lines and all are "restore something the port
|
|
||||||
dropped silently". (1) **"what a bizarre schtick!"**, `sticks.c` 237 — the
|
|
||||||
`otherwise` arm that closes `do_zap`'s switch, which `doZap` had turned into
|
|
||||||
doing nothing at all. Two things about it are easy to get wrong and are why
|
|
||||||
the fix is not one line. It is under `#ifdef MASTER`, **not** under a `wizard`
|
|
||||||
test, so in the MASTER build this port is it printed for every player — gating
|
|
||||||
it on `g.Wizard` would be issue #11's trap in reverse. And `WS_NOP` is a case
|
|
||||||
of that switch in its own right (`when WS_NOP: break;`), so "no handler ran"
|
|
||||||
cannot be the trigger: the wand of nothing does nothing _quietly_, and only a
|
|
||||||
kind C had no case for is bizarre. Since C's switch covers all 14 `WS_`
|
|
||||||
values, its `otherwise` is reachable only for an `o_which` outside the table,
|
|
||||||
which is exactly what `Object.hasValidWhich` already screens for — so the
|
|
||||||
split needed no new state, just a three-way switch on handler / valid-Which /
|
|
||||||
neither. All three arms fall through to `obj.Charges--`, as C's do: even the
|
|
||||||
bizarre schtick costs a charge. Replaces the deferral comment PR #20 left
|
|
||||||
there. (2) **`CTRL('R')` now actually redraws.** C is
|
|
||||||
`after = FALSE; clearok(curscr, TRUE); wrefresh(curscr);` (`command.c`
|
|
||||||
288-291); the port called `g.refresh()`, the ordinary diffing blit, **which
|
|
||||||
cannot fix the only situation the command exists for** — a screen corrupted by
|
|
||||||
something else's output leaves the game's record of it still correct, so the
|
|
||||||
diff sends nothing and the corruption stays. New `Terminal.Repaint` (tcell
|
|
||||||
`Screen.Sync`, which discards tcell's record of the terminal instead of
|
|
||||||
diffing against it), `Screen.Repaint`, `g.repaint()`; three implementations to
|
|
||||||
update, the same shape as PR #26's `ReadChar` change, so no split was needed.
|
|
||||||
Named for the curses operation, not for tcell: the interface is the game's
|
|
||||||
abstraction. It repaints what was last rendered — C repainted `curscr`, not
|
|
||||||
`stdscr` — so it takes no window, and the arm drops the `refresh()` C never
|
|
||||||
had there (`command` refreshes before the next key read anyway). (3) **The
|
|
||||||
startup greeting**, `main.c` 107-113, which existed nowhere in the tree. New
|
|
||||||
`game.Greeting`, printed by `cmd/rogue/main.go` before `term.New()` — the
|
|
||||||
port's `initscr()`. Only the wizard wording is `#ifdef MASTER`; the other is
|
|
||||||
unconditional. The `%d` is `dnum`, which `main.c` has just assigned to `seed`,
|
|
||||||
so it is `Params.Seed`. Two placement details the issue did not mention and
|
|
||||||
the tests now pin: the printf sits **after** `parse_opts`, so a ROGUEOPTS
|
|
||||||
`name=` is what the player is greeted by and the account name is only the
|
|
||||||
fallback (`Greeting` re-runs `ParseOpts`, which does nothing but assign into
|
|
||||||
fields — no RNG, no screen); and it sits after the `-s`/`-d` handling and
|
|
||||||
after `restore()`, which never returns, so a resumed game does not announce
|
|
||||||
that a dungeon is being dug (`digsNewDungeon`). The game `Greeting` parses
|
|
||||||
into is a throwaway but is built the way `New` builds the real one, tables and
|
|
||||||
home directory included, because `ParseOpts` handles every option and not just
|
|
||||||
the one the greeting reads: `inven=` is matched against `inv_t_name[]`, which
|
|
||||||
lives on the game, so a bare `&RogueGame{}` turned a legal `ROGUEOPTS` into a
|
|
||||||
nil dereference before the player saw a character. No RNG call is added on any
|
|
||||||
path and nothing under `game/testdata/` moved; `TestSeedCompatItemTables` is
|
|
||||||
green against the untouched golden. Mutation-proved, each new behaviour
|
|
||||||
deleted in turn and only its own test failing: dropping the message arm fails
|
|
||||||
`TestZapUnhandledWandSaysBizarreSchtick`; extending it to `WandNothing` fails
|
|
||||||
`TestZapWandOfNothingIsSilent`; putting `g.refresh()` back fails
|
|
||||||
`TestRedrawCommandForcesFullRepaint`; swapping the two wordings, or the
|
|
||||||
ROGUEOPTS name for the account name, fails `TestGreeting`; greeting on the
|
|
||||||
restore path fails `TestDigsNewDungeon`. ARCHITECTURE.md §5.3 gains `Repaint`
|
|
||||||
and the paragraph on why a blit cannot substitute for it. `Next Step`
|
|
||||||
deliberately not rotated: out-of-band issue work.
|
|
||||||
|
|
||||||
- 2026-08-09 The `'+'` wizard-mode toggle (`fix/wizard-toggle-off`, closes #11):
|
|
||||||
C's `command.c` 317-338 has a `when '+'` arm that leaves wizard mode —
|
|
||||||
`wizard = FALSE`, `turn_see(TRUE)`, `msg("not wizard any more")` — and the
|
|
||||||
port had no `'+'` anywhere, so the key fell through `dispatchKey`'s default to
|
|
||||||
`illcom` and answered "illegal command '+'". The password half of that arm was
|
|
||||||
dropped on purpose (wizard mode is `ROGUE_WIZARD` configuration) and is in
|
|
||||||
ARCHITECTURE.md §9; the leave half was lost silently and is not the same
|
|
||||||
decision — it does not touch the password machinery at all. **The substantive
|
|
||||||
part is `turn_see(TRUE)`**, not the flag: wizard sight draws every monster the
|
|
||||||
hero cannot see, so without the re-hide there is no way back to normal
|
|
||||||
visibility once wizard mode is on, and clearing the flag alone would have left
|
|
||||||
the screen lying. New `wizardToggleCommand` in `game/command.go`, registered
|
|
||||||
in `commandHandlers` between `'^'` and `Escape` — C's own switch order, and
|
|
||||||
note that C's arm sits in the **main** command switch under `#ifdef MASTER`,
|
|
||||||
not in the `if (wizard) switch (ch)` sub-switch that `wizardCommand` ports, so
|
|
||||||
it is reachable whether or not `wizard` is set. That makes the non-wizard case
|
|
||||||
a divergence too, and it resolves the way the dropped `passwd()` forces: a
|
|
||||||
password check that no longer exists can never succeed, so the else arm is
|
|
||||||
what C did on a wrong answer, the message "sorry" — no prompt, since nothing
|
|
||||||
typed into one could change the outcome, and none of the
|
|
||||||
`noscore`/`turn_see(FALSE)` bookkeeping of C's unreachable success branch. The
|
|
||||||
choice is stated in the function's doc comment and in §9, whose password row
|
|
||||||
now names the `'+'` enter arm and whose new paragraph records that the leave
|
|
||||||
arm is ported in full. Two tests in `game/wizard_test.go` drive `'+'` through
|
|
||||||
`g.dispatch`: the wizard one spawns a phantom (`ISINVIS` straight from the
|
|
||||||
monster table, so `seeMonst` is false and it is on screen only because wizard
|
|
||||||
sight put it there), asserts the precondition — monster glyph drawn in
|
|
||||||
standout at its cell, `SenseMonsters` set — and then asserts the flag cleared,
|
|
||||||
`SenseMonsters` cleared, the cell back to the map char under the monster with
|
|
||||||
standout off, the exact message, and `After` false; the non-wizard one pins
|
|
||||||
"sorry" and that `'+'` is no longer an illegal command. Mutation-proved:
|
|
||||||
deleting the `turnSee(true)` call fails the test on all three visibility
|
|
||||||
assertions, which is the half a flag-only test would have missed. No RNG call
|
|
||||||
is added — the `turn_off` arm of `turn_see` never reaches `rnd`, only the
|
|
||||||
turn-on arm does — and `TestSeedCompatItemTables` stays green against the
|
|
||||||
untouched golden. `Next Step` deliberately not rotated: out-of-band issue
|
|
||||||
work.
|
|
||||||
|
|
||||||
- 2026-08-09 Cleanups deferred from the PR #26 review (`cleanup/pr26-followups`,
|
|
||||||
closes #27): four items, no behaviour change. (1) The `sig-leave` entry below
|
|
||||||
still argued, in the present tense, that declining to save on SIGINT/SIGQUIT
|
|
||||||
was the safe choice because `AutoSave` encodes live state after removing the
|
|
||||||
file — both halves untrue since #24, and the entry read as a claim about how
|
|
||||||
the code works now rather than a record of what was weighed then. It is in the
|
|
||||||
past tense and marked superseded, pointing at the `fix/autosave-race` entry.
|
|
||||||
Nothing else in the file was touched — in particular the `err113` linter name
|
|
||||||
in the 2026-07-06 entry, which a `grep` for `113` still matches, and the "over
|
|
||||||
a hundred reports" wording the #26 rework had already corrected. Note for
|
|
||||||
anyone chasing this class of bug: the false claim was in the #12 entry, not
|
|
||||||
the #24 one, whose account of the old remove-then-write is correctly past
|
|
||||||
tense — find these by content, since `make fmt` reflows the file and cited
|
|
||||||
line numbers rot. (2) `encodeSnapshot` is `writeSnapshotFile`: it encodes,
|
|
||||||
fsyncs, chmods 0400 and closes, and the old name claimed only the first of
|
|
||||||
those. One call site (`saveFile`), and the doc comment now lists what it does
|
|
||||||
and why the fsync is there. (3) `TestAutoSaveOnSignalWhileInShellEscape` used
|
|
||||||
`t.Error` for its precondition, so a save that was never taken fell through
|
|
||||||
into `assertRestorable`, which can then only report a second, derived failure;
|
|
||||||
it is `t.Fatal`, matching the identical assertion in the blocked-on-input
|
|
||||||
test. (4) `serviceAutoSaveRequest`'s doc comment had a 24-column stub line
|
|
||||||
("The result is still a") left by an earlier edit — `gofmt` does not rewrap
|
|
||||||
comments, so `fmt-check` was legitimately green and nothing would ever have
|
|
||||||
caught it. Rewrapped to the block's width. `Next Step` deliberately not
|
|
||||||
rotated: out-of-band issue work.
|
|
||||||
|
|
||||||
- 2026-08-09 Signal-time autosave moved onto the game goroutine
|
|
||||||
(`fix/autosave-race`, closes #24): the SIGHUP/SIGTERM handler gob-encoded the
|
|
||||||
live game tree from the signal goroutine while the game goroutine was mid-turn
|
|
||||||
mutating it, and `AutoSave` **removed** the save file before encoding — so the
|
|
||||||
failure mode was not a stale save but a deleted one followed by a possibly
|
|
||||||
torn replacement, with a window in which the player had neither. `make test`
|
|
||||||
has run with `-race` since 2026-08-09 and was green, because no test had ever
|
|
||||||
driven the turn loop concurrently with a signal: evidence of untested, not of
|
|
||||||
safe. The handler now writes nothing itself. `AutoSaveOnSignal` posts a
|
|
||||||
request on a one-deep channel, wakes the input read, and waits up to
|
|
||||||
`signalSaveTimeout` (3s) for the game goroutine to take it; the encode happens
|
|
||||||
on the goroutine that owns the state. **The blocked-on-input case is the whole
|
|
||||||
point** — a dropped connection lands while the player is thinking, so a flag
|
|
||||||
checked only between turns would never be looked at — and it is handled by
|
|
||||||
making the read interruptible: `Terminal.ReadChar` returns `(byte, bool)` with
|
|
||||||
`ok == false` meaning "woken by `Interrupt`, no key", `term.Tcell.Interrupt`
|
|
||||||
posts a `tcell.EventInterrupt` onto tcell's own event queue to unpark
|
|
||||||
`PollEvent`, and `readchar` services the request and reads again, so no caller
|
|
||||||
sees the wake-up. The other unbounded park is the `!` shell escape, where a
|
|
||||||
hangup used to save and would otherwise have regressed to not saving: the
|
|
||||||
shell now runs on a helper goroutine and `runShellEscape` selects on {shell
|
|
||||||
finished, save request}, keeping the encode on the game goroutine while it
|
|
||||||
draws nothing. Between turns (`command`) covers a game that is busy rather
|
|
||||||
than parked. The wait is bounded so that a game goroutine wedged with no
|
|
||||||
service point can never stop a signal from getting the process out; giving up
|
|
||||||
costs nothing now that `saveFile` writes a temporary file in the save's own
|
|
||||||
directory, fsyncs it, and renames it over the target instead of truncating in
|
|
||||||
place — a failed or skipped save leaves the previous save whole. New
|
|
||||||
`game/autosave_test.go` drives the real turn loop while a second goroutine
|
|
||||||
asks for 25 saves (the interleaving that never existed before), plus the
|
|
||||||
parked-on-input case with a terminal fake that genuinely blocks, the shell
|
|
||||||
case, the deadline case (previous save byte-for-byte intact), the no-file-name
|
|
||||||
case, and the rename discipline — the last pinned by a handle opened before
|
|
||||||
the save, which still reads the old file whole after it. Each was
|
|
||||||
mutation-proved: reverting `AutoSaveOnSignal` to encode on the calling
|
|
||||||
goroutine (the pre-fix behavior) makes the turn-loop test fail under `-race`
|
|
||||||
with over a hundred reports, and removing each of the three service points
|
|
||||||
fails exactly the test for that park with its own message. `pendingSaver` now
|
|
||||||
reads the game out from under its mutex instead of delegating with it held,
|
|
||||||
because the delegated call blocks until the save is taken — the PR #23
|
|
||||||
review's N3 note, load-bearing rather than hypothetical, and pinned by a test.
|
|
||||||
The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering
|
|
||||||
guarantee are untouched; `savesOnSignal`'s third ground ("safety") is
|
|
||||||
rewritten, since the corruption window it weighed no longer exists.
|
|
||||||
`MEMORY.md` stops listing signal-time autosave among the deliberate `_ =`
|
|
||||||
discards and states the new discipline; `ARCHITECTURE.md` §5.3, the `Terminal`
|
|
||||||
sketch, the C-to-Go mapping row and §9's SIGTSTP paragraph are corrected to
|
|
||||||
match. Two things review caught and this entry records so they are not undone:
|
|
||||||
moving the shell onto a helper goroutine also moved `term.Tcell.ShellEscape`'s
|
|
||||||
`panic` on a failed `Screen.Resume` there, and a panic at the top of any
|
|
||||||
goroutine kills the process without running the deferred calls of the others —
|
|
||||||
including `cmd/rogue/main.go`'s `defer t.Fini()`, so the tty would have been
|
|
||||||
left raw on exactly the path where the terminal is already broken (issue #12's
|
|
||||||
failure, reintroduced on a new path). `runShellEscape` recovers the helper's
|
|
||||||
panic and re-raises it on the game goroutine, pinned by
|
|
||||||
`TestShellEscapePanicUnwindsTheGameGoroutine`. And the doc comment took two
|
|
||||||
rounds to get right: the first version claimed in four places that nothing is
|
|
||||||
half-mutated at the `readchar` service point, and the revision that fixed that
|
|
||||||
claimed two of the three service points were between-commands. Both are false.
|
|
||||||
Only the check at the top of `command` is between commands — `readchar` is
|
|
||||||
reached from mid-command prompts, and `runShellEscape` is reached from
|
|
||||||
`shell`, an ordinary `'!'` command handler dispatched inside `command`, with
|
|
||||||
that turn's `DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER
|
|
||||||
pass and ring effects not yet. What is actually guaranteed is that the encode
|
|
||||||
runs on the state-owning goroutine, so the snapshot is internally consistent
|
|
||||||
and restorable, though it may freeze a command half applied. `Next Step`
|
|
||||||
deliberately not rotated: out-of-band issue work.
|
|
||||||
|
|
||||||
- 2026-08-09 Signal-time terminal restore (`sig-leave`, closes #12): the port
|
- 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
|
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
|
tcell still holding the tty, leaving the user at a shell with no echo. All
|
||||||
@@ -618,26 +47,17 @@ is finished.
|
|||||||
HUP/TERM — and the semantics agree: HUP/TERM are involuntary teardown worth
|
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
|
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
|
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
|
anti-save-scum. It is also the safe choice: `AutoSave` gob-encodes live state
|
||||||
superseded: back then `AutoSave` gob-encoded live state that the main
|
that the main goroutine is still mutating, after removing the old file, so on
|
||||||
goroutine was still mutating, after removing the old file, so declining to
|
the signals with nothing to rescue the port takes the option with no
|
||||||
save on the signals with nothing to rescue was also the option with no
|
corruption window. The single-reader design closes the window the issue warned
|
||||||
corruption window. That window is gone as of the `fix/autosave-race` entry
|
about: a second signal arriving mid-save stays unread in the buffer instead of
|
||||||
above (#24) — the encode now runs on the game goroutine and `saveFile` renames
|
exiting out from under the writer (`TestLeaveOnSignalIgnoresLaterSignals`
|
||||||
a temporary file into place — so nothing here should be read as a statement
|
reproduces exactly that interleaving). New `cmd/rogue/main_test.go` covers the
|
||||||
about how saving works now; the split stands on C and on semantics alone, as
|
ordering for each signal, the save/no-save split against `savesOnSignal`, the
|
||||||
the current `savesOnSignal` comment says. The single-reader design closes the
|
mid-save-second-signal case, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered
|
||||||
window the issue warned about: a second signal arriving mid-save stays unread
|
to the test process through the same `notifySignals` wiring the game uses; the
|
||||||
in the buffer instead of exiting out from under the writer
|
tty leaving raw mode is the one step not checkable headlessly (it needs a
|
||||||
(`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
|
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
|
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
|
issue turned out to be wrong and are recorded in ARCHITECTURE.md: `leave()` is
|
||||||
@@ -646,23 +66,15 @@ is finished.
|
|||||||
`leave()` appears only in the endgame paths of `rip.c`/`main.c`), and Ctrl-C
|
`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
|
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
|
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
|
`raw()`. The real exposure is `kill -INT`/`kill -QUIT` and the window before
|
||||||
process group while the `!` shell escape has the screen suspended, and the
|
`term.New()`. ARCHITECTURE.md §9 gained rows for SIGTSTP/`tstp()` (dropped:
|
||||||
window **after** `term.New()`: nothing is raw before it, and the handlers used
|
raw mode means Ctrl-Z cannot reach us, a suspend from the signal goroutine
|
||||||
to be installed only once the game existed, leaving the restore path and
|
would race the drawing goroutine, and C armed `tstp` only after a `restore()`;
|
||||||
`-d`'s `DeathDemo()` — which never returns, blocking in `waitFor` inside
|
the `!` shell escape covers the need), for SIGINT not routing to the
|
||||||
`death()` — running raw with no handler at all. The handlers are therefore
|
interactive `quit()` prompt, and for `auto_save` on the fault signals; §5.3's
|
||||||
installed immediately after `term.New()`, with the game handed to them
|
claim that tcell handles SIGTSTP was false — tcell registers only SIGWINCH —
|
||||||
afterwards via `pendingSaver`; a signal before the game exists restores the
|
and is corrected. `Next Step` deliberately not rotated: out-of-band issue
|
||||||
terminal and exits with nothing to save, and the SIGHUP/SIGTERM autosave
|
work.
|
||||||
behavior on the play path is unchanged. ARCHITECTURE.md §9 gained rows for
|
|
||||||
SIGTSTP/`tstp()` (dropped: raw mode means Ctrl-Z cannot reach us, a suspend
|
|
||||||
from the signal goroutine would race the drawing goroutine, and C armed `tstp`
|
|
||||||
only after a `restore()`; the `!` shell escape covers the need), for SIGINT
|
|
||||||
not routing to the interactive `quit()` prompt, and for `auto_save` on the
|
|
||||||
fault signals; §5.3's claim that tcell handles SIGTSTP was false — tcell
|
|
||||||
registers only SIGWINCH — and is corrected. `Next Step` deliberately not
|
|
||||||
rotated: out-of-band issue work.
|
|
||||||
|
|
||||||
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
|
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
|
||||||
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
|
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
|
||||||
@@ -754,11 +166,7 @@ is finished.
|
|||||||
24 long lines wrapped or their comments tightened, control bytes in
|
24 long lines wrapped or their comments tightened, control bytes in
|
||||||
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
|
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
|
||||||
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
|
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
|
||||||
`make lint` runs whatever `golangci-lint` is on the host). Superseded
|
`make lint` runs whatever `golangci-lint` is on the host).
|
||||||
2026-08-10: there is a pin now, and no host lint path — `Dockerfile.lint` pins
|
|
||||||
the linter image by digest and `script/lint` runs it in a container. See the
|
|
||||||
2026-08-10 entry at the top of this section
|
|
||||||
(https://git.eeqj.de/sneak/rgoue/issues/41).
|
|
||||||
|
|
||||||
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
|
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
|
||||||
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
|
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
|
||||||
@@ -902,17 +310,13 @@ is finished.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
1. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
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
|
per-game dungeon dimensions instead of the 80x24 constants; open design
|
||||||
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
||||||
80x24 mode.
|
80x24 mode.
|
||||||
2. Note: this repo is exempt from the standard policy scaffold, but the
|
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
||||||
exemption is narrower than it was. A minimal dev Makefile
|
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
||||||
(fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07
|
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
||||||
request. `Dockerfile.lint` and `script/lint` are now also permitted, and
|
REPO_POLICIES.md.
|
||||||
required, along with the `.dockerignore` that scopes their build context:
|
|
||||||
sneak's 2026-08-09 ruling (https://git.eeqj.de/sneak/rgoue/issues/41) is that
|
|
||||||
every repo lints in a container invoked through `script/lint`, and being
|
|
||||||
later and explicit it overrides the 2026-07-07 exemption for those three
|
|
||||||
files only. Still do not add: CI config, `REPO_POLICIES.md`, an application
|
|
||||||
`Dockerfile`, or any other `script/` entrypoint.
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"os/user"
|
"os/user"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,15 +39,6 @@ func run() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// C printed its greeting just before initscr(); here that means just
|
|
||||||
// before the tcell screen takes the terminal, and on stdout, exactly
|
|
||||||
// as C did (main.c main). C followed the printf with fflush because
|
|
||||||
// its stdout was buffered; os.Stdout is not, so the write is the
|
|
||||||
// flush.
|
|
||||||
if digsNewDungeon(*deathDemo, flag.Args()) {
|
|
||||||
_, _ = fmt.Fprint(os.Stdout, game.Greeting(params)) // CLI output
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := term.New()
|
t, err := term.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
@@ -57,13 +47,6 @@ func run() int {
|
|||||||
}
|
}
|
||||||
defer t.Fini()
|
defer t.Fini()
|
||||||
|
|
||||||
// Armed here, the instant the tty goes raw, not after the game is
|
|
||||||
// built: everything below this line — the restore, the death demo
|
|
||||||
// (which never returns), the game itself — would otherwise run raw
|
|
||||||
// with no handler installed. There is no game to save yet, so the
|
|
||||||
// saver is filled in below once there is one.
|
|
||||||
pending := installSignalHandlers(t)
|
|
||||||
|
|
||||||
params.Term = t
|
params.Term = t
|
||||||
|
|
||||||
var g *game.RogueGame
|
var g *game.RogueGame
|
||||||
@@ -81,38 +64,18 @@ func run() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *deathDemo {
|
if *deathDemo {
|
||||||
// The demo is left without a saver on purpose: a signal still
|
|
||||||
// restores the terminal, but a throwaway demo game is not worth
|
|
||||||
// writing over the player's save file.
|
|
||||||
g.DeathDemo() // does not return: death exits the process
|
g.DeathDemo() // does not return: death exits the process
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.set(g)
|
installSignalHandlers(g, t)
|
||||||
|
|
||||||
g.Run() // does not return: the game ends by exiting the process
|
g.Run() // does not return: the game ends by exiting the process
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// digsNewDungeon reports whether this invocation is the one that digs a
|
|
||||||
// fresh dungeon, and so the only one that greets.
|
|
||||||
//
|
|
||||||
// C's printf is the last statement before initscr(), and everything that
|
|
||||||
// does something else has already left by then: -s scores and exits, -d
|
|
||||||
// runs the death demo and exits, and restore() — the argc == 2 case that
|
|
||||||
// is neither — never returns. So a saved game resumes without a greeting,
|
|
||||||
// which is right: nothing is being dug.
|
|
||||||
//
|
|
||||||
// The restore test is duplicated from run's own, deliberately. Keeping
|
|
||||||
// them as one predicate would mean deciding the startup path before the
|
|
||||||
// terminal exists and carrying it past the error returns, which is more
|
|
||||||
// rearrangement of run than a greeting is worth.
|
|
||||||
func digsNewDungeon(deathDemo bool, args []string) bool {
|
|
||||||
return !deathDemo && len(args) != 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadParams gathers the game parameters from the environment: home
|
// loadParams gathers the game parameters from the environment: home
|
||||||
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
|
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
|
||||||
// (main.c's startup).
|
// (main.c's startup).
|
||||||
@@ -141,32 +104,11 @@ func loadParams() game.Params {
|
|||||||
// saver is the autosave half of *game.RogueGame that the signal handler
|
// saver is the autosave half of *game.RogueGame that the signal handler
|
||||||
// needs; an interface so the handler is testable headlessly.
|
// needs; an interface so the handler is testable headlessly.
|
||||||
type saver interface {
|
type saver interface {
|
||||||
// AutoSaveOnSignal asks the game goroutine to write the save file and
|
// AutoSave writes the game to its save file, best effort (save.c
|
||||||
// waits up to timeout for it, reporting whether the save ran (save.c
|
// auto_save).
|
||||||
// auto_save). The handler never encodes anything itself; see
|
AutoSave()
|
||||||
// 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
|
// finisher is the terminal-restoring half of game.Terminal that the
|
||||||
// signal handler needs (curses endwin).
|
// signal handler needs (curses endwin).
|
||||||
type finisher interface {
|
type finisher interface {
|
||||||
@@ -174,48 +116,6 @@ type finisher interface {
|
|||||||
Fini()
|
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
|
// handledSignals returns the signals the game leaves on. They split into
|
||||||
// two groups with deliberately different save behavior; see
|
// two groups with deliberately different save behavior; see
|
||||||
// savesOnSignal.
|
// savesOnSignal.
|
||||||
@@ -237,8 +137,7 @@ func handledSignals() []os.Signal {
|
|||||||
// md_onsignal_default), and the only INT handler it ever installs is
|
// 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
|
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
|
||||||
// discarding pending output. The build that does wire INT during play
|
// discarding pending output. The build that does wire INT during play
|
||||||
// (md_onsignal_autosave, mdport.c — defined unconditionally, but with
|
// (md_onsignal_autosave, mdport.c, compiled only under DUMP) sends it to
|
||||||
// its only call site, mach_dep.c setup, inside #ifdef DUMP) sends it to
|
|
||||||
// quit(), which confirms, scores, and exits, again without saving, and
|
// quit(), which confirms, scores, and exits, again without saving, and
|
||||||
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
|
// 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
|
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
|
||||||
@@ -252,16 +151,12 @@ func handledSignals() []os.Signal {
|
|||||||
// would turn it into a one-keystroke undo for a bad turn: a gameplay
|
// would turn it into a one-keystroke undo for a bad turn: a gameplay
|
||||||
// change, not a robustness fix.
|
// change, not a robustness fix.
|
||||||
//
|
//
|
||||||
// Safety: this used to be the third ground, back when the handler
|
// Safety: this runs on a goroutine while the main goroutine is mid-turn
|
||||||
// gob-encoded live game state from its own goroutine after removing the
|
// mutating game state, and AutoSave removes the save file before
|
||||||
// save file — a data race with a window in which the player had no save
|
// gob-encoding that live state. On HUP/TERM that risk is accepted
|
||||||
// at all, accepted on HUP/TERM because the process was dying anyway and
|
// because the process is about to die regardless and a best-effort save
|
||||||
// avoided entirely on INT/QUIT. Issue #24 removed the window instead of
|
// beats none. On INT/QUIT there is nothing to rescue, so the right
|
||||||
// living with it: the handler now hands the save to the game goroutine
|
// choice is the one with no corruption window at all.
|
||||||
// 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 {
|
func savesOnSignal(sig os.Signal) bool {
|
||||||
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
|
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
|
||||||
}
|
}
|
||||||
@@ -270,17 +165,8 @@ func savesOnSignal(sig os.Signal) bool {
|
|||||||
// usable when it is signalled: C's leave(), "leave quickly but
|
// usable when it is signalled: C's leave(), "leave quickly but
|
||||||
// curteously" (main.c), extended with save.c auto_save on the two
|
// curteously" (main.c), extended with save.c auto_save on the two
|
||||||
// signals that warrant it.
|
// signals that warrant it.
|
||||||
//
|
func installSignalHandlers(g saver, t finisher) {
|
||||||
// Call it the instant the terminal goes raw, which is earlier than the
|
go leaveOnSignal(notifySignals(), g, t, os.Exit)
|
||||||
// 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
|
// notifySignals subscribes to the handled signals and returns the
|
||||||
@@ -299,19 +185,14 @@ func notifySignals() chan os.Signal {
|
|||||||
// leaveOnSignal waits for one signal and takes the game out.
|
// leaveOnSignal waits for one signal and takes the game out.
|
||||||
//
|
//
|
||||||
// Exactly one goroutine reads exactly one signal, which is what makes
|
// 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
|
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's
|
||||||
// is still being written, say) stays in the buffer unread and can never
|
// AutoSave is still writing, say) stays in the buffer unread and can
|
||||||
// call exit out from under an in-flight save. The order within is the
|
// never call exit out from under an in-flight save. The order within is
|
||||||
// same one myExit uses (game/rip.go): save if this signal saves, then
|
// the same one myExit uses (game/rip.go): save if this signal saves,
|
||||||
// restore the terminal, then exit.
|
// 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)) {
|
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
|
||||||
if savesOnSignal(<-sig) {
|
if savesOnSignal(<-sig) {
|
||||||
g.AutoSaveOnSignal(signalSaveTimeout)
|
g.AutoSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Fini()
|
t.Fini()
|
||||||
|
|||||||
@@ -8,44 +8,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"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
|
// signalRecorder stands in for the game and the terminal in the signal
|
||||||
// handler, recording the order of the steps the handler takes. The mutex
|
// handler, recording the order of the steps the handler takes. The mutex
|
||||||
// matters: the handler runs on its own goroutine, so an unguarded slice
|
// matters: the handler runs on its own goroutine, so an unguarded slice
|
||||||
@@ -62,18 +30,14 @@ func newSignalRecorder() *signalRecorder {
|
|||||||
return &signalRecorder{done: make(chan struct{})}
|
return &signalRecorder{done: make(chan struct{})}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoSaveOnSignal records a save attempt (the saver half). The real one
|
// AutoSave records a save attempt (the saver half).
|
||||||
// hands the work to the game goroutine and waits; the recorder stands in
|
func (r *signalRecorder) AutoSave() {
|
||||||
// for a game that takes it immediately.
|
r.record("save")
|
||||||
func (r *signalRecorder) AutoSaveOnSignal(time.Duration) bool {
|
|
||||||
r.record(stepSave)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fini records a terminal restore (the finisher half).
|
// Fini records a terminal restore (the finisher half).
|
||||||
func (r *signalRecorder) Fini() {
|
func (r *signalRecorder) Fini() {
|
||||||
r.record(stepFini)
|
r.record("fini")
|
||||||
}
|
}
|
||||||
|
|
||||||
// exit records the process exit that ends the handler and releases any
|
// exit records the process exit that ends the handler and releases any
|
||||||
@@ -81,7 +45,7 @@ func (r *signalRecorder) Fini() {
|
|||||||
func (r *signalRecorder) exit(code int) {
|
func (r *signalRecorder) exit(code int) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.code = code
|
r.code = code
|
||||||
r.steps = append(r.steps, stepExit)
|
r.steps = append(r.steps, "exit")
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
close(r.done)
|
close(r.done)
|
||||||
@@ -103,34 +67,6 @@ func (r *signalRecorder) taken() ([]string, int) {
|
|||||||
return slices.Clone(r.steps), r.code
|
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:
|
// TestLeaveOnSignalRestoresTerminalBeforeExit is the core of issue #12:
|
||||||
// whatever the signal, the terminal is restored before the process ends,
|
// 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
|
// so the player is never dropped into a shell with the tty still in raw
|
||||||
@@ -148,8 +84,8 @@ func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
|
|||||||
|
|
||||||
steps, code := rec.taken()
|
steps, code := rec.taken()
|
||||||
|
|
||||||
fini := slices.Index(steps, stepFini)
|
fini := slices.Index(steps, "fini")
|
||||||
exit := slices.Index(steps, stepExit)
|
exit := slices.Index(steps, "exit")
|
||||||
|
|
||||||
if fini < 0 || exit < 0 || fini > exit {
|
if fini < 0 || exit < 0 || fini > exit {
|
||||||
t.Errorf("%v: want the terminal restored before exit, got %v",
|
t.Errorf("%v: want the terminal restored before exit, got %v",
|
||||||
@@ -167,21 +103,17 @@ func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
|
|||||||
// SIGQUIT (a deliberate "stop now" from the player) do not, matching C,
|
// 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()
|
// where auto_save is reserved for HUP/TERM and neither leave() nor quit()
|
||||||
// nor endit() writes a save file.
|
// 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) {
|
func TestLeaveOnSignalSaveSplit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for sig, want := range wantSteps() {
|
want := map[os.Signal][]string{
|
||||||
if !slices.Contains(handledSignals(), sig) {
|
syscall.SIGHUP: {"save", "fini", "exit"},
|
||||||
t.Errorf("%v is not handled at all, so it cannot exit cleanly", sig)
|
syscall.SIGTERM: {"save", "fini", "exit"},
|
||||||
|
syscall.SIGINT: {"fini", "exit"},
|
||||||
continue
|
syscall.SIGQUIT: {"fini", "exit"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, sig := range handledSignals() {
|
||||||
rec := newSignalRecorder()
|
rec := newSignalRecorder()
|
||||||
|
|
||||||
ch := make(chan os.Signal, 1)
|
ch := make(chan os.Signal, 1)
|
||||||
@@ -190,11 +122,11 @@ func TestLeaveOnSignalSaveSplit(t *testing.T) {
|
|||||||
leaveOnSignal(ch, rec, rec, rec.exit)
|
leaveOnSignal(ch, rec, rec, rec.exit)
|
||||||
|
|
||||||
steps, _ := rec.taken()
|
steps, _ := rec.taken()
|
||||||
if !slices.Equal(steps, want) {
|
if !slices.Equal(steps, want[sig]) {
|
||||||
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
t.Errorf("%v: steps = %v, want %v", sig, steps, want[sig])
|
||||||
}
|
}
|
||||||
|
|
||||||
if saved := slices.Contains(steps, stepSave); saved != savesOnSignal(sig) {
|
if saved := slices.Contains(steps, "save"); saved != savesOnSignal(sig) {
|
||||||
t.Errorf("%v: saved = %v, savesOnSignal = %v",
|
t.Errorf("%v: saved = %v, savesOnSignal = %v",
|
||||||
sig, saved, savesOnSignal(sig))
|
sig, saved, savesOnSignal(sig))
|
||||||
}
|
}
|
||||||
@@ -219,7 +151,7 @@ func TestLeaveOnSignalIgnoresLaterSignals(t *testing.T) {
|
|||||||
leaveOnSignal(ch, blocker, rec, rec.exit)
|
leaveOnSignal(ch, blocker, rec, rec.exit)
|
||||||
|
|
||||||
steps, _ := rec.taken()
|
steps, _ := rec.taken()
|
||||||
if !slices.Equal(steps, []string{stepSave, stepFini, stepExit}) {
|
if !slices.Equal(steps, []string{"save", "fini", "exit"}) {
|
||||||
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
|
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,20 +168,18 @@ type blockingSaver struct {
|
|||||||
extra os.Signal
|
extra os.Signal
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoSaveOnSignal delivers the extra signal mid-save, then records the
|
// AutoSave delivers the extra signal mid-save, then records the save.
|
||||||
// save.
|
func (b *blockingSaver) AutoSave() {
|
||||||
func (b *blockingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
|
|
||||||
b.queue <- b.extra
|
b.queue <- b.extra
|
||||||
|
|
||||||
return b.rec.AutoSaveOnSignal(timeout)
|
b.rec.AutoSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLeaveOnRealSignal is the deepest headless check available: it
|
// TestLeaveOnRealSignal is the deepest headless check available: it
|
||||||
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
|
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
|
||||||
// os/signal, exactly as notifySignals wires them in the game, and
|
// os/signal, exactly as notifySignals wires them in the game, and
|
||||||
// verifies each one reaches the handler and produces the full expected
|
// verifies each one reaches the handler and restores the terminal before
|
||||||
// step sequence — including the save/no-save split, which this test is
|
// exiting.
|
||||||
// the best placed to check end to end.
|
|
||||||
//
|
//
|
||||||
// What cannot be checked here is the tty itself coming back out of raw
|
// 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
|
// mode: that needs a controlling terminal and a live tcell screen, which
|
||||||
@@ -280,8 +210,9 @@ func TestLeaveOnRealSignal(t *testing.T) {
|
|||||||
<-rec.done
|
<-rec.done
|
||||||
|
|
||||||
steps, code := rec.taken()
|
steps, code := rec.taken()
|
||||||
if want := wantSteps()[sig]; !slices.Equal(steps, want) {
|
if !strings.HasSuffix(strings.Join(steps, ","), "fini,exit") {
|
||||||
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
t.Errorf("%v: steps = %v, want the terminal restored before exit",
|
||||||
|
sig, steps)
|
||||||
}
|
}
|
||||||
|
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
@@ -289,131 +220,3 @@ func TestLeaveOnRealSignal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,610 +0,0 @@
|
|||||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
|
||||||
package game
|
|
||||||
|
|
||||||
// Tests for the signal-triggered autosave handoff (issue #24): the signal
|
|
||||||
// goroutine must never encode game state itself, and the game goroutine
|
|
||||||
// must answer wherever it is parked.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
|
|
||||||
// expect the save to be taken. It is long enough that a loaded machine
|
|
||||||
// cannot turn a working handoff into a spurious failure, and it is never
|
|
||||||
// actually waited out on a passing run. It is also what bounds
|
|
||||||
// driveUntilDone, by way of the saving goroutine it waits for — see
|
|
||||||
// there for what that bound comes to.
|
|
||||||
const autoSaveWait = 10 * time.Second
|
|
||||||
|
|
||||||
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
|
|
||||||
// drives the real turn loop on one goroutine while another asks for a
|
|
||||||
// signal-triggered autosave over and over, which is the interleaving no
|
|
||||||
// test in the suite used to produce. `make test` runs with -race, so a
|
|
||||||
// save that encodes the live game tree from the asking goroutine — what
|
|
||||||
// the old AutoSave did straight from the signal handler — is reported as
|
|
||||||
// a data race and fails this test.
|
|
||||||
//
|
|
||||||
// Non-vacuity: with AutoSaveOnSignal's body replaced by a direct
|
|
||||||
// g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails
|
|
||||||
// under -race with the encoder reading state that command() is writing.
|
|
||||||
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Same mix as TestTurnLoopCrashSweep — the spaces answer any --More--
|
|
||||||
// prompt — on a driveTerm, so the drive can run for as long as the
|
|
||||||
// saves take rather than for as long as a script lasts. The '.' and
|
|
||||||
// the 's' are what make an unbounded drive safe, and at least one of
|
|
||||||
// the two has to stay in the cycle: see driveTerm.
|
|
||||||
term := &driveTerm{script: []byte("h j k l y u b n s . ")}
|
|
||||||
|
|
||||||
g := New(Params{Seed: 20260809, Term: term})
|
|
||||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
|
||||||
g.startLevel()
|
|
||||||
g.prePlay()
|
|
||||||
|
|
||||||
const wantSaves = 25
|
|
||||||
|
|
||||||
var taken int
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
|
|
||||||
for range wantSaves {
|
|
||||||
if g.AutoSaveOnSignal(autoSaveWait) {
|
|
||||||
taken++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
driveUntilDone(t, g, done)
|
|
||||||
|
|
||||||
// The close of done orders that goroutine's writes before this read.
|
|
||||||
if taken != wantSaves {
|
|
||||||
t.Errorf("saves taken = %d, want %d", taken, wantSaves)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every request was answered by the turn loop, so the file is the
|
|
||||||
// work of the game goroutine and must be a whole save.
|
|
||||||
assertRestorable(t, g.FileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// driveUntilDone runs turns until the saving goroutine is finished,
|
|
||||||
// fortifying the hero each turn so no death exits the test binary. The
|
|
||||||
// condition it waits on is that goroutine finishing — nothing else.
|
|
||||||
//
|
|
||||||
// It used to stop after a fixed 1000 turns and fail, and that cap was a
|
|
||||||
// load-sensitive assumption wearing a counter's clothes (issue #36). The
|
|
||||||
// turns this loop spends between one save request being answered and the
|
|
||||||
// next arriving are not work; they are the saving goroutine's scheduling
|
|
||||||
// latency, so the turn count 25 saves costs is a function of how
|
|
||||||
// contended the machine is rather than of anything the code under test
|
|
||||||
// does. Measured here on a 48-core host at load ~57: about 60-120 turns
|
|
||||||
// with a whole machine to spread over, 418 to 655 as GOMAXPROCS was cut
|
|
||||||
// from 4 to 1, and past 1000 under the doubled load of the verbose
|
|
||||||
// rerun, which is the flake this replaces. A budget that has to be
|
|
||||||
// guessed cannot be guessed right, so there is no budget.
|
|
||||||
//
|
|
||||||
// Dropping it costs no termination guarantee, because the bound belongs
|
|
||||||
// to the code under test and not to this loop: each AutoSaveOnSignal
|
|
||||||
// call returns within the timeout the caller hands it, so the saving
|
|
||||||
// goroutine always finishes and done always closes. That bound is worth
|
|
||||||
// stating exactly, because it is not one autoSaveWait.
|
|
||||||
//
|
|
||||||
// A handoff that has stopped answering altogether costs one, in total,
|
|
||||||
// however many saves were asked for. g.sigSave
|
|
||||||
// is one deep, so the unserviced request stays in the channel and every
|
|
||||||
// later call finds it full and reports failure immediately — measured
|
|
||||||
// at 10.0s for 25 saves with the service point deleted from command().
|
|
||||||
// What fails is then the caller's own assertion, the count of saves
|
|
||||||
// actually taken, which says far more than "out of turns" ever did.
|
|
||||||
//
|
|
||||||
// A handoff that still drains every request but takes longer than
|
|
||||||
// autoSaveWait to do it is the worst case, and costs one timeout per
|
|
||||||
// save: wantSaves * autoSaveWait, 250s at these constants, which would
|
|
||||||
// run past the package timeout rather than reach the assertion. It
|
|
||||||
// takes about ten seconds of scheduler starvation per save to get
|
|
||||||
// there, against a regime measured at 0.12s per 1000 turns, so it is
|
|
||||||
// remote — and the 1000-turn cap did not bound it either, a turn count
|
|
||||||
// being no kind of time bound. `go test -timeout 30s` is the backstop
|
|
||||||
// under all of it.
|
|
||||||
//
|
|
||||||
// The one thing the caller does have to supply is a terminal that can
|
|
||||||
// feed an unbounded drive: see driveTerm.
|
|
||||||
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
fortify(g)
|
|
||||||
g.command()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
|
|
||||||
// for: the connection drops while the player is staring at the screen,
|
|
||||||
// so the game goroutine is parked in ReadChar and will not reach the
|
|
||||||
// between-turns check on its own. A flag checked only between turns would
|
|
||||||
// never be looked at here.
|
|
||||||
func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
bt := newBlockingTerm()
|
|
||||||
g := mkBlockedGame(t, bt)
|
|
||||||
|
|
||||||
read := make(chan byte)
|
|
||||||
|
|
||||||
go func() { read <- g.readchar() }()
|
|
||||||
|
|
||||||
// The wake is buffered, so this is correct whether or not the reader
|
|
||||||
// has reached ReadChar yet.
|
|
||||||
if !g.AutoSaveOnSignal(autoSaveWait) {
|
|
||||||
t.Fatal("the save was not taken while the game was blocked on input")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertRestorable(t, g.FileName)
|
|
||||||
|
|
||||||
// The interrupt must not have been mistaken for a keystroke: the
|
|
||||||
// reader is still waiting, and still returns the real key.
|
|
||||||
bt.keys <- 'x'
|
|
||||||
|
|
||||||
if ch := <-read; ch != 'x' {
|
|
||||||
t.Errorf("readchar() = %q, want 'x'", ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAutoSaveOnSignalWhileInShellEscape covers the other place the game
|
|
||||||
// goroutine parks for an unbounded time: the `!` shell escape, where it
|
|
||||||
// used to sit inside the shell call with no way to answer. A dropped line
|
|
||||||
// while the player is off in a shell is as much a hangup as any other.
|
|
||||||
func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
st := &shellTerm{
|
|
||||||
blockingTerm: newBlockingTerm(),
|
|
||||||
entered: make(chan struct{}),
|
|
||||||
release: make(chan struct{}),
|
|
||||||
}
|
|
||||||
g := mkBlockedGame(t, st)
|
|
||||||
|
|
||||||
left := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(left)
|
|
||||||
|
|
||||||
g.shell()
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-st.entered
|
|
||||||
|
|
||||||
if !g.AutoSaveOnSignal(autoSaveWait) {
|
|
||||||
t.Fatal("the save was not taken while the game was in the shell escape")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertRestorable(t, g.FileName)
|
|
||||||
|
|
||||||
close(st.release)
|
|
||||||
<-left
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShellEscapePanicUnwindsTheGameGoroutine pins the reason
|
|
||||||
// runShellEscape recovers its helper's panic.
|
|
||||||
//
|
|
||||||
// term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell
|
|
||||||
// now runs on a helper goroutine. A panic reaching the top of that helper
|
|
||||||
// would kill the process without running the deferred calls of any other
|
|
||||||
// goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is
|
|
||||||
// the only thing that takes the tty back out of raw mode. That is issue
|
|
||||||
// #12's failure, and it would land on the one path where the terminal is
|
|
||||||
// already broken.
|
|
||||||
//
|
|
||||||
// So the panic has to arrive on the goroutine that runs the game, with
|
|
||||||
// that goroutine's deferred restore still on the stack. This test stands
|
|
||||||
// in for main: a Fini deferred around the g.shell() call, and the panic
|
|
||||||
// caught after it, asserting both that the restore ran and that the
|
|
||||||
// original value came through. Against the unrecovered version there is
|
|
||||||
// nothing to assert — the panic escapes a helper goroutine and takes the
|
|
||||||
// whole test binary down, which is the failure being prevented.
|
|
||||||
func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
pt := &panickingShellTerm{blockingTerm: newBlockingTerm()}
|
|
||||||
g := mkBlockedGame(t, pt)
|
|
||||||
|
|
||||||
caught := make(chan any, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
// Registered first, so it runs last: it sees the terminal
|
|
||||||
// already restored, exactly as the runtime would have printed
|
|
||||||
// the trace after main's Fini.
|
|
||||||
defer func() { caught <- recover() }()
|
|
||||||
|
|
||||||
// Stands in for cmd/rogue/main.go's `defer t.Fini()`.
|
|
||||||
defer pt.Fini()
|
|
||||||
|
|
||||||
g.shell()
|
|
||||||
}()
|
|
||||||
|
|
||||||
got := <-caught
|
|
||||||
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("the resume failure did not reach the game goroutine")
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg, ok := got.(string); !ok || msg != errShellResume {
|
|
||||||
t.Errorf("recovered %v, want %q", got, errShellResume)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !pt.restored {
|
|
||||||
t.Error("the terminal was not restored on the way out")
|
|
||||||
}
|
|
||||||
|
|
||||||
// shell() must not have resumed into its InShell reset and refresh:
|
|
||||||
// there is no screen left to draw into.
|
|
||||||
if !g.InShell {
|
|
||||||
t.Error("shell() carried on drawing after the resume failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game
|
|
||||||
// goroutine that never reaches a service point must not hold the process
|
|
||||||
// open, and giving up must cost the player nothing. The old save is still
|
|
||||||
// there, byte for byte — which is the whole point of renaming over the
|
|
||||||
// target instead of removing it first.
|
|
||||||
func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := mkGame(t, 77)
|
|
||||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
|
||||||
|
|
||||||
const old = "an older save nobody is allowed to destroy"
|
|
||||||
|
|
||||||
writeErr := os.WriteFile(g.FileName, []byte(old), 0o600)
|
|
||||||
if writeErr != nil {
|
|
||||||
t.Fatal(writeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing drives the turn loop, so nothing will ever answer.
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
if g.AutoSaveOnSignal(100 * time.Millisecond) {
|
|
||||||
t.Error("AutoSaveOnSignal reported a save that nobody took")
|
|
||||||
}
|
|
||||||
|
|
||||||
if waited := time.Since(start); waited > time.Second {
|
|
||||||
t.Errorf("waited %v for an unanswered save, want the deadline to bound it",
|
|
||||||
waited)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, readErr := os.ReadFile(g.FileName)
|
|
||||||
if readErr != nil {
|
|
||||||
t.Fatalf("the previous save was destroyed: %v", readErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(got) != old {
|
|
||||||
t.Error("the previous save was overwritten by a save that never ran")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal
|
|
||||||
// case: a game with no file name has nothing to write, and must say so
|
|
||||||
// rather than reporting a save that did not happen.
|
|
||||||
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}})
|
|
||||||
g.FileName = ""
|
|
||||||
g.startLevel()
|
|
||||||
g.prePlay()
|
|
||||||
|
|
||||||
var answered bool
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
|
|
||||||
answered = g.AutoSaveOnSignal(autoSaveWait)
|
|
||||||
}()
|
|
||||||
|
|
||||||
driveUntilDone(t, g, done)
|
|
||||||
|
|
||||||
if answered {
|
|
||||||
t.Error("AutoSaveOnSignal = true with no save file name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSaveFileReplacesTargetAtomically pins the write discipline: the new
|
|
||||||
// save arrives by rename, so the file the player already had is never
|
|
||||||
// written into, and the temporary file it came from is not left lying in
|
|
||||||
// the save directory.
|
|
||||||
//
|
|
||||||
// The load-bearing assertion is the handle opened before the save. A
|
|
||||||
// rename leaves the old file whole and merely stops it being reachable by
|
|
||||||
// name, so that handle still reads the old save; the truncate-in-place
|
|
||||||
// write this replaced would empty it under the reader — the same
|
|
||||||
// in-place write that, interrupted, left the player with a file that
|
|
||||||
// could no longer be restored.
|
|
||||||
func TestSaveFileReplacesTargetAtomically(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := mkGame(t, 11)
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "rogue.save")
|
|
||||||
|
|
||||||
const old = "an older save"
|
|
||||||
|
|
||||||
writeErr := os.WriteFile(path, []byte(old), 0o600)
|
|
||||||
if writeErr != nil {
|
|
||||||
t.Fatal(writeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
held, openErr := os.Open(path) //nolint:gosec // G304: test temp path
|
|
||||||
if openErr != nil {
|
|
||||||
t.Fatal(openErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = held.Close() }()
|
|
||||||
|
|
||||||
saveErr := g.saveFile(path)
|
|
||||||
if saveErr != nil {
|
|
||||||
t.Fatalf("saveFile: %v", saveErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
kept, readErr := io.ReadAll(held)
|
|
||||||
if readErr != nil {
|
|
||||||
t.Fatalf("reading the file that was there before the save: %v", readErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(kept) != old {
|
|
||||||
t.Errorf("the previous save was written into rather than replaced: %q",
|
|
||||||
string(kept))
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, readErr := os.ReadDir(dir)
|
|
||||||
if readErr != nil {
|
|
||||||
t.Fatal(readErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entries) != 1 || entries[0].Name() != "rogue.save" {
|
|
||||||
t.Errorf("save directory = %v, want just the save file", names(entries))
|
|
||||||
}
|
|
||||||
|
|
||||||
info, statErr := os.Stat(path)
|
|
||||||
if statErr != nil {
|
|
||||||
t.Fatal(statErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if perm := info.Mode().Perm(); perm != 0o400 {
|
|
||||||
t.Errorf("save file mode = %v, want 0400", perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertRestorable(t, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the
|
|
||||||
// same discipline: a save that cannot be completed must leave what the
|
|
||||||
// player already had. The target here is a non-empty directory, which no
|
|
||||||
// rename can replace — the one write failure that can be forced without
|
|
||||||
// depending on file permissions, and therefore on not being root.
|
|
||||||
func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := mkGame(t, 12)
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "rogue.save")
|
|
||||||
|
|
||||||
mkErr := os.Mkdir(path, 0o700)
|
|
||||||
if mkErr != nil {
|
|
||||||
t.Fatal(mkErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
keep := filepath.Join(path, "keep")
|
|
||||||
|
|
||||||
writeErr := os.WriteFile(keep, []byte("still here"), 0o600)
|
|
||||||
if writeErr != nil {
|
|
||||||
t.Fatal(writeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
saveErr := g.saveFile(path)
|
|
||||||
if saveErr == nil {
|
|
||||||
t.Error("saveFile over an unreplaceable target reported success")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, statErr := os.Stat(keep)
|
|
||||||
if statErr != nil {
|
|
||||||
t.Errorf("the target was damaged by a failed save: %v", statErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, readErr := os.ReadDir(dir)
|
|
||||||
if readErr != nil {
|
|
||||||
t.Fatal(readErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entries) != 1 {
|
|
||||||
t.Errorf("save directory = %v, want no temporary file left behind",
|
|
||||||
names(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// names lists directory entry names for a failure message.
|
|
||||||
func names(entries []os.DirEntry) []string {
|
|
||||||
out := make([]string, 0, len(entries))
|
|
||||||
for _, e := range entries {
|
|
||||||
out = append(out, e.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertRestorable checks that path holds a save this program can load,
|
|
||||||
// which is what "the save was taken" has to mean: a file of the right
|
|
||||||
// size proves nothing about a torn encode.
|
|
||||||
func assertRestorable(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, err := Restore(path, Params{Term: &testTerm{}})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("the saved file does not restore: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mkBlockedGame builds a game with a save file name and a terminal whose
|
|
||||||
// reads block, for the tests that park the game goroutine.
|
|
||||||
func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
g := New(Params{Seed: 4242, Term: term})
|
|
||||||
g.NewLevel()
|
|
||||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
|
||||||
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
|
|
||||||
// driveTerm is a headless Terminal whose script repeats instead of
|
|
||||||
// running out, for the tests that drive the turn loop until something
|
|
||||||
// else finishes rather than for a set number of turns.
|
|
||||||
//
|
|
||||||
// testTerm cannot do that job. Once its script is exhausted it answers
|
|
||||||
// space and newline for ever, and neither takes a turn, so command() —
|
|
||||||
// which loops until the player does something that consumes one, the
|
|
||||||
// `if !g.After { ntimes++ }` in command.c — never returns. A drive with
|
|
||||||
// a turn cap sized to its script never notices; a drive that runs until
|
|
||||||
// the saves are taken wedges inside a single command() call, which is
|
|
||||||
// what a first attempt at issue #36 did.
|
|
||||||
//
|
|
||||||
// Repeating the script is necessary but nowhere near sufficient, and
|
|
||||||
// the difference is what anyone editing one of these scripts has to
|
|
||||||
// know. Most keys take a turn only conditionally. ' ' is the "legal
|
|
||||||
// illegal command" and clears After outright (tables.go). All eight
|
|
||||||
// movement keys clear it whenever the step is refused: a wall or the
|
|
||||||
// map edge (move.go moveResolve), an illegal diagonal (moveTarget), or
|
|
||||||
// a confused step that lands back in place (moveHero). A script of
|
|
||||||
// nothing but those keys wedges exactly the way testTerm's tail does,
|
|
||||||
// repetition or no repetition — with the script set to just " " this
|
|
||||||
// drive hits the 30s package timeout inside command().
|
|
||||||
//
|
|
||||||
// What actually makes the wedge impossible is that the cycle always
|
|
||||||
// contains at least one *unconditional* turn-taker, and the scripts
|
|
||||||
// here carry two: '.', the rest command, whose handler is empty, and
|
|
||||||
// 's', search, which writes After on no path. Nothing refuses either
|
|
||||||
// one — not being blocked in all eight directions, not Held, not stuck
|
|
||||||
// in a bear trap, and not NoCommand > 0, where playTurn skips
|
|
||||||
// executeCommand altogether and After is simply left true. Trim both
|
|
||||||
// out and the wedge this test exists to remove comes straight back.
|
|
||||||
//
|
|
||||||
// One further precondition, from what this fake does not supply:
|
|
||||||
// testTerm's tail answered a newline every other read and this does
|
|
||||||
// not. Nothing reachable from these scripts asks for one — waitFor('\n')
|
|
||||||
// sits on the death and score paths (rip.go, score.go), which fortify
|
|
||||||
// prevents from ever being reached — but a script that could reach them
|
|
||||||
// would park in waitFor for ever.
|
|
||||||
type driveTerm struct {
|
|
||||||
script []byte
|
|
||||||
pos int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *driveTerm) Render(*Window) {}
|
|
||||||
|
|
||||||
func (t *driveTerm) Repaint() {}
|
|
||||||
|
|
||||||
func (t *driveTerm) Fini() {}
|
|
||||||
|
|
||||||
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
|
|
||||||
func (t *driveTerm) Interrupt() {}
|
|
||||||
|
|
||||||
// ReadChar hands out the next scripted key, wrapping at the end.
|
|
||||||
func (t *driveTerm) ReadChar() (byte, bool) {
|
|
||||||
ch := t.script[t.pos]
|
|
||||||
t.pos = (t.pos + 1) % len(t.script)
|
|
||||||
|
|
||||||
return ch, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
|
|
||||||
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
|
|
||||||
// block, cannot reproduce.
|
|
||||||
type blockingTerm struct {
|
|
||||||
keys chan byte
|
|
||||||
wake chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBlockingTerm() *blockingTerm {
|
|
||||||
return &blockingTerm{
|
|
||||||
keys: make(chan byte),
|
|
||||||
// Buffered by one and posted to without blocking, the same
|
|
||||||
// contract term.Tcell.Interrupt has with tcell's event queue: an
|
|
||||||
// interrupt that arrives before the read still wakes it.
|
|
||||||
wake: make(chan struct{}, 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *blockingTerm) Render(*Window) {}
|
|
||||||
|
|
||||||
// Repaint has nothing to redraw: this terminal exists for its input
|
|
||||||
// behaviour, and no autosave test types CTRL-R.
|
|
||||||
func (t *blockingTerm) Repaint() {}
|
|
||||||
|
|
||||||
func (t *blockingTerm) Fini() {}
|
|
||||||
|
|
||||||
// Interrupt wakes a blocked ReadChar; called from the saving goroutine.
|
|
||||||
func (t *blockingTerm) Interrupt() {
|
|
||||||
select {
|
|
||||||
case t.wake <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadChar blocks until a key arrives or Interrupt wakes it.
|
|
||||||
func (t *blockingTerm) ReadChar() (byte, bool) {
|
|
||||||
select {
|
|
||||||
case ch := <-t.keys:
|
|
||||||
return ch, true
|
|
||||||
case <-t.wake:
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// shellTerm is a blockingTerm that also offers a shell escape which stays
|
|
||||||
// in the shell until the test lets it out.
|
|
||||||
type shellTerm struct {
|
|
||||||
*blockingTerm
|
|
||||||
|
|
||||||
entered chan struct{}
|
|
||||||
release chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShellEscape parks the caller in the "shell" until released.
|
|
||||||
func (t *shellTerm) ShellEscape() {
|
|
||||||
close(t.entered)
|
|
||||||
<-t.release
|
|
||||||
}
|
|
||||||
|
|
||||||
// errShellResume is what panickingShellTerm panics with, standing in for
|
|
||||||
// the value term.Tcell.ShellEscape raises when Screen.Resume fails.
|
|
||||||
const errShellResume = "resume failed"
|
|
||||||
|
|
||||||
// panickingShellTerm is a blockingTerm whose shell escape panics on the
|
|
||||||
// way out, the way term.Tcell.ShellEscape does when the screen cannot be
|
|
||||||
// resumed. It records whether Fini ran, which is the thing that must
|
|
||||||
// still happen.
|
|
||||||
type panickingShellTerm struct {
|
|
||||||
*blockingTerm
|
|
||||||
|
|
||||||
restored bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *panickingShellTerm) Fini() { t.restored = true }
|
|
||||||
|
|
||||||
func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,13 +8,6 @@ package game
|
|||||||
func (g *RogueGame) command() {
|
func (g *RogueGame) command() {
|
||||||
p := &g.Player
|
p := &g.Player
|
||||||
|
|
||||||
// Between turns is the one point in the loop where the game state is
|
|
||||||
// whole, so it is where a signal-triggered autosave is answered when
|
|
||||||
// the game goroutine is busy rather than waiting for a key (issue
|
|
||||||
// #24). The other service points are readchar (io.c) and
|
|
||||||
// runShellEscape, covering the two ways this goroutine can be parked.
|
|
||||||
g.serviceAutoSaveRequest()
|
|
||||||
|
|
||||||
ntimes := 1 // number of player moves
|
ntimes := 1 // number of player moves
|
||||||
if p.On(Hasted) {
|
if p.On(Hasted) {
|
||||||
ntimes++
|
ntimes++
|
||||||
@@ -398,36 +391,6 @@ func (g *RogueGame) identifyTrapCommand() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wizardToggleCommand handles '+': leave wizard mode (command.c
|
|
||||||
// command). C's arm lives in the main command switch under
|
|
||||||
// #ifdef MASTER, not in the wizard sub-switch, so it is reachable
|
|
||||||
// whether or not wizard is set.
|
|
||||||
//
|
|
||||||
// The entry half is deliberately not ported. C ran wizard = passwd(),
|
|
||||||
// which compared a DES-crypted answer against a compiled-in password;
|
|
||||||
// this port drops that machinery and makes wizard mode configuration
|
|
||||||
// instead (ROGUE_WIZARD, ARCHITECTURE.md §9). A password check that is
|
|
||||||
// gone is a password check that can never succeed, so the else arm
|
|
||||||
// reduces to exactly what C did when the answer was wrong: wizard stays
|
|
||||||
// off and the game says "sorry". No prompt is shown, since nothing typed
|
|
||||||
// into it could change the outcome, and the noscore/turn_see(FALSE)
|
|
||||||
// bookkeeping of C's success branch is unreachable and so is absent.
|
|
||||||
func (g *RogueGame) wizardToggleCommand() {
|
|
||||||
g.After = false
|
|
||||||
|
|
||||||
if !g.Wizard {
|
|
||||||
g.msg("sorry")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Wizard = false
|
|
||||||
// Re-hide the monsters wizard sight was showing: without this there
|
|
||||||
// is no way back to normal visibility.
|
|
||||||
g.turnSee(true)
|
|
||||||
g.msg("not wizard any more")
|
|
||||||
}
|
|
||||||
|
|
||||||
// wizardCommand handles the MASTER debug commands (command.c).
|
// wizardCommand handles the MASTER debug commands (command.c).
|
||||||
func (g *RogueGame) wizardCommand(ch byte) {
|
func (g *RogueGame) wizardCommand(ch byte) {
|
||||||
p := &g.Player
|
p := &g.Player
|
||||||
@@ -927,7 +890,7 @@ func (g *RogueGame) shell() {
|
|||||||
if se, ok := g.scr.term.(interface{ ShellEscape() }); ok {
|
if se, ok := g.scr.term.(interface{ ShellEscape() }); ok {
|
||||||
g.InShell = true
|
g.InShell = true
|
||||||
|
|
||||||
g.runShellEscape(se)
|
se.ShellEscape()
|
||||||
|
|
||||||
g.InShell = false
|
g.InShell = false
|
||||||
g.refresh()
|
g.refresh()
|
||||||
@@ -935,58 +898,3 @@ func (g *RogueGame) shell() {
|
|||||||
g.msg("shell escape is not available")
|
g.msg("shell escape is not available")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runShellEscape runs the shell and returns when it exits, answering
|
|
||||||
// signal-triggered autosave requests in the meantime (issue #24).
|
|
||||||
//
|
|
||||||
// The shell blocks for as long as the player is away — minutes, or until
|
|
||||||
// they forget — and a line dropping while they are in it is exactly the
|
|
||||||
// case SIGHUP autosave exists for, so this goroutine cannot simply sit
|
|
||||||
// inside the call. The shell runs on a helper goroutine and the game
|
|
||||||
// goroutine waits here, still the only one that ever encodes game state.
|
|
||||||
// It draws nothing while it waits, so the suspend/resume dance is as
|
|
||||||
// undisturbed as it was when it ran inline (ARCHITECTURE.md section 9).
|
|
||||||
//
|
|
||||||
// A panic out of ShellEscape must not be allowed to unwind on the helper
|
|
||||||
// goroutine. term.Tcell.ShellEscape panics when Screen.Resume fails, and
|
|
||||||
// a panic reaching the top of any goroutine kills the process without
|
|
||||||
// running any *other* goroutine's deferred calls — which is where
|
|
||||||
// cmd/rogue/main.go's `defer t.Fini()` lives. Running the shell off the
|
|
||||||
// game goroutine would therefore have left the tty raw on exactly the
|
|
||||||
// path where the terminal is already broken, reintroducing issue #12 on a
|
|
||||||
// path this change created. So the helper recovers, and the value is
|
|
||||||
// re-raised below on the game goroutine, whose stack does have Fini in
|
|
||||||
// it. The recover deferral is registered after `defer close(done)` and so
|
|
||||||
// runs before it, which is what publishes panicVal to the reader.
|
|
||||||
func (g *RogueGame) runShellEscape(se interface{ ShellEscape() }) {
|
|
||||||
done := make(chan struct{})
|
|
||||||
|
|
||||||
var panicVal any
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
panicVal = recover()
|
|
||||||
}()
|
|
||||||
|
|
||||||
se.ShellEscape()
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
if panicVal != nil {
|
|
||||||
// Re-raised here so the unwind passes through the game
|
|
||||||
// goroutine's deferred Fini. shell()'s InShell reset and
|
|
||||||
// refresh are skipped deliberately: there is no screen
|
|
||||||
// left to draw into.
|
|
||||||
panic(panicVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
case req := <-g.sigSave:
|
|
||||||
g.runAutoSaveRequest(req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
//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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -194,74 +194,6 @@ func TestZapSlowMonster(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bizarreSchtick is C's message for a zap that matched no case at all
|
|
||||||
// (sticks.c do_zap, the "otherwise" arm). Shared by the pair of tests
|
|
||||||
// below so that the one asserting it appears and the one asserting it
|
|
||||||
// does not can never drift apart.
|
|
||||||
const bizarreSchtick = "what a bizarre schtick!"
|
|
||||||
|
|
||||||
// TestZapUnhandledWandSaysBizarreSchtick pins the closing arm of C's zap
|
|
||||||
// switch. Every WS_ kind has a case, so the arm is reachable only for an
|
|
||||||
// o_which outside the table — here a wand one past the end, the state a
|
|
||||||
// corrupt save file can still describe. C's message is not gated on the
|
|
||||||
// wizard flag, only on the MASTER build this port is, so no test setup
|
|
||||||
// turns it on.
|
|
||||||
func TestZapUnhandledWandSaysBizarreSchtick(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := mkGameInput(t)
|
|
||||||
wand := malformed(KindWand)
|
|
||||||
wand.Charges = 3
|
|
||||||
ch := give(g, wand)
|
|
||||||
|
|
||||||
setInput(t, g, ch)
|
|
||||||
g.Msgs.Huh = ""
|
|
||||||
|
|
||||||
g.doZap()
|
|
||||||
|
|
||||||
if g.Msgs.Huh != bizarreSchtick {
|
|
||||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, bizarreSchtick)
|
|
||||||
}
|
|
||||||
|
|
||||||
// C falls out of the switch into o_charges-- from the otherwise arm
|
|
||||||
// as much as from any other.
|
|
||||||
if wand.Charges != 2 {
|
|
||||||
t.Errorf("charges = %d after zapping, want 2", wand.Charges)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestZapWandOfNothingIsSilent is the other half, and the reason the
|
|
||||||
// message cannot simply be attached to "no handler ran". WS_NOP is a case
|
|
||||||
// of C's switch in its own right — "when WS_NOP: break;" — so the wand
|
|
||||||
// that does nothing does it quietly, and only a kind C had no case for
|
|
||||||
// is bizarre.
|
|
||||||
func TestZapWandOfNothingIsSilent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := mkGameInput(t)
|
|
||||||
stick := newObject()
|
|
||||||
stick.Kind = KindWand
|
|
||||||
stick.Which = int(WandNothing)
|
|
||||||
g.fixStick(stick)
|
|
||||||
ch := give(g, stick)
|
|
||||||
|
|
||||||
setInput(t, g, ch)
|
|
||||||
|
|
||||||
charges := stick.Charges
|
|
||||||
g.Msgs.Huh = ""
|
|
||||||
|
|
||||||
g.doZap()
|
|
||||||
|
|
||||||
if g.Msgs.Huh == bizarreSchtick {
|
|
||||||
t.Errorf("the wand of nothing said %q; WS_NOP is a case of C's "+
|
|
||||||
"switch, not an unhandled kind", bizarreSchtick)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stick.Charges != charges-1 {
|
|
||||||
t.Error("zap did not use a charge")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOpts(t *testing.T) {
|
func TestParseOpts(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
58
game/game.go
58
game/game.go
@@ -1,8 +1,6 @@
|
|||||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||||
package game
|
package game
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// ItemLore is the per-game item identity state: the randomized appearance
|
// ItemLore is the per-game item identity state: the randomized appearance
|
||||||
// names and the seven mutable ObjInfo tables (extern.c/init.c).
|
// names and the seven mutable ObjInfo tables (extern.c/init.c).
|
||||||
type ItemLore struct {
|
type ItemLore struct {
|
||||||
@@ -142,65 +140,10 @@ type RogueGame struct {
|
|||||||
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
|
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
|
||||||
restored bool // game came from a save file; Run skips setup
|
restored bool // game came from a save file; Run skips setup
|
||||||
|
|
||||||
// sigSave carries signal-triggered autosave requests from the signal
|
|
||||||
// goroutine to the game goroutine, which is the only one allowed to
|
|
||||||
// touch the state above (issue #24). Buffered by one: the handler
|
|
||||||
// reads exactly one signal, so there is never more than one request.
|
|
||||||
// See AutoSaveOnSignal and serviceAutoSaveRequest in save.go.
|
|
||||||
sigSave chan *autoSaveRequest
|
|
||||||
|
|
||||||
// data is the game's copy of the static tables (extern.c and friends).
|
// data is the game's copy of the static tables (extern.c and friends).
|
||||||
data *gameData
|
data *gameData
|
||||||
}
|
}
|
||||||
|
|
||||||
// Greeting is the line C printed on stdout while the player waited for
|
|
||||||
// the dungeon to be dug, immediately before initscr() (main.c main). The
|
|
||||||
// caller prints it before the terminal package takes the screen, which is
|
|
||||||
// where initscr() sat; there is no trailing newline in either wording,
|
|
||||||
// because C followed the printf with fflush and let curses have the
|
|
||||||
// display.
|
|
||||||
//
|
|
||||||
// Only the wizard wording is #ifdef MASTER in C, and it carries the
|
|
||||||
// dungeon number, which is the seed (main.c assigns seed = dnum right
|
|
||||||
// after choosing dnum). The other wording is unconditional.
|
|
||||||
//
|
|
||||||
// The name is C's whoami, resolved the way main.c resolves it: parse_opts
|
|
||||||
// runs before the printf, so a ROGUEOPTS "name=" setting is what the
|
|
||||||
// player is greeted by, and the account name is only the fallback. New
|
|
||||||
// does the same parse a moment later; doing it here too is safe because
|
|
||||||
// ParseOpts does nothing but assign into the fields it is handed — no
|
|
||||||
// RNG, no screen — so it cannot disturb the item tables the seed-compat
|
|
||||||
// golden pins.
|
|
||||||
//
|
|
||||||
// The game it parses into is a throwaway, but it is built the way New
|
|
||||||
// builds the real one, because ParseOpts handles every option and not
|
|
||||||
// just the one this function reads: "inven=" is matched against the
|
|
||||||
// inv_t_name[] table and "file=~/..." against the home directory, both
|
|
||||||
// of which live on the game. A greeting that skimped on them faulted on
|
|
||||||
// a perfectly legal ROGUEOPTS before the player saw a single character.
|
|
||||||
func Greeting(params Params) string {
|
|
||||||
whoami := params.Name
|
|
||||||
|
|
||||||
if params.RogueOpts != "" {
|
|
||||||
opts := &RogueGame{
|
|
||||||
data: newGameData(),
|
|
||||||
Whoami: params.Name,
|
|
||||||
Home: params.Home,
|
|
||||||
}
|
|
||||||
opts.ParseOpts(params.RogueOpts)
|
|
||||||
|
|
||||||
whoami = opts.Whoami
|
|
||||||
}
|
|
||||||
|
|
||||||
if params.Wizard {
|
|
||||||
return fmt.Sprintf("Hello %s, welcome to dungeon #%d",
|
|
||||||
whoami, params.Seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"Hello %s, just a moment while I dig the dungeon...", whoami)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds a game from params, seeds the RNG, and randomizes the item
|
// New builds a game from params, seeds the RNG, and randomizes the item
|
||||||
// appearance tables (the front half of main.c main(); the player roll-up
|
// appearance tables (the front half of main.c main(); the player roll-up
|
||||||
// and first level arrive with later porting phases).
|
// and first level arrive with later porting phases).
|
||||||
@@ -218,7 +161,6 @@ func New(params Params) *RogueGame {
|
|||||||
Depth: 1,
|
Depth: 1,
|
||||||
ScorePath: params.ScorePath,
|
ScorePath: params.ScorePath,
|
||||||
LastScore: -1,
|
LastScore: -1,
|
||||||
sigSave: make(chan *autoSaveRequest, 1),
|
|
||||||
}
|
}
|
||||||
g.Options = Options{
|
g.Options = Options{
|
||||||
SeeFloor: true,
|
SeeFloor: true,
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
game/io.go
26
game/io.go
@@ -166,31 +166,8 @@ func stepOk(ch byte) bool {
|
|||||||
|
|
||||||
// readchar reads and returns a character, checking for gross input errors
|
// readchar reads and returns a character, checking for gross input errors
|
||||||
// (io.c readchar).
|
// (io.c readchar).
|
||||||
//
|
|
||||||
// Waiting for a key is where the game spends nearly all of its wall
|
|
||||||
// clock, so it is also where a signal-triggered autosave usually finds
|
|
||||||
// it: a dropped connection lands while the player is thinking, not
|
|
||||||
// mid-turn. Terminal.Interrupt wakes the read for exactly that, and the
|
|
||||||
// save runs here, on the game goroutine, before reading again.
|
|
||||||
//
|
|
||||||
// What that buys is a snapshot taken by the goroutine that owns the
|
|
||||||
// state, so it is internally consistent and restorable. It is never a
|
|
||||||
// between-commands snapshot: readchar is reached from readCommand at the
|
|
||||||
// top of a turn that has already run its BEFORE daemons and turnUpkeep,
|
|
||||||
// and from prompts raised part-way through a command — --More--,
|
|
||||||
// askOverwrite, getStr, the direction and pack prompts — by which point
|
|
||||||
// the command has mutated state as well. See serviceAutoSaveRequest
|
|
||||||
// (save.go) for the full statement of what the handoff guarantees and
|
|
||||||
// what it costs the player.
|
|
||||||
func (g *RogueGame) readchar() byte {
|
func (g *RogueGame) readchar() byte {
|
||||||
for {
|
ch := g.scr.term.ReadChar()
|
||||||
ch, ok := g.scr.term.ReadChar()
|
|
||||||
if !ok {
|
|
||||||
g.serviceAutoSaveRequest()
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ch == 3 { // ^C
|
if ch == 3 { // ^C
|
||||||
g.quit(0)
|
g.quit(0)
|
||||||
|
|
||||||
@@ -198,7 +175,6 @@ func (g *RogueGame) readchar() byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ch
|
return ch
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusCache is the set of static shadow variables in io.c status() that
|
// statusCache is the set of static shadow variables in io.c status() that
|
||||||
|
|||||||
@@ -1,751 +0,0 @@
|
|||||||
//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.
|
|
||||||
203
game/save.go
203
game/save.go
@@ -6,8 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
|
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
|
||||||
@@ -645,210 +643,40 @@ func (g *RogueGame) askOverwrite() saveAnswer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveFile writes the saved game (save.c save_file).
|
// saveFile writes the saved game (save.c save_file). A failed write means
|
||||||
//
|
// a corrupt save, so the file is removed before reporting the error.
|
||||||
// The snapshot goes to a temporary file in the target's own directory and
|
|
||||||
// is renamed over the target, so there is no instant at which the player
|
|
||||||
// has no save file: until the rename the old file is whole, and after it
|
|
||||||
// the new one is. C wrote straight over the target, and this port did the
|
|
||||||
// same with a remove in front of it (AutoSave), so a write that failed —
|
|
||||||
// or a signal-time save cut short by the process dying — could leave the
|
|
||||||
// player with neither the old save nor a usable new one (issue #24).
|
|
||||||
//
|
|
||||||
// The temporary file is fsynced before the rename so its contents reach
|
|
||||||
// the disk ahead of the directory entry that will point at it. The
|
|
||||||
// directory itself is not fsynced: that would only matter for a machine
|
|
||||||
// that loses power in the same instant, and the old save survives that
|
|
||||||
// case anyway. A process killed mid-encode leaves its temporary file
|
|
||||||
// behind, which is litter next to a destroyed save file, and the dot
|
|
||||||
// prefix keeps it out of the way.
|
|
||||||
func (g *RogueGame) saveFile(path string) error {
|
func (g *RogueGame) saveFile(path string) error {
|
||||||
f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp := f.Name()
|
encErr := gob.NewEncoder(f).Encode(g.snapshot())
|
||||||
|
|
||||||
writeErr := writeSnapshotFile(f, g.snapshot())
|
|
||||||
if writeErr != nil {
|
|
||||||
_ = os.Remove(tmp) // never leave a half-written file behind
|
|
||||||
|
|
||||||
return writeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
renErr := os.Rename(tmp, path)
|
|
||||||
if renErr != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
|
|
||||||
return renErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSnapshotFile writes the snapshot into an open temporary file: it
|
|
||||||
// encodes, fsyncs so the bytes reach the disk before the caller renames
|
|
||||||
// the file into place, chmods it read-only as the C game's saves were
|
|
||||||
// (save.c save_file), and closes it. It never removes the file: its
|
|
||||||
// caller owns the cleanup, so that one place decides what happens to a
|
|
||||||
// failed write.
|
|
||||||
func writeSnapshotFile(f *os.File, st *SaveState) error {
|
|
||||||
encErr := gob.NewEncoder(f).Encode(st)
|
|
||||||
if encErr == nil {
|
|
||||||
encErr = f.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
if encErr == nil {
|
|
||||||
encErr = f.Chmod(0o400)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeErr := f.Close()
|
closeErr := f.Close()
|
||||||
|
|
||||||
|
if encErr != nil || closeErr != nil {
|
||||||
|
_ = os.Remove(path) // don't leave a corrupt save behind
|
||||||
|
|
||||||
if encErr != nil {
|
if encErr != nil {
|
||||||
return encErr
|
return encErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return closeErr
|
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()
|
return os.Chmod(path, 0o400)
|
||||||
|
|
||||||
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
|
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
|
||||||
// one is waiting, and otherwise returns at once. It runs on the game
|
// (save.c auto_save). Best effort by design: it runs on the way out of a
|
||||||
// goroutine — that is the whole design — so it must only be called where
|
// dying process.
|
||||||
// that goroutine is not itself inside the encode: between turns, or while
|
func (g *RogueGame) AutoSave() {
|
||||||
// parked waiting for input or for the shell escape.
|
if g.FileName != "" {
|
||||||
//
|
_ = os.Remove(g.FileName)
|
||||||
// What is guaranteed, exactly: the encode runs on the one goroutine that
|
_ = g.saveFile(g.FileName)
|
||||||
// owns the state, so the snapshot is internally consistent and always
|
|
||||||
// restorable. It is *not* guaranteed to be a between-commands snapshot.
|
|
||||||
// Only one of the three service points gives that: the check at the top
|
|
||||||
// of command, which runs after the previous command returned and before
|
|
||||||
// this turn's DoDaemons(Before)/DoFuses(Before). The other two are both
|
|
||||||
// reached from inside a command call already under way, and both cost
|
|
||||||
// the same on restore.
|
|
||||||
//
|
|
||||||
// readchar is reached from prompts raised part-way through a command —
|
|
||||||
// --More-- on the second message of a turn, askOverwrite, getStr, the
|
|
||||||
// direction and pack prompts — and by then the command has already
|
|
||||||
// mutated state: fight sets g.Count and g.Quiet and runs runTo before
|
|
||||||
// any message, revealXeroc writes tp.Disguise before emitting one. The
|
|
||||||
// ordinary top-of-turn key read in readCommand is inside command too,
|
|
||||||
// after that turn's BEFORE daemons and turnUpkeep.
|
|
||||||
//
|
|
||||||
// runShellEscape is no safer. shell is an ordinary command handler ('!'
|
|
||||||
// in the tables.go dispatch table), reached through executeCommand, so a
|
|
||||||
// goroutine parked in the shell escape has already run this turn's
|
|
||||||
// DoDaemons(Before), DoFuses(Before), turnUpkeep and the last-command
|
|
||||||
// bookkeeping, and has not yet run DoDaemons(After), DoFuses(After) or
|
|
||||||
// ringTurnEffects.
|
|
||||||
//
|
|
||||||
// The cost, at both: restoring re-enters playit at the top of command,
|
|
||||||
// so the rest of that command never runs — its AFTER daemons and fuses
|
|
||||||
// and its ring effects are lost — and the restored game opens with a
|
|
||||||
// fresh BEFORE pass on top of the one already in the snapshot. That
|
|
||||||
// second BEFORE pass is not free: rollwand, a live Before daemon once
|
|
||||||
// swander has fired, ticks again and draws from the RNG every fourth
|
|
||||||
// tick, and any Before fuse is decremented again.
|
|
||||||
//
|
|
||||||
// Not every consequence of that pass is shared by both, though. visuals
|
|
||||||
// returns immediately unless g.After, and After is part of the snapshot,
|
|
||||||
// so DVisuals never re-ticks after a shell-escape save: shell sets
|
|
||||||
// g.After = false as its first statement, before it parks. After a
|
|
||||||
// readchar save it usually does re-tick, because turnUpkeep sets
|
|
||||||
// g.After = true just before the top-of-turn read; the exception is a
|
|
||||||
// handler that clears After before prompting, as identifyTrapCommand
|
|
||||||
// does ahead of promptDirection.
|
|
||||||
//
|
|
||||||
// The result is still a coherent game state, one turn's worth of effects
|
|
||||||
// off — strictly better than the torn encode this replaced, and the cost
|
|
||||||
// of being able to save a player whose line dropped mid-prompt, or who
|
|
||||||
// is away in a shell, at all.
|
|
||||||
func (g *RogueGame) serviceAutoSaveRequest() {
|
|
||||||
select {
|
|
||||||
case req := <-g.sigSave:
|
|
||||||
g.runAutoSaveRequest(req)
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAutoSaveRequest answers one request: save, then release the waiter.
|
|
||||||
func (g *RogueGame) runAutoSaveRequest(req *autoSaveRequest) {
|
|
||||||
req.ok = g.autoSave()
|
|
||||||
|
|
||||||
close(req.done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// autoSave silently saves to the current file name (save.c auto_save),
|
|
||||||
// reporting whether it wrote a save. Game-goroutine only — reach it
|
|
||||||
// through AutoSaveOnSignal from anywhere else.
|
|
||||||
//
|
|
||||||
// The error is not surfaced: there is no player to tell, since the
|
|
||||||
// terminal is on its way out, and nothing sensible to do about it. It is
|
|
||||||
// reported to the waiting signal goroutine as a failed save rather than
|
|
||||||
// discarded outright, which is what the old `_ =` here used to do.
|
|
||||||
func (g *RogueGame) autoSave() bool {
|
|
||||||
if g.FileName == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return g.saveFile(g.FileName) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrSaveOutOfDate reports a save file from an incompatible version.
|
// ErrSaveOutOfDate reports a save file from an incompatible version.
|
||||||
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
|
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
|
||||||
|
|
||||||
@@ -918,7 +746,6 @@ func Restore(path string, params Params) (*RogueGame, error) {
|
|||||||
FileName: path,
|
FileName: path,
|
||||||
rogueOpts: params.RogueOpts,
|
rogueOpts: params.RogueOpts,
|
||||||
restored: true,
|
restored: true,
|
||||||
sigSave: make(chan *autoSaveRequest, 1),
|
|
||||||
}
|
}
|
||||||
g.scr = NewScreen(params.Term)
|
g.scr = NewScreen(params.Term)
|
||||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||||
|
|||||||
@@ -14,24 +14,8 @@ type Terminal interface {
|
|||||||
// Render blits the window to the device.
|
// Render blits the window to the device.
|
||||||
Render(w *Window)
|
Render(w *Window)
|
||||||
// ReadChar blocks for the next key, translated to Rogue's input bytes
|
// ReadChar blocks for the next key, translated to Rogue's input bytes
|
||||||
// (arrows become hjkl, control keys their C0 codes). ok is false when
|
// (arrows become hjkl, control keys their C0 codes).
|
||||||
// the read was woken by Interrupt instead of by a key, which is how a
|
ReadChar() byte
|
||||||
// signal-triggered autosave reaches a game parked on input; the byte
|
|
||||||
// is meaningless then.
|
|
||||||
ReadChar() (ch byte, ok bool)
|
|
||||||
// Interrupt wakes a ReadChar that is blocked waiting for a key. It is
|
|
||||||
// the one Terminal method called from another goroutine, so an
|
|
||||||
// implementation must be safe to call concurrently with ReadChar.
|
|
||||||
Interrupt()
|
|
||||||
// Repaint forces the device to redraw every cell it is showing, the
|
|
||||||
// redraw command's whole point (curses clearok(curscr, TRUE) followed
|
|
||||||
// by wrefresh(curscr)). Render cannot stand in for it: a device that
|
|
||||||
// diffs against its own idea of what is on screen will do nothing at
|
|
||||||
// all when the screen has been corrupted by something else's output,
|
|
||||||
// which is the case the player types CTRL-R for. It repaints what was
|
|
||||||
// last rendered — C repainted curscr, not stdscr — so it neither
|
|
||||||
// needs nor takes a window.
|
|
||||||
Repaint()
|
|
||||||
// Fini restores the device to its pre-game state (curses endwin). The
|
// Fini restores the device to its pre-game state (curses endwin). The
|
||||||
// game calls it on its way out, since one game run is one process.
|
// game calls it on its way out, since one game run is one process.
|
||||||
Fini()
|
Fini()
|
||||||
@@ -232,14 +216,6 @@ func (s *Screen) Refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repaint forces the device to redraw everything it is showing, if there
|
|
||||||
// is a device (curses clearok(curscr, TRUE) + wrefresh(curscr)).
|
|
||||||
func (s *Screen) Repaint() {
|
|
||||||
if s.term != nil {
|
|
||||||
s.term.Repaint()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fini restores the terminal device, if there is one (curses endwin).
|
// Fini restores the terminal device, if there is one (curses endwin).
|
||||||
func (s *Screen) Fini() {
|
func (s *Screen) Fini() {
|
||||||
if s.term != nil {
|
if s.term != nil {
|
||||||
@@ -247,15 +223,6 @@ func (s *Screen) Fini() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interrupt wakes a device read that is blocked waiting for a key, if
|
|
||||||
// there is a device. Called from the signal goroutine; everything else on
|
|
||||||
// Screen belongs to the game goroutine.
|
|
||||||
func (s *Screen) Interrupt() {
|
|
||||||
if s.term != nil {
|
|
||||||
s.term.Interrupt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
|
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
|
||||||
func (s *Screen) RefreshWin(w *Window) {
|
func (s *Screen) RefreshWin(w *Window) {
|
||||||
if s.term != nil {
|
if s.term != nil {
|
||||||
@@ -280,4 +247,3 @@ func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
|
|||||||
func (g *RogueGame) clear() { g.scr.Std.Clear() }
|
func (g *RogueGame) clear() { g.scr.Std.Clear() }
|
||||||
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
|
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
|
||||||
func (g *RogueGame) refresh() { g.scr.Refresh() }
|
func (g *RogueGame) refresh() { g.scr.Refresh() }
|
||||||
func (g *RogueGame) repaint() { g.scr.Repaint() }
|
|
||||||
|
|||||||
@@ -31,30 +31,14 @@ func (g *RogueGame) doZap() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// C's switch has a case for every one of the 14 WS_ kinds, so its
|
// A malformed Which yields no handler, which is exactly what C did in a
|
||||||
// closing "otherwise: msg(...)" arm is reachable only for an o_which
|
// non-MASTER build: its do_zap switch matched no case, fell out, and
|
||||||
// outside the table — the malformed objects hasValidWhich screens
|
// still ran o_charges--. (The MASTER-only "what a bizarre schtick!"
|
||||||
// for, which is why no handler and a legal Which can only mean WS_NOP.
|
// message for that arm is issue #13, not this bounds fix.)
|
||||||
//
|
if h := g.data.zapHandler(obj); h != nil {
|
||||||
// The message is under #ifdef MASTER, not under a wizard test: C
|
|
||||||
// printed it for every player of a MASTER build, which is the build
|
|
||||||
// this port is (see the '+' command, issue #11). Do not gate it on
|
|
||||||
// g.Wizard.
|
|
||||||
//
|
|
||||||
// WS_NOP is a case of its own ("when WS_NOP: break;"): the wand that
|
|
||||||
// deliberately does nothing says nothing either. All three arms fall
|
|
||||||
// out of the switch into o_charges--, so even the bizarre schtick
|
|
||||||
// costs a charge.
|
|
||||||
h := g.data.zapHandler(obj)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case h != nil:
|
|
||||||
if !h(g, obj) {
|
if !h(g, obj) {
|
||||||
return // the zap aborted; no charge is used
|
return // the zap aborted; no charge is used
|
||||||
}
|
}
|
||||||
case obj.hasValidWhich(): // WS_NOP
|
|
||||||
default:
|
|
||||||
g.msg("what a bizarre schtick!")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
obj.Charges--
|
obj.Charges--
|
||||||
|
|||||||
@@ -1,874 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -785,10 +785,7 @@ func newGameData() *gameData {
|
|||||||
},
|
},
|
||||||
CTRL('R'): func(g *RogueGame) {
|
CTRL('R'): func(g *RogueGame) {
|
||||||
g.After = false
|
g.After = false
|
||||||
// C forces a full repaint here (clearok + wrefresh on
|
g.refresh()
|
||||||
// curscr), not the diffing refresh: the command exists
|
|
||||||
// for screens the game's own record no longer matches.
|
|
||||||
g.repaint()
|
|
||||||
},
|
},
|
||||||
'v': func(g *RogueGame) {
|
'v': func(g *RogueGame) {
|
||||||
g.After = false
|
g.After = false
|
||||||
@@ -805,7 +802,6 @@ func newGameData() *gameData {
|
|||||||
g.After = false // "legal" illegal command
|
g.After = false // "legal" illegal command
|
||||||
},
|
},
|
||||||
'^': (*RogueGame).identifyTrapCommand,
|
'^': (*RogueGame).identifyTrapCommand,
|
||||||
'+': (*RogueGame).wizardToggleCommand,
|
|
||||||
Escape: func(g *RogueGame) {
|
Escape: func(g *RogueGame) {
|
||||||
g.DoorStop = false
|
g.DoorStop = false
|
||||||
g.Count = 0
|
g.Count = 0
|
||||||
|
|||||||
@@ -8,36 +8,24 @@ type testTerm struct {
|
|||||||
input []byte
|
input []byte
|
||||||
pos int
|
pos int
|
||||||
tick int
|
tick int
|
||||||
// repaints counts forced full redraws. Rendering is a no-op here, so
|
|
||||||
// counting is the only way a headless test can tell that CTRL-R asked
|
|
||||||
// for a repaint rather than an ordinary refresh — the two are
|
|
||||||
// indistinguishable in the window contents, which is the whole reason
|
|
||||||
// the bug this replaces went unnoticed.
|
|
||||||
repaints int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testTerm) Render(*Window) {}
|
func (t *testTerm) Render(*Window) {}
|
||||||
|
|
||||||
func (t *testTerm) Repaint() { t.repaints++ }
|
|
||||||
|
|
||||||
func (t *testTerm) Fini() {}
|
func (t *testTerm) Fini() {}
|
||||||
|
|
||||||
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
|
func (t *testTerm) ReadChar() byte {
|
||||||
// The blocking case has its own fake, blockingTerm in autosave_test.go.
|
|
||||||
func (t *testTerm) Interrupt() {}
|
|
||||||
|
|
||||||
func (t *testTerm) ReadChar() (byte, bool) {
|
|
||||||
if t.pos < len(t.input) {
|
if t.pos < len(t.input) {
|
||||||
c := t.input[t.pos]
|
c := t.input[t.pos]
|
||||||
t.pos++
|
t.pos++
|
||||||
|
|
||||||
return c, true
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
t.tick++
|
t.tick++
|
||||||
if t.tick%2 == 0 {
|
if t.tick%2 == 0 {
|
||||||
return '\n', true
|
return '\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
return ' ', true
|
return ' '
|
||||||
}
|
}
|
||||||
|
|||||||
1140
game/traps_test.go
1140
game/traps_test.go
File diff suppressed because it is too large
Load Diff
1071
game/wizard_test.go
1071
game/wizard_test.go
File diff suppressed because it is too large
Load Diff
37
script/lint
37
script/lint
@@ -1,37 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/lint: lint in docker. golangci-lint is never installed on the host.
|
|
||||||
#
|
|
||||||
# Traps, each of which yields a green run over an unlinted or partly linted
|
|
||||||
# tree:
|
|
||||||
#
|
|
||||||
# 1. --target and --no-cache-filter must both stay, and $stage must match
|
|
||||||
# the stage name in Dockerfile.lint. BuildKit ignores --no-cache-filter
|
|
||||||
# when no stage matches its argument, serving the lint layer from cache
|
|
||||||
# without a word; --target rejects a name that is not in the file, which
|
|
||||||
# is what makes the single $stage safe.
|
|
||||||
#
|
|
||||||
# 2. --target checks that the stage exists, not that it is the stage
|
|
||||||
# running golangci-lint, and it halts the build there. Moving the lint
|
|
||||||
# step to another stage, or adding a stage after it, is not caught.
|
|
||||||
#
|
|
||||||
# 3. .dockerignore decides what reaches the container, and only what
|
|
||||||
# reaches it is linted. Excluding a self-contained Go file drops it from
|
|
||||||
# the lint silently. Never exclude Go sources, go.mod/go.sum or
|
|
||||||
# .golangci.yml.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
# Must match the stage name in Dockerfile.lint.
|
|
||||||
stage=lint
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
docker build \
|
|
||||||
--target "$stage" \
|
|
||||||
--no-cache-filter="$stage" \
|
|
||||||
--output=type=cacheonly \
|
|
||||||
-f Dockerfile.lint .
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -78,20 +78,9 @@ func (t *Tcell) Render(w *game.Window) {
|
|||||||
t.screen.Show()
|
t.screen.Show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repaint redraws the whole physical screen from tcell's content buffer
|
|
||||||
// — which holds what Render last blitted, so this is C's clearok(curscr,
|
|
||||||
// TRUE) + wrefresh(curscr) (command.c, the CTRL('R') arm) rather than a
|
|
||||||
// fresh draw of stdscr. Sync throws away tcell's record of what the
|
|
||||||
// terminal is showing, so unlike Show it repaints cells it believes are
|
|
||||||
// already correct, which is what makes it fix a corrupted screen.
|
|
||||||
func (t *Tcell) Repaint() {
|
|
||||||
t.screen.Sync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadChar blocks for the next key, translated to the byte codes the C
|
// ReadChar blocks for the next key, translated to the byte codes the C
|
||||||
// game reads: arrows become hjkl, control keys their C0 codes. ok is
|
// game reads: arrows become hjkl, control keys their C0 codes.
|
||||||
// false when Interrupt woke the read instead of a key arriving.
|
func (t *Tcell) ReadChar() byte {
|
||||||
func (t *Tcell) ReadChar() (byte, bool) {
|
|
||||||
for {
|
for {
|
||||||
ev := t.screen.PollEvent()
|
ev := t.screen.PollEvent()
|
||||||
switch ev := ev.(type) {
|
switch ev := ev.(type) {
|
||||||
@@ -99,32 +88,14 @@ func (t *Tcell) ReadChar() (byte, bool) {
|
|||||||
if t.last != nil {
|
if t.last != nil {
|
||||||
t.Render(t.last)
|
t.Render(t.last)
|
||||||
}
|
}
|
||||||
case *tcell.EventInterrupt:
|
|
||||||
// Interrupt posted this from the signal goroutine: hand
|
|
||||||
// control back so the game goroutine can service a pending
|
|
||||||
// autosave, then it reads again.
|
|
||||||
return 0, false
|
|
||||||
case *tcell.EventKey:
|
case *tcell.EventKey:
|
||||||
if b, ok := translateKey(ev); ok {
|
if b, ok := translateKey(ev); ok {
|
||||||
return b, true
|
return b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interrupt wakes a ReadChar parked in PollEvent by posting an interrupt
|
|
||||||
// event onto tcell's own event queue — the mechanism tcell provides for
|
|
||||||
// exactly this, and the only Tcell method called from another goroutine
|
|
||||||
// (Screen.PostEvent is a channel send, safe to call concurrently).
|
|
||||||
//
|
|
||||||
// Best effort by design: PostEvent fails only when the event queue is
|
|
||||||
// full, which means the game goroutine is not parked waiting for a key,
|
|
||||||
// and a game goroutine that is running turns reaches the between-turns
|
|
||||||
// check on its own.
|
|
||||||
func (t *Tcell) Interrupt() {
|
|
||||||
_ = t.screen.PostEvent(tcell.NewEventInterrupt(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// translateKey converts a key event to a game input byte; ok is false
|
// translateKey converts a key event to a game input byte; ok is false
|
||||||
// for keys the C game does not understand.
|
// for keys the C game does not understand.
|
||||||
func translateKey(ev *tcell.EventKey) (byte, bool) {
|
func translateKey(ev *tcell.EventKey) (byte, bool) {
|
||||||
|
|||||||
Reference in New Issue
Block a user