Porovnat revize
53
Commity
88f18fc635
..
next
| Autor | SHA1 | Datum | |
|---|---|---|---|
|
|
e3ab4aba8b | ||
|
|
60442ce103 | ||
|
|
6f997b8d5c | ||
|
|
9f079ab594 | ||
|
|
3eb9f81fc4 | ||
|
|
20cfb47912 | ||
|
|
329c03f06e | ||
|
|
599286a88e | ||
|
|
bde4eae450 | ||
|
|
3061931291 | ||
|
|
13caec4298 | ||
|
|
df45f4cb24 | ||
|
|
ba444a2002 | ||
|
|
6f409bda9e | ||
|
|
c0741ad1ea | ||
|
|
29fbedb77d | ||
|
|
bf820e3ec9 | ||
|
|
c61e2827c5 | ||
|
|
2f7a0d980d | ||
|
|
2e02e7d190 | ||
|
|
a653cc76f2 | ||
|
|
1142f43aed | ||
|
|
727dfb2642 | ||
|
|
c95f98ffe5 | ||
|
|
630038eedb | ||
|
|
f7670cf86a | ||
|
|
85354f2e6b | ||
|
|
3a01283358 | ||
|
|
e1bf46b241 | ||
|
|
dfb34be1c4 | ||
|
|
4aa4babe40 | ||
|
|
af3050b187 | ||
|
|
eb31473ef0 | ||
|
|
56bcad9fc6 | ||
|
|
c922a16781 | ||
|
|
e376b2bf11 | ||
|
|
d6cd418f38 | ||
|
|
63d1e797e2 | ||
|
|
8ce238dd62 | ||
|
|
c30da22e43 | ||
|
|
e595b87718 | ||
|
|
11223caa7c | ||
|
|
e7e1bc3c40 | ||
|
|
061da11877 | ||
|
|
0e6ed41351 | ||
|
|
b431af8b74 | ||
|
|
cb1e302102 | ||
|
|
bcdfaf4ab4 | ||
|
|
a7d27ef65f | ||
|
|
194ce1dd16 | ||
|
|
cd0ba6c8ee | ||
|
|
8241cf4bee | ||
|
|
35b538e888 |
@@ -0,0 +1,8 @@
|
||||
# Part of the lint gate: only what reaches the container is linted, so
|
||||
# excluding a self-contained Go source here drops it from the lint silently.
|
||||
# Never exclude Go sources, go.mod/go.sum or .golangci.yml.
|
||||
.git
|
||||
|
||||
# Generated artifacts only; `make build` puts a multi-megabyte binary here
|
||||
# and it would otherwise be shipped into the build context.
|
||||
/build/
|
||||
@@ -1,4 +1,5 @@
|
||||
*.log
|
||||
*.out
|
||||
*.test
|
||||
/build/
|
||||
/rogue
|
||||
|
||||
+5
-9
@@ -1,5 +1,9 @@
|
||||
version: "2"
|
||||
|
||||
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||
# linters.settings, not top-level linters-settings) so that the
|
||||
# thresholds below are actually applied by golangci-lint >= v2.
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
@@ -14,14 +18,7 @@ linters:
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
# Repo-specific exceptions approved by sneak (2026-07-06)
|
||||
- paralleltest # Requires t.Parallel() in every test
|
||||
# Approved by sneak 2026-07-07
|
||||
- testpackage # Tests use internal package game to reach unexported state
|
||||
- exhaustive # C-faithful switches handle only the cases C handled
|
||||
- mnd # C-faithful gameplay literals; naming them hurts C-greppability
|
||||
|
||||
linters-settings:
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
@@ -33,6 +30,5 @@ linters-settings:
|
||||
threshold: 100
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
+648
-269
Rozdílový obsah nebyl zobrazen, protože je příliš veliký
Načíst rozdílové porovnání
@@ -0,0 +1,20 @@
|
||||
# Lint image, built by script/lint: golangci-lint runs as a build step, so
|
||||
# a successful build is a clean lint.
|
||||
|
||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# This stage must stay the one that runs golangci-lint, and its name must
|
||||
# match $stage in script/lint. --target halts the build at this stage, so
|
||||
# moving the lint step to another stage, or adding a stage after this one,
|
||||
# is not caught.
|
||||
FROM deps AS lint
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
+74
-30
@@ -1,44 +1,88 @@
|
||||
# Project Memory
|
||||
|
||||
Working notes for agents on this repo. Read this alongside TODO.md
|
||||
(which holds the step queue and workflow) before starting work.
|
||||
Working notes for agents on this repo. Read this alongside TODO.md (which holds
|
||||
the step queue and workflow) before starting work.
|
||||
|
||||
## Error handling
|
||||
|
||||
Panicking on bad/unexpected errors is allowed and preferred over
|
||||
threading unlikely error returns through game code — e.g. write-side
|
||||
Close/encode failures where continuing would mean corrupt state. The
|
||||
game already unwinds C's exit() calls via a gameEnd panic recovered in
|
||||
Run. Return errors where a caller genuinely handles them (save-file
|
||||
prompts, restore validation). Reserve deliberate `_ =` discards for
|
||||
true best-effort paths (scorefile writes, signal-time autosave), always
|
||||
with a comment saying why.
|
||||
Panicking on bad/unexpected errors is allowed and preferred over threading
|
||||
unlikely error returns through game code — e.g. write-side Close/encode failures
|
||||
where continuing would mean corrupt state. Return errors where a caller
|
||||
genuinely handles them (save-file prompts, restore validation). Reserve
|
||||
deliberate `_ =` discards for true best-effort paths (scorefile writes,
|
||||
`Terminal.Interrupt`'s post to a full event queue), always with a comment saying
|
||||
why.
|
||||
|
||||
Signal-time autosave used to be on that list and no longer is (issue #24). It is
|
||||
best effort in the sense that nothing can be reported to a player whose terminal
|
||||
is already going away, but the outcome is a value, not a discard: the signal
|
||||
goroutine calls `AutoSaveOnSignal`, which hands the save to the game goroutine —
|
||||
the only one allowed to touch game state — and returns whether it was taken
|
||||
before the deadline. The game answers between turns (`command`), while parked
|
||||
waiting for a key (`readchar`), and while parked in the `!` shell escape
|
||||
(`runShellEscape`). `saveFile` writes a temporary file and renames it over the
|
||||
target, so a save that fails or never happens leaves the player's previous save
|
||||
whole; never reintroduce a `Remove` before the write in `autoSave`, and never
|
||||
encode game state from any goroutine but the game's.
|
||||
|
||||
Do not upgrade that into "the snapshot is always taken between commands" — it is
|
||||
not. What is true is that the encode runs on the state-owning goroutine, so the
|
||||
snapshot is internally consistent and restorable. Only the check at the top of
|
||||
`command` is a between-commands snapshot; the other two service points both sit
|
||||
inside a `command` call already under way. `readchar` is reached from
|
||||
mid-command prompts (`--More--`, `askOverwrite`, `getStr`, direction and pack
|
||||
prompts) with the command's mutations already applied, and `runShellEscape` is
|
||||
reached from `shell`, an ordinary `'!'` command handler, with that turn's
|
||||
`DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER pass not yet.
|
||||
Restoring re-enters `playit` at the top of `command`, so either way the rest of
|
||||
that command is lost and a fresh BEFORE pass runs on top of the one already in
|
||||
the snapshot. That is acceptable and documented; two successive false claims —
|
||||
first that `readchar` was safe, then that two of the three service points were
|
||||
between-commands — were caught in review of PR #26, and neither may come back.
|
||||
|
||||
Related, and easy to reintroduce: work moved onto a helper goroutine must not be
|
||||
allowed to panic there. A panic at the top of any goroutine kills the process
|
||||
without running the other goroutines' defers, including `cmd/rogue/main.go`'s
|
||||
`defer t.Fini()`, which is what leaves a raw tty (issue #12). `runShellEscape`
|
||||
recovers its helper's panic and re-raises it on the game goroutine for exactly
|
||||
that reason.
|
||||
|
||||
C's exit() calls are not unwound: one game run is one process, so myExit
|
||||
(game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and
|
||||
Run() never returns. There is nothing to recover — do not write code that
|
||||
expects to regain control after game-over. The testing consequence is that any
|
||||
death (combat, starvation, level drain, freezing) exits the _test binary_, so
|
||||
tests drive command() directly rather than Run(), and crash-sweep drives pin the
|
||||
hero each turn with the fortify() helper in game/run_test.go.
|
||||
|
||||
## Linting
|
||||
|
||||
The .golangci.yml is the house standard and may only be modified with
|
||||
sneak's explicit permission. To disable a linter, ask, explaining what
|
||||
the linter does; he approves specific exceptions, which are recorded
|
||||
in the config's "Repo-specific exceptions" block with the approval
|
||||
date. Approved so far: paralleltest (2026-07-06); testpackage,
|
||||
exhaustive, and mnd (2026-07-07). The complexity linters (cyclop,
|
||||
gocognit, nestif) are enabled and clean as of refactor step 7
|
||||
(2026-07-07): the whole golangci-lint run is 0 issues, so keep it that
|
||||
way — decompose new hot spots rather than reaching for a nolint.
|
||||
Line-level //nolint with a reason is used sparingly for C-faithfulness
|
||||
(e.g. the authentic "missle" message spellings) and provably-safe gosec
|
||||
conversions; each needs a justifying comment.
|
||||
The .golangci.yml is byte-identical to the canonical shared config and must not
|
||||
be edited — not even to add an exception. To disable a linter, ask sneak,
|
||||
explaining what the linter does; approved exceptions are recorded as in-code
|
||||
//nolint directives (file-level where a whole file is affected) carrying the
|
||||
approval date, which is what keeps the config canonical. Approved so far:
|
||||
testpackage, exhaustive, and mnd (2026-07-07). paralleltest was approved on
|
||||
2026-07-06 but the exception is no longer in force — it was fixed instead, with
|
||||
t.Parallel() in all 32 tests. The complexity linters (cyclop, gocognit, nestif)
|
||||
are enabled and clean as of refactor step 7 (2026-07-07): the whole
|
||||
golangci-lint run is 0 issues, so keep it that way — decompose new hot spots
|
||||
rather than reaching for a nolint. Line-level //nolint with a reason is used
|
||||
sparingly for C-faithfulness (e.g. the authentic "missle" message spellings) and
|
||||
provably-safe gosec conversions; each needs a justifying comment.
|
||||
|
||||
## Faithfulness
|
||||
|
||||
Behavior must not change during the idiomatic-Go refactor unless a
|
||||
TODO step says so. The 80x24 seed-compatible gameplay, message text
|
||||
(including original typos), RNG call order, and C quirks (documented
|
||||
in tests like TestHoldScrollGreedyMonsterQuirk) are contract. Doc
|
||||
comments keep their "(file.c func_name)" breadcrumbs.
|
||||
Behavior must not change during the idiomatic-Go refactor unless a TODO step
|
||||
says so. The 80x24 seed-compatible gameplay, message text (including original
|
||||
typos), RNG call order, and C quirks (documented in tests like
|
||||
TestHoldScrollGreedyMonsterQuirk) are contract. Doc comments keep their "(file.c
|
||||
func_name)" breadcrumbs.
|
||||
|
||||
## Debugging
|
||||
|
||||
Write real, committed test files with t.Logf output and run plain
|
||||
`go test -v`; no throwaway scratch scripts. Successful debug probes
|
||||
become regression tests.
|
||||
Write real, committed test files with t.Logf output and run them with the make
|
||||
targets — `make test` (or `make check` for the full gate); never raw `go test`.
|
||||
The target carries `-timeout 30s -race -cover` and reruns verbosely on failure,
|
||||
so a raw invocation silently drops the race detector. No throwaway scratch
|
||||
scripts. Successful debug probes become regression tests.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Development convenience targets. This repo is exempt from the standard
|
||||
# policy scaffold (no CI config, no REPO_POLICIES.md, no application
|
||||
# Dockerfile) except for the lint container: per sneak's 2026-08-09
|
||||
# ruling, linting runs in docker only, so Dockerfile.lint and script/lint
|
||||
# are part of this repo. This Makefile is otherwise only a thin wrapper
|
||||
# around the Go toolchain and prettier so `make fmt` / `make check` behave
|
||||
# the same as in sneak's other repos.
|
||||
|
||||
GO_PKGS := ./...
|
||||
MD_FILES := $(shell git ls-files '*.md')
|
||||
PRETTIER := prettier --tab-width 4 --prose-wrap always
|
||||
|
||||
# Every generated artifact goes here, and the whole directory is
|
||||
# git-ignored. Targets that write outside it can commit their output.
|
||||
BUILD_DIR := build
|
||||
BIN := $(BUILD_DIR)/rogue
|
||||
COVERPROF := $(BUILD_DIR)/coverage.out
|
||||
COVERHTML := $(BUILD_DIR)/coverage.html
|
||||
|
||||
.PHONY: build check cover cover-html fmt fmt-check lint test
|
||||
|
||||
# Format, lint, and test — the full local pre-commit gate. Keep this list
|
||||
# to targets that write nothing into the working tree.
|
||||
check: fmt-check lint test
|
||||
|
||||
# Build the executable into $(BUILD_DIR). `go build -o` does not create the
|
||||
# parent directory.
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build -o $(BIN) ./cmd/rogue
|
||||
|
||||
# Per-function coverage, for finding which functions are untested. The
|
||||
# percentage `make test` prints is a per-package total and cannot answer
|
||||
# that. Writes files, so it stays out of `check`.
|
||||
cover:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go test -timeout 30s -coverprofile=$(COVERPROF) $(GO_PKGS)
|
||||
go tool cover -func=$(COVERPROF)
|
||||
|
||||
# Render the same profile as annotated source.
|
||||
cover-html: cover
|
||||
go tool cover -html=$(COVERPROF) -o $(COVERHTML)
|
||||
@echo "wrote $(COVERHTML)"
|
||||
|
||||
# Format Go and Markdown in place.
|
||||
fmt:
|
||||
gofmt -w .
|
||||
$(PRETTIER) --write $(MD_FILES)
|
||||
|
||||
# Fail if any Go or Markdown file is not formatted.
|
||||
fmt-check:
|
||||
@unformatted="$$(gofmt -l .)"; \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; \
|
||||
fi
|
||||
$(PRETTIER) --check $(MD_FILES)
|
||||
|
||||
# Run the house linter. golangci-lint is never installed on the host: the
|
||||
# work happens inside the pinned container built by Dockerfile.lint, and
|
||||
# this target is a thin shim over the script that builds it.
|
||||
lint:
|
||||
./script/lint
|
||||
|
||||
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
|
||||
# full output and still fail the target (the first run already proved the
|
||||
# tests are broken, so a flaky pass on the rerun must not rescue the build).
|
||||
test:
|
||||
@go test -timeout 30s -race -cover $(GO_PKGS) || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 30s -race -v $(GO_PKGS); exit 1; }
|
||||
+36
-27
@@ -2,51 +2,50 @@
|
||||
|
||||
[](LICENSE.TXT)
|
||||
|
||||
**Rogue** is the original dungeon-crawling adventure game that spawned an
|
||||
entire genre. This branch is a faithful Go port of Rogue 5.4.4: explore
|
||||
procedurally generated dungeons, fight monsters, collect treasure, and
|
||||
attempt to retrieve the Amulet of Yendor.
|
||||
**Rogue** is the original dungeon-crawling adventure game that spawned an entire
|
||||
genre. This branch is a faithful Go port of Rogue 5.4.4: explore procedurally
|
||||
generated dungeons, fight monsters, collect treasure, and attempt to retrieve
|
||||
the Amulet of Yendor.
|
||||
|
||||
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman
|
||||
(1980–1983, 1985, 1999).
|
||||
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman (1980–1983,
|
||||
1985, 1999).
|
||||
|
||||
The port is function-by-function faithful to the classic C sources — same
|
||||
dungeon generation (seed-compatible RNG), same combat math, same item
|
||||
tables, same messages. The C reference implementation lives on the
|
||||
`master` and `modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
documents both the original program structure and the design of this port.
|
||||
dungeon generation (seed-compatible RNG), same combat math, same item tables,
|
||||
same messages. The C reference implementation lives on the `master` and
|
||||
`modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md) documents both the
|
||||
original program structure and the design of this port.
|
||||
|
||||
## Building and running
|
||||
|
||||
Requires Go 1.25 or later and a terminal at least 80x24.
|
||||
|
||||
```bash
|
||||
go build ./cmd/rogue
|
||||
./rogue
|
||||
make build
|
||||
./build/rogue
|
||||
```
|
||||
|
||||
```bash
|
||||
# Restore a saved game
|
||||
./rogue ~/rogue.save
|
||||
./build/rogue ~/rogue.save
|
||||
|
||||
# View high scores
|
||||
./rogue -s
|
||||
./build/rogue -s
|
||||
|
||||
# Test the death screen (demo mode)
|
||||
./rogue -d
|
||||
./build/rogue -d
|
||||
```
|
||||
|
||||
## In-game commands
|
||||
|
||||
Press `?` in game for the full list.
|
||||
|
||||
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run
|
||||
until adjacent)
|
||||
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run until
|
||||
adjacent)
|
||||
- **`.`** rest, **`s`** search for hidden doors and traps
|
||||
- **`i`** inventory, **`,`** pick up, **`d`** drop
|
||||
- **`q`** quaff potion, **`r`** read scroll, **`e`** eat food
|
||||
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on /
|
||||
remove ring
|
||||
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on / remove ring
|
||||
- **`t`** throw, **`z`** zap a wand, **`f`**/**`F`** fight
|
||||
- **`>`**/**`<`** take the stairs
|
||||
- **`S`** save, **`Q`** quit
|
||||
@@ -58,11 +57,11 @@ Press `?` in game for the full list.
|
||||
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
||||
|
||||
# Wizard (debug) mode, with a reproducible dungeon
|
||||
ROGUE_WIZARD=1 SEED=12345 ./rogue
|
||||
ROGUE_WIZARD=1 SEED=12345 ./build/rogue
|
||||
```
|
||||
|
||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob
|
||||
snapshots and, as in the original, are deleted when restored.
|
||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
||||
and, as in the original, are deleted when restored.
|
||||
|
||||
## Code layout
|
||||
|
||||
@@ -73,13 +72,23 @@ term/ tcell-backed terminal, replacing curses
|
||||
cmd/rogue/ the executable
|
||||
```
|
||||
|
||||
The engine package is fully headless-testable: `go test ./game/` runs
|
||||
scripted game sessions, dungeon-generation golden checks, and an RNG
|
||||
compatibility test against the original C generator.
|
||||
The engine package is fully headless-testable: `make test` runs scripted command
|
||||
sequences, dungeon-generation golden checks, and an RNG compatibility test
|
||||
against the original C generator.
|
||||
|
||||
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
||||
prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
|
||||
pinned container built from `Dockerfile.lint` — it is never installed on the
|
||||
host, so docker is required), `make test` (the suite, under the race detector
|
||||
with coverage and a timeout), `make check` (all three), `make build` (the
|
||||
executable), and `make cover` / `make cover-html` (per-function coverage, and
|
||||
the same profile as annotated source at `build/coverage.html`). Everything they
|
||||
generate lands in the git-ignored `build/`. Use the targets rather than the
|
||||
toolchain directly — they carry the flags the project relies on.
|
||||
|
||||
## License
|
||||
|
||||
BSD-style; see [LICENSE.TXT](LICENSE.TXT).
|
||||
|
||||
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn
|
||||
Wichman. All rights reserved.
|
||||
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman.
|
||||
All rights reserved.
|
||||
|
||||
+894
-140
Rozdílový obsah nebyl zobrazen, protože je příliš veliký
Načíst rozdílové porovnání
+228
-30
@@ -10,6 +10,7 @@ import (
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -21,22 +22,33 @@ func main() {
|
||||
os.Exit(run())
|
||||
}
|
||||
|
||||
// run carries the real main so that deferred terminal restoration runs
|
||||
// before the process exits (os.Exit skips defers).
|
||||
// run does the real work and returns an exit code. It only returns on a
|
||||
// startup error; once the game starts, it ends by exiting the process
|
||||
// from within (game.myExit restores the terminal first). The deferred
|
||||
// Fini covers the early-return paths.
|
||||
func run() int {
|
||||
scores := flag.Bool("s", false, "print the scoreboard and exit")
|
||||
deathDemo := flag.Bool("d", false, "die a random death (demo)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
cfg := loadConfig()
|
||||
params := loadParams()
|
||||
|
||||
if *scores {
|
||||
game.NewGame(cfg).ShowScores()
|
||||
game.New(params).ShowScores()
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// C printed its greeting just before initscr(); here that means just
|
||||
// before the tcell screen takes the terminal, and on stdout, exactly
|
||||
// as C did (main.c main). C followed the printf with fflush because
|
||||
// its stdout was buffered; os.Stdout is not, so the write is the
|
||||
// flush.
|
||||
if digsNewDungeon(*deathDemo, flag.Args()) {
|
||||
_, _ = fmt.Fprint(os.Stdout, game.Greeting(params)) // CLI output
|
||||
}
|
||||
|
||||
t, err := term.New()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -45,46 +57,66 @@ func run() int {
|
||||
}
|
||||
defer t.Fini()
|
||||
|
||||
cfg.Term = t
|
||||
// Armed here, the instant the tty goes raw, not after the game is
|
||||
// built: everything below this line — the restore, the death demo
|
||||
// (which never returns), the game itself — would otherwise run raw
|
||||
// with no handler installed. There is no game to save yet, so the
|
||||
// saver is filled in below once there is one.
|
||||
pending := installSignalHandlers(t)
|
||||
|
||||
params.Term = t
|
||||
|
||||
var g *game.RogueGame
|
||||
|
||||
if args := flag.Args(); len(args) == 1 && !*deathDemo {
|
||||
// restore a saved game
|
||||
g, err = game.Restore(args[0], cfg)
|
||||
g, err = game.Restore(args[0], params)
|
||||
if err != nil {
|
||||
t.Fini()
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
|
||||
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
g = game.NewGame(cfg)
|
||||
g = game.New(params)
|
||||
}
|
||||
|
||||
if *deathDemo {
|
||||
g.DeathDemo()
|
||||
// The demo is left without a saver on purpose: a signal still
|
||||
// restores the terminal, but a throwaway demo game is not worth
|
||||
// writing over the player's save file.
|
||||
g.DeathDemo() // does not return: death exits the process
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
installAutosave(g, t)
|
||||
pending.set(g)
|
||||
|
||||
runErr := g.Run()
|
||||
if runErr != nil {
|
||||
t.Fini()
|
||||
fmt.Fprintln(os.Stderr, runErr)
|
||||
|
||||
return 1
|
||||
}
|
||||
g.Run() // does not return: the game ends by exiting the process
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// loadConfig gathers the game configuration from the environment: home
|
||||
// digsNewDungeon reports whether this invocation is the one that digs a
|
||||
// fresh dungeon, and so the only one that greets.
|
||||
//
|
||||
// C's printf is the last statement before initscr(), and everything that
|
||||
// does something else has already left by then: -s scores and exits, -d
|
||||
// runs the death demo and exits, and restore() — the argc == 2 case that
|
||||
// is neither — never returns. So a saved game resumes without a greeting,
|
||||
// which is right: nothing is being dug.
|
||||
//
|
||||
// The restore test is duplicated from run's own, deliberately. Keeping
|
||||
// them as one predicate would mean deciding the startup path before the
|
||||
// terminal exists and carrying it past the error returns, which is more
|
||||
// rearrangement of run than a greeting is worth.
|
||||
func digsNewDungeon(deathDemo bool, args []string) bool {
|
||||
return !deathDemo && len(args) != 1
|
||||
}
|
||||
|
||||
// loadParams gathers the game parameters from the environment: home
|
||||
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
|
||||
// (main.c's startup).
|
||||
func loadConfig() game.Config {
|
||||
func loadParams() game.Params {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
name := ""
|
||||
@@ -96,7 +128,7 @@ func loadConfig() game.Config {
|
||||
|
||||
wizard := os.Getenv("ROGUE_WIZARD") != ""
|
||||
|
||||
return game.Config{
|
||||
return game.Params{
|
||||
Seed: chooseSeed(wizard),
|
||||
Name: name,
|
||||
RogueOpts: os.Getenv("ROGUEOPTS"),
|
||||
@@ -106,19 +138,184 @@ func loadConfig() game.Config {
|
||||
}
|
||||
}
|
||||
|
||||
// installAutosave saves the game and exits on SIGHUP/SIGTERM (save.c
|
||||
// auto_save).
|
||||
func installAutosave(g *game.RogueGame, t *term.Tcell) {
|
||||
// saver is the autosave half of *game.RogueGame that the signal handler
|
||||
// needs; an interface so the handler is testable headlessly.
|
||||
type saver interface {
|
||||
// AutoSaveOnSignal asks the game goroutine to write the save file and
|
||||
// waits up to timeout for it, reporting whether the save ran (save.c
|
||||
// auto_save). The handler never encodes anything itself; see
|
||||
// signalSaveTimeout.
|
||||
AutoSaveOnSignal(timeout time.Duration) bool
|
||||
}
|
||||
|
||||
// signalSaveTimeout bounds how long the signal handler waits for the game
|
||||
// goroutine to take its autosave.
|
||||
//
|
||||
// The handler cannot encode the game itself — that was issue #24's data
|
||||
// race — so it has to hand the work to the goroutine that owns the state
|
||||
// and wait. The game answers between turns, while parked waiting for a
|
||||
// key, and while parked in the shell escape, which covers everywhere it
|
||||
// can sit for any length of time; the deadline is the backstop for a game
|
||||
// goroutine wedged somewhere with no service point, so that a signal can
|
||||
// never fail to get the process out. It is generous next to the
|
||||
// milliseconds a gob encode of one game takes, and invisible to a player
|
||||
// whose connection has already dropped.
|
||||
//
|
||||
// Giving up costs nothing now that saveFile renames over the target
|
||||
// (game/save.go): a save that does not happen leaves the previous save
|
||||
// whole, where the old remove-then-encode could leave the player with
|
||||
// neither.
|
||||
const signalSaveTimeout = 3 * time.Second
|
||||
|
||||
// finisher is the terminal-restoring half of game.Terminal that the
|
||||
// signal handler needs (curses endwin).
|
||||
type finisher interface {
|
||||
// Fini restores the terminal to its pre-game state.
|
||||
Fini()
|
||||
}
|
||||
|
||||
// pendingSaver is the saver the signal handler holds from the moment the
|
||||
// terminal goes raw. The handler has to be armed before there is a game
|
||||
// to save — restoring a save file and the death demo both run with the
|
||||
// tty already raw — so AutoSaveOnSignal does nothing until set hands over
|
||||
// the real game. The mutex is not decoration: set runs on the main
|
||||
// goroutine and AutoSaveOnSignal on the signal goroutine.
|
||||
type pendingSaver struct {
|
||||
mu sync.Mutex
|
||||
game saver
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal saves the game if there is one yet, and otherwise does
|
||||
// nothing: a signal arriving before the game is built still restores the
|
||||
// terminal, which is the part that matters.
|
||||
//
|
||||
// The lock is held only long enough to read the game, not across the
|
||||
// delegated save. That changed with issue #24: the real
|
||||
// AutoSaveOnSignal now blocks until the game goroutine takes the save or
|
||||
// the deadline expires, and holding the mutex across a wait that long
|
||||
// would stall a concurrent set — the case the PR #23 review flagged as
|
||||
// safe only for as long as set is called exactly once. This shape does
|
||||
// not depend on that.
|
||||
func (p *pendingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
|
||||
p.mu.Lock()
|
||||
g := p.game
|
||||
p.mu.Unlock()
|
||||
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return g.AutoSaveOnSignal(timeout)
|
||||
}
|
||||
|
||||
// set hands the signal handler the game to autosave, once one exists.
|
||||
func (p *pendingSaver) set(g saver) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.game = g
|
||||
}
|
||||
|
||||
// handledSignals returns the signals the game leaves on. They split into
|
||||
// two groups with deliberately different save behavior; see
|
||||
// savesOnSignal.
|
||||
func handledSignals() []os.Signal {
|
||||
return []os.Signal{
|
||||
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
|
||||
}
|
||||
}
|
||||
|
||||
// savesOnSignal reports whether the game should autosave on its way out
|
||||
// for this signal.
|
||||
//
|
||||
// THE DECISION (issue #12): SIGHUP and SIGTERM save; SIGINT and SIGQUIT
|
||||
// restore the terminal and exit WITHOUT saving. This is deliberate, not
|
||||
// an oversight, on three grounds.
|
||||
//
|
||||
// C: no path in the C game saves on INT or QUIT. The shipped build
|
||||
// installs no handler at all during play (mach_dep.c setup calls
|
||||
// md_onsignal_default), and the only INT handler it ever installs is
|
||||
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
|
||||
// discarding pending output. The build that does wire INT during play
|
||||
// (md_onsignal_autosave, mdport.c — defined unconditionally, but with
|
||||
// its only call site, mach_dep.c setup, inside #ifdef DUMP) sends it to
|
||||
// quit(), which confirms, scores, and exits, again without saving, and
|
||||
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
|
||||
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
|
||||
// therefore exactly C's split.
|
||||
//
|
||||
// Semantics: HUP and TERM mean involuntary teardown — the line dropped
|
||||
// or the machine is going down — so rescuing the game is right. INT and
|
||||
// QUIT are the player deliberately saying "stop now". Rogue scores a
|
||||
// deliberate quit, and its save discipline is anti-save-scum by design
|
||||
// (restoring consumes the file), so making Ctrl-C a free checkpoint
|
||||
// would turn it into a one-keystroke undo for a bad turn: a gameplay
|
||||
// change, not a robustness fix.
|
||||
//
|
||||
// Safety: this used to be the third ground, back when the handler
|
||||
// gob-encoded live game state from its own goroutine after removing the
|
||||
// save file — a data race with a window in which the player had no save
|
||||
// at all, accepted on HUP/TERM because the process was dying anyway and
|
||||
// avoided entirely on INT/QUIT. Issue #24 removed the window instead of
|
||||
// living with it: the handler now hands the save to the game goroutine
|
||||
// and waits (AutoSaveOnSignal), and the write goes to a temporary file
|
||||
// renamed over the target. The split above stands on C and on semantics,
|
||||
// which is where it always belonged; INT and QUIT do not save because
|
||||
// the player asked to stop, not because saving is dangerous.
|
||||
func savesOnSignal(sig os.Signal) bool {
|
||||
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
|
||||
}
|
||||
|
||||
// installSignalHandlers arranges for the game to leave the terminal
|
||||
// usable when it is signalled: C's leave(), "leave quickly but
|
||||
// curteously" (main.c), extended with save.c auto_save on the two
|
||||
// signals that warrant it.
|
||||
//
|
||||
// Call it the instant the terminal goes raw, which is earlier than the
|
||||
// game exists; the returned pendingSaver takes the game once it does.
|
||||
// Restoring the terminal is what has to be armed the moment the tty
|
||||
// stops being usable, and it does not need a game.
|
||||
func installSignalHandlers(t finisher) *pendingSaver {
|
||||
pending := &pendingSaver{}
|
||||
|
||||
go leaveOnSignal(notifySignals(), pending, t, os.Exit)
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
// notifySignals subscribes to the handled signals and returns the
|
||||
// channel they arrive on. Split out from installSignalHandlers so tests
|
||||
// can drive leaveOnSignal with real signal delivery.
|
||||
func notifySignals() chan os.Signal {
|
||||
// Buffered so signal delivery never blocks, and deliberately never
|
||||
// drained past the first signal: see leaveOnSignal.
|
||||
sig := make(chan os.Signal, 1)
|
||||
|
||||
signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM)
|
||||
signal.Notify(sig, handledSignals()...)
|
||||
|
||||
return sig
|
||||
}
|
||||
|
||||
// leaveOnSignal waits for one signal and takes the game out.
|
||||
//
|
||||
// Exactly one goroutine reads exactly one signal, which is what makes
|
||||
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's save
|
||||
// is still being written, say) stays in the buffer unread and can never
|
||||
// call exit out from under an in-flight save. The order within is the
|
||||
// same one myExit uses (game/rip.go): save if this signal saves, then
|
||||
// restore the terminal, then exit.
|
||||
//
|
||||
// AutoSaveOnSignal returns once the game goroutine has finished writing,
|
||||
// or once signalSaveTimeout has run out, so the save is complete before
|
||||
// the terminal is torn down and the process leaves — and the process
|
||||
// leaves either way.
|
||||
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
|
||||
if savesOnSignal(<-sig) {
|
||||
g.AutoSaveOnSignal(signalSaveTimeout)
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-sig
|
||||
g.AutoSave()
|
||||
t.Fini()
|
||||
os.Exit(0)
|
||||
}()
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// chooseSeed picks the dungeon number: SEED for reproducible dungeons
|
||||
@@ -133,6 +330,7 @@ func chooseSeed(wizard bool) int32 {
|
||||
|
||||
// The C game computed `lowtime + getpid()` in int; the truncation to
|
||||
// 32 bits is the same wraparound the C int arithmetic performed.
|
||||
//nolint:mnd // C-faithful: the C int wraparound mask
|
||||
return int32(time.Now().Unix()&0x7fffffff) +
|
||||
int32(os.Getpid()&0x7fffffff)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package main
|
||||
|
||||
// White-box tests for the signal plumbing. Unlike the game package's test
|
||||
// files this one carries no //nolint:testpackage directive: testpackage
|
||||
// exempts package main, so nolintlint rejects the directive as unused.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The steps the signal handler can take, in the order signalRecorder
|
||||
// records them.
|
||||
const (
|
||||
stepSave = "save"
|
||||
stepFini = "fini"
|
||||
stepExit = "exit"
|
||||
)
|
||||
|
||||
// wantHandledSignals is the exact set of signals the game must leave on.
|
||||
// This is the subject of issue #12: SIGHUP and SIGTERM were handled and
|
||||
// SIGINT and SIGQUIT were not, so the latter two killed the process with
|
||||
// the tty still raw. Every other test here iterates handledSignals(), so
|
||||
// without this one the whole file would pass against a set that had
|
||||
// silently lost SIGINT and SIGQUIT again.
|
||||
func wantHandledSignals() []os.Signal {
|
||||
return []os.Signal{
|
||||
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
|
||||
}
|
||||
}
|
||||
|
||||
// wantSteps is the expected handler step sequence for each handled
|
||||
// signal, and the single source of truth for the tests that check the
|
||||
// save/no-save split.
|
||||
func wantSteps() map[os.Signal][]string {
|
||||
return map[os.Signal][]string{
|
||||
syscall.SIGHUP: {stepSave, stepFini, stepExit},
|
||||
syscall.SIGTERM: {stepSave, stepFini, stepExit},
|
||||
syscall.SIGINT: {stepFini, stepExit},
|
||||
syscall.SIGQUIT: {stepFini, stepExit},
|
||||
}
|
||||
}
|
||||
|
||||
// signalRecorder stands in for the game and the terminal in the signal
|
||||
// handler, recording the order of the steps the handler takes. The mutex
|
||||
// matters: the handler runs on its own goroutine, so an unguarded slice
|
||||
// would be a data race under -race, which is exactly what these tests
|
||||
// are meant to rule out.
|
||||
type signalRecorder struct {
|
||||
mu sync.Mutex
|
||||
steps []string
|
||||
code int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSignalRecorder() *signalRecorder {
|
||||
return &signalRecorder{done: make(chan struct{})}
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal records a save attempt (the saver half). The real one
|
||||
// hands the work to the game goroutine and waits; the recorder stands in
|
||||
// for a game that takes it immediately.
|
||||
func (r *signalRecorder) AutoSaveOnSignal(time.Duration) bool {
|
||||
r.record(stepSave)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Fini records a terminal restore (the finisher half).
|
||||
func (r *signalRecorder) Fini() {
|
||||
r.record(stepFini)
|
||||
}
|
||||
|
||||
// exit records the process exit that ends the handler and releases any
|
||||
// waiter. It stands in for os.Exit, which cannot be called in a test.
|
||||
func (r *signalRecorder) exit(code int) {
|
||||
r.mu.Lock()
|
||||
r.code = code
|
||||
r.steps = append(r.steps, stepExit)
|
||||
r.mu.Unlock()
|
||||
|
||||
close(r.done)
|
||||
}
|
||||
|
||||
// record appends one step.
|
||||
func (r *signalRecorder) record(step string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.steps = append(r.steps, step)
|
||||
}
|
||||
|
||||
// taken returns the recorded steps and the exit code.
|
||||
func (r *signalRecorder) taken() ([]string, int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
return slices.Clone(r.steps), r.code
|
||||
}
|
||||
|
||||
// TestHandledSignalsSet pins the membership of handledSignals() itself.
|
||||
// The regression issue #12 exists to prevent is a signal dropping out of
|
||||
// that set — SIGINT and SIGQUIT reaching the process at SIG_DFL and
|
||||
// killing it with the tty raw — and every other test in this file is
|
||||
// driven by the set, so only this test can fail on it.
|
||||
func TestHandledSignalsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := handledSignals()
|
||||
want := wantHandledSignals()
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("handledSignals() = %v, want exactly %v", got, want)
|
||||
}
|
||||
|
||||
for _, sig := range want {
|
||||
if !slices.Contains(got, sig) {
|
||||
t.Errorf("handledSignals() = %v, missing %v", got, sig)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sig := range got {
|
||||
if !slices.Contains(want, sig) {
|
||||
t.Errorf("handledSignals() = %v, unexpected %v", got, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveOnSignalRestoresTerminalBeforeExit is the core of issue #12:
|
||||
// whatever the signal, the terminal is restored before the process ends,
|
||||
// so the player is never dropped into a shell with the tty still in raw
|
||||
// mode.
|
||||
func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, sig := range handledSignals() {
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
ch <- sig
|
||||
|
||||
leaveOnSignal(ch, rec, rec, rec.exit)
|
||||
|
||||
steps, code := rec.taken()
|
||||
|
||||
fini := slices.Index(steps, stepFini)
|
||||
exit := slices.Index(steps, stepExit)
|
||||
|
||||
if fini < 0 || exit < 0 || fini > exit {
|
||||
t.Errorf("%v: want the terminal restored before exit, got %v",
|
||||
sig, steps)
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
t.Errorf("%v: exit code = %d, want 0", sig, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveOnSignalSaveSplit pins the decision recorded on savesOnSignal:
|
||||
// SIGHUP/SIGTERM (involuntary teardown) save on the way out, SIGINT and
|
||||
// SIGQUIT (a deliberate "stop now" from the player) do not, matching C,
|
||||
// where auto_save is reserved for HUP/TERM and neither leave() nor quit()
|
||||
// nor endit() writes a save file.
|
||||
//
|
||||
// It is driven by the expectation table rather than by
|
||||
// handledSignals(), so that every entry — including the SIGINT and
|
||||
// SIGQUIT ones — is actually read, and a signal dropped from the handled
|
||||
// set fails here as well as in TestHandledSignalsSet.
|
||||
func TestLeaveOnSignalSaveSplit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for sig, want := range wantSteps() {
|
||||
if !slices.Contains(handledSignals(), sig) {
|
||||
t.Errorf("%v is not handled at all, so it cannot exit cleanly", sig)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
ch <- sig
|
||||
|
||||
leaveOnSignal(ch, rec, rec, rec.exit)
|
||||
|
||||
steps, _ := rec.taken()
|
||||
if !slices.Equal(steps, want) {
|
||||
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
||||
}
|
||||
|
||||
if saved := slices.Contains(steps, stepSave); saved != savesOnSignal(sig) {
|
||||
t.Errorf("%v: saved = %v, savesOnSignal = %v",
|
||||
sig, saved, savesOnSignal(sig))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveOnSignalIgnoresLaterSignals covers the ordering guarantee in
|
||||
// leaveOnSignal's comment: only the first signal is read, so a second one
|
||||
// arriving mid-save cannot exit out from under the save and truncate the
|
||||
// player's file. The saver here blocks until a second signal has been
|
||||
// queued, reproducing that window.
|
||||
func TestLeaveOnSignalIgnoresLaterSignals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 2)
|
||||
ch <- syscall.SIGHUP
|
||||
|
||||
blocker := &blockingSaver{rec: rec, queue: ch, extra: syscall.SIGINT}
|
||||
|
||||
leaveOnSignal(ch, blocker, rec, rec.exit)
|
||||
|
||||
steps, _ := rec.taken()
|
||||
if !slices.Equal(steps, []string{stepSave, stepFini, stepExit}) {
|
||||
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
|
||||
}
|
||||
|
||||
if len(ch) != 1 {
|
||||
t.Errorf("queued signals left unread = %d, want 1", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
// blockingSaver queues another signal while the save is in flight, the
|
||||
// race window leaveOnSignal is built to close.
|
||||
type blockingSaver struct {
|
||||
rec *signalRecorder
|
||||
queue chan os.Signal
|
||||
extra os.Signal
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal delivers the extra signal mid-save, then records the
|
||||
// save.
|
||||
func (b *blockingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
|
||||
b.queue <- b.extra
|
||||
|
||||
return b.rec.AutoSaveOnSignal(timeout)
|
||||
}
|
||||
|
||||
// TestLeaveOnRealSignal is the deepest headless check available: it
|
||||
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
|
||||
// os/signal, exactly as notifySignals wires them in the game, and
|
||||
// verifies each one reaches the handler and produces the full expected
|
||||
// step sequence — including the save/no-save split, which this test is
|
||||
// the best placed to check end to end.
|
||||
//
|
||||
// What cannot be checked here is the tty itself coming back out of raw
|
||||
// mode: that needs a controlling terminal and a live tcell screen, which
|
||||
// a headless test run does not have. This test covers everything up to
|
||||
// the Terminal.Fini call; term.Tcell.Fini is a direct pass-through to
|
||||
// tcell's Screen.Fini, which is the same call myExit already relies on.
|
||||
func TestLeaveOnRealSignal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := notifySignals()
|
||||
defer signal.Stop(ch)
|
||||
|
||||
for _, sig := range handledSignals() {
|
||||
rec := newSignalRecorder()
|
||||
|
||||
go leaveOnSignal(ch, rec, rec, rec.exit)
|
||||
|
||||
unix, ok := sig.(syscall.Signal)
|
||||
if !ok {
|
||||
t.Fatalf("%v is not a unix signal", sig)
|
||||
}
|
||||
|
||||
err := syscall.Kill(os.Getpid(), unix)
|
||||
if err != nil {
|
||||
t.Fatalf("kill(%v): %v", sig, err)
|
||||
}
|
||||
|
||||
<-rec.done
|
||||
|
||||
steps, code := rec.taken()
|
||||
if want := wantSteps()[sig]; !slices.Equal(steps, want) {
|
||||
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
t.Errorf("%v: exit code = %d, want 0", sig, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingSaverArmsBeforeTheGameExists covers what lets the handlers
|
||||
// be installed the instant the terminal goes raw rather than after the
|
||||
// game is built: a signal arriving before there is a game must still
|
||||
// reach Fini, and must not save anything, while one arriving after the
|
||||
// game is handed over saves it.
|
||||
func TestPendingSaverArmsBeforeTheGameExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pending := &pendingSaver{}
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
ch <- syscall.SIGHUP
|
||||
|
||||
// No game yet: the SIGHUP still restores the terminal and exits, it
|
||||
// just has nothing to write.
|
||||
leaveOnSignal(ch, pending, rec, rec.exit)
|
||||
|
||||
steps, code := rec.taken()
|
||||
if want := []string{stepFini, stepExit}; !slices.Equal(steps, want) {
|
||||
t.Errorf("before the game exists: steps = %v, want %v", steps, want)
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
t.Errorf("before the game exists: exit code = %d, want 0", code)
|
||||
}
|
||||
|
||||
// Once the game is handed over, the same saver writes it.
|
||||
started := newSignalRecorder()
|
||||
pending.set(started)
|
||||
|
||||
if !pending.AutoSaveOnSignal(signalSaveTimeout) {
|
||||
t.Error("after set: the save was not reported as taken")
|
||||
}
|
||||
|
||||
saved, _ := started.taken()
|
||||
if want := []string{stepSave}; !slices.Equal(saved, want) {
|
||||
t.Errorf("after set: steps = %v, want %v", saved, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingSaverDoesNotHoldItsLockAcrossTheSave pins the reason
|
||||
// pendingSaver reads the game out from under the mutex instead of
|
||||
// delegating with it held: since issue #24 the delegated save blocks
|
||||
// until the game goroutine takes it or the deadline expires, so a mutex
|
||||
// held across it would stall whoever calls set. Nothing calls set twice
|
||||
// today, which is why the PR #23 review recorded this as a future-proof
|
||||
// note rather than a bug — this test is what stops it becoming one.
|
||||
func TestPendingSaverDoesNotHoldItsLockAcrossTheSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pending := &pendingSaver{}
|
||||
stuck := &stuckSaver{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
pending.set(stuck)
|
||||
|
||||
go pending.AutoSaveOnSignal(signalSaveTimeout)
|
||||
|
||||
<-stuck.entered // the delegated save is in flight
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
pending.set(newSignalRecorder()) // must not block on the save
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("set blocked while a save was in flight: the lock is held across it")
|
||||
}
|
||||
|
||||
close(stuck.release)
|
||||
}
|
||||
|
||||
// stuckSaver blocks inside the delegated save until it is released,
|
||||
// standing in for a game goroutine that is slow to answer.
|
||||
type stuckSaver struct {
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal blocks until the test releases it.
|
||||
func (s *stuckSaver) AutoSaveOnSignal(time.Duration) bool {
|
||||
close(s.entered)
|
||||
<-s.release
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TestDigsNewDungeon pins which invocations reach C's greeting. In main.c
|
||||
// the printf is the last statement before initscr(), so -s and -d, which
|
||||
// exit earlier, never see it, and neither does a restored game, because
|
||||
// restore() does not return. The saved-game case is the one worth having
|
||||
// a test for: resuming a dungeon must not announce that one is being dug.
|
||||
func TestDigsNewDungeon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deathDemo bool
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "new game", args: nil, want: true},
|
||||
{name: "restore a save", args: []string{"rogue.save"}, want: false},
|
||||
{name: "death demo", deathDemo: true, want: false},
|
||||
{
|
||||
name: "death demo wins over a save argument",
|
||||
deathDemo: true,
|
||||
args: []string{"rogue.save"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := digsNewDungeon(tc.deathDemo, tc.args); got != tc.want {
|
||||
t.Errorf("digsNewDungeon(%v, %v) = %v, want %v",
|
||||
tc.deathDemo, tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// Tests for the signal-triggered autosave handoff (issue #24): the signal
|
||||
// goroutine must never encode game state itself, and the game goroutine
|
||||
// must answer wherever it is parked.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
|
||||
// expect the save to be taken. It is long enough that a loaded machine
|
||||
// cannot turn a working handoff into a spurious failure, and it is never
|
||||
// actually waited out on a passing run. It is also what bounds
|
||||
// driveUntilDone, by way of the saving goroutine it waits for — see
|
||||
// there for what that bound comes to.
|
||||
const autoSaveWait = 10 * time.Second
|
||||
|
||||
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
|
||||
// drives the real turn loop on one goroutine while another asks for a
|
||||
// signal-triggered autosave over and over, which is the interleaving no
|
||||
// test in the suite used to produce. `make test` runs with -race, so a
|
||||
// save that encodes the live game tree from the asking goroutine — what
|
||||
// the old AutoSave did straight from the signal handler — is reported as
|
||||
// a data race and fails this test.
|
||||
//
|
||||
// Non-vacuity: with AutoSaveOnSignal's body replaced by a direct
|
||||
// g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails
|
||||
// under -race with the encoder reading state that command() is writing.
|
||||
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Same mix as TestTurnLoopCrashSweep — the spaces answer any --More--
|
||||
// prompt — on a driveTerm, so the drive can run for as long as the
|
||||
// saves take rather than for as long as a script lasts. The '.' and
|
||||
// the 's' are what make an unbounded drive safe, and at least one of
|
||||
// the two has to stay in the cycle: see driveTerm.
|
||||
term := &driveTerm{script: []byte("h j k l y u b n s . ")}
|
||||
|
||||
g := New(Params{Seed: 20260809, Term: term})
|
||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
const wantSaves = 25
|
||||
|
||||
var taken int
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
for range wantSaves {
|
||||
if g.AutoSaveOnSignal(autoSaveWait) {
|
||||
taken++
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
driveUntilDone(t, g, done)
|
||||
|
||||
// The close of done orders that goroutine's writes before this read.
|
||||
if taken != wantSaves {
|
||||
t.Errorf("saves taken = %d, want %d", taken, wantSaves)
|
||||
}
|
||||
|
||||
// Every request was answered by the turn loop, so the file is the
|
||||
// work of the game goroutine and must be a whole save.
|
||||
assertRestorable(t, g.FileName)
|
||||
}
|
||||
|
||||
// driveUntilDone runs turns until the saving goroutine is finished,
|
||||
// fortifying the hero each turn so no death exits the test binary. The
|
||||
// condition it waits on is that goroutine finishing — nothing else.
|
||||
//
|
||||
// It used to stop after a fixed 1000 turns and fail, and that cap was a
|
||||
// load-sensitive assumption wearing a counter's clothes (issue #36). The
|
||||
// turns this loop spends between one save request being answered and the
|
||||
// next arriving are not work; they are the saving goroutine's scheduling
|
||||
// latency, so the turn count 25 saves costs is a function of how
|
||||
// contended the machine is rather than of anything the code under test
|
||||
// does. Measured here on a 48-core host at load ~57: about 60-120 turns
|
||||
// with a whole machine to spread over, 418 to 655 as GOMAXPROCS was cut
|
||||
// from 4 to 1, and past 1000 under the doubled load of the verbose
|
||||
// rerun, which is the flake this replaces. A budget that has to be
|
||||
// guessed cannot be guessed right, so there is no budget.
|
||||
//
|
||||
// Dropping it costs no termination guarantee, because the bound belongs
|
||||
// to the code under test and not to this loop: each AutoSaveOnSignal
|
||||
// call returns within the timeout the caller hands it, so the saving
|
||||
// goroutine always finishes and done always closes. That bound is worth
|
||||
// stating exactly, because it is not one autoSaveWait.
|
||||
//
|
||||
// A handoff that has stopped answering altogether costs one, in total,
|
||||
// however many saves were asked for. g.sigSave
|
||||
// is one deep, so the unserviced request stays in the channel and every
|
||||
// later call finds it full and reports failure immediately — measured
|
||||
// at 10.0s for 25 saves with the service point deleted from command().
|
||||
// What fails is then the caller's own assertion, the count of saves
|
||||
// actually taken, which says far more than "out of turns" ever did.
|
||||
//
|
||||
// A handoff that still drains every request but takes longer than
|
||||
// autoSaveWait to do it is the worst case, and costs one timeout per
|
||||
// save: wantSaves * autoSaveWait, 250s at these constants, which would
|
||||
// run past the package timeout rather than reach the assertion. It
|
||||
// takes about ten seconds of scheduler starvation per save to get
|
||||
// there, against a regime measured at 0.12s per 1000 turns, so it is
|
||||
// remote — and the 1000-turn cap did not bound it either, a turn count
|
||||
// being no kind of time bound. `go test -timeout 30s` is the backstop
|
||||
// under all of it.
|
||||
//
|
||||
// The one thing the caller does have to supply is a terminal that can
|
||||
// feed an unbounded drive: see driveTerm.
|
||||
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
fortify(g)
|
||||
g.command()
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
|
||||
// for: the connection drops while the player is staring at the screen,
|
||||
// so the game goroutine is parked in ReadChar and will not reach the
|
||||
// between-turns check on its own. A flag checked only between turns would
|
||||
// never be looked at here.
|
||||
func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bt := newBlockingTerm()
|
||||
g := mkBlockedGame(t, bt)
|
||||
|
||||
read := make(chan byte)
|
||||
|
||||
go func() { read <- g.readchar() }()
|
||||
|
||||
// The wake is buffered, so this is correct whether or not the reader
|
||||
// has reached ReadChar yet.
|
||||
if !g.AutoSaveOnSignal(autoSaveWait) {
|
||||
t.Fatal("the save was not taken while the game was blocked on input")
|
||||
}
|
||||
|
||||
assertRestorable(t, g.FileName)
|
||||
|
||||
// The interrupt must not have been mistaken for a keystroke: the
|
||||
// reader is still waiting, and still returns the real key.
|
||||
bt.keys <- 'x'
|
||||
|
||||
if ch := <-read; ch != 'x' {
|
||||
t.Errorf("readchar() = %q, want 'x'", ch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalWhileInShellEscape covers the other place the game
|
||||
// goroutine parks for an unbounded time: the `!` shell escape, where it
|
||||
// used to sit inside the shell call with no way to answer. A dropped line
|
||||
// while the player is off in a shell is as much a hangup as any other.
|
||||
func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := &shellTerm{
|
||||
blockingTerm: newBlockingTerm(),
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
g := mkBlockedGame(t, st)
|
||||
|
||||
left := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(left)
|
||||
|
||||
g.shell()
|
||||
}()
|
||||
|
||||
<-st.entered
|
||||
|
||||
if !g.AutoSaveOnSignal(autoSaveWait) {
|
||||
t.Fatal("the save was not taken while the game was in the shell escape")
|
||||
}
|
||||
|
||||
assertRestorable(t, g.FileName)
|
||||
|
||||
close(st.release)
|
||||
<-left
|
||||
}
|
||||
|
||||
// TestShellEscapePanicUnwindsTheGameGoroutine pins the reason
|
||||
// runShellEscape recovers its helper's panic.
|
||||
//
|
||||
// term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell
|
||||
// now runs on a helper goroutine. A panic reaching the top of that helper
|
||||
// would kill the process without running the deferred calls of any other
|
||||
// goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is
|
||||
// the only thing that takes the tty back out of raw mode. That is issue
|
||||
// #12's failure, and it would land on the one path where the terminal is
|
||||
// already broken.
|
||||
//
|
||||
// So the panic has to arrive on the goroutine that runs the game, with
|
||||
// that goroutine's deferred restore still on the stack. This test stands
|
||||
// in for main: a Fini deferred around the g.shell() call, and the panic
|
||||
// caught after it, asserting both that the restore ran and that the
|
||||
// original value came through. Against the unrecovered version there is
|
||||
// nothing to assert — the panic escapes a helper goroutine and takes the
|
||||
// whole test binary down, which is the failure being prevented.
|
||||
func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pt := &panickingShellTerm{blockingTerm: newBlockingTerm()}
|
||||
g := mkBlockedGame(t, pt)
|
||||
|
||||
caught := make(chan any, 1)
|
||||
|
||||
go func() {
|
||||
// Registered first, so it runs last: it sees the terminal
|
||||
// already restored, exactly as the runtime would have printed
|
||||
// the trace after main's Fini.
|
||||
defer func() { caught <- recover() }()
|
||||
|
||||
// Stands in for cmd/rogue/main.go's `defer t.Fini()`.
|
||||
defer pt.Fini()
|
||||
|
||||
g.shell()
|
||||
}()
|
||||
|
||||
got := <-caught
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("the resume failure did not reach the game goroutine")
|
||||
}
|
||||
|
||||
if msg, ok := got.(string); !ok || msg != errShellResume {
|
||||
t.Errorf("recovered %v, want %q", got, errShellResume)
|
||||
}
|
||||
|
||||
if !pt.restored {
|
||||
t.Error("the terminal was not restored on the way out")
|
||||
}
|
||||
|
||||
// shell() must not have resumed into its InShell reset and refresh:
|
||||
// there is no screen left to draw into.
|
||||
if !g.InShell {
|
||||
t.Error("shell() carried on drawing after the resume failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game
|
||||
// goroutine that never reaches a service point must not hold the process
|
||||
// open, and giving up must cost the player nothing. The old save is still
|
||||
// there, byte for byte — which is the whole point of renaming over the
|
||||
// target instead of removing it first.
|
||||
func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 77)
|
||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||
|
||||
const old = "an older save nobody is allowed to destroy"
|
||||
|
||||
writeErr := os.WriteFile(g.FileName, []byte(old), 0o600)
|
||||
if writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
|
||||
// Nothing drives the turn loop, so nothing will ever answer.
|
||||
start := time.Now()
|
||||
|
||||
if g.AutoSaveOnSignal(100 * time.Millisecond) {
|
||||
t.Error("AutoSaveOnSignal reported a save that nobody took")
|
||||
}
|
||||
|
||||
if waited := time.Since(start); waited > time.Second {
|
||||
t.Errorf("waited %v for an unanswered save, want the deadline to bound it",
|
||||
waited)
|
||||
}
|
||||
|
||||
got, readErr := os.ReadFile(g.FileName)
|
||||
if readErr != nil {
|
||||
t.Fatalf("the previous save was destroyed: %v", readErr)
|
||||
}
|
||||
|
||||
if string(got) != old {
|
||||
t.Error("the previous save was overwritten by a save that never ran")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal
|
||||
// case: a game with no file name has nothing to write, and must say so
|
||||
// rather than reporting a save that did not happen.
|
||||
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}})
|
||||
g.FileName = ""
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
var answered bool
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
answered = g.AutoSaveOnSignal(autoSaveWait)
|
||||
}()
|
||||
|
||||
driveUntilDone(t, g, done)
|
||||
|
||||
if answered {
|
||||
t.Error("AutoSaveOnSignal = true with no save file name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveFileReplacesTargetAtomically pins the write discipline: the new
|
||||
// save arrives by rename, so the file the player already had is never
|
||||
// written into, and the temporary file it came from is not left lying in
|
||||
// the save directory.
|
||||
//
|
||||
// The load-bearing assertion is the handle opened before the save. A
|
||||
// rename leaves the old file whole and merely stops it being reachable by
|
||||
// name, so that handle still reads the old save; the truncate-in-place
|
||||
// write this replaced would empty it under the reader — the same
|
||||
// in-place write that, interrupted, left the player with a file that
|
||||
// could no longer be restored.
|
||||
func TestSaveFileReplacesTargetAtomically(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 11)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "rogue.save")
|
||||
|
||||
const old = "an older save"
|
||||
|
||||
writeErr := os.WriteFile(path, []byte(old), 0o600)
|
||||
if writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
|
||||
held, openErr := os.Open(path) //nolint:gosec // G304: test temp path
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
|
||||
defer func() { _ = held.Close() }()
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr != nil {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
kept, readErr := io.ReadAll(held)
|
||||
if readErr != nil {
|
||||
t.Fatalf("reading the file that was there before the save: %v", readErr)
|
||||
}
|
||||
|
||||
if string(kept) != old {
|
||||
t.Errorf("the previous save was written into rather than replaced: %q",
|
||||
string(kept))
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
|
||||
if len(entries) != 1 || entries[0].Name() != "rogue.save" {
|
||||
t.Errorf("save directory = %v, want just the save file", names(entries))
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
t.Fatal(statErr)
|
||||
}
|
||||
|
||||
if perm := info.Mode().Perm(); perm != 0o400 {
|
||||
t.Errorf("save file mode = %v, want 0400", perm)
|
||||
}
|
||||
|
||||
assertRestorable(t, path)
|
||||
}
|
||||
|
||||
// TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the
|
||||
// same discipline: a save that cannot be completed must leave what the
|
||||
// player already had. The target here is a non-empty directory, which no
|
||||
// rename can replace — the one write failure that can be forced without
|
||||
// depending on file permissions, and therefore on not being root.
|
||||
func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 12)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "rogue.save")
|
||||
|
||||
mkErr := os.Mkdir(path, 0o700)
|
||||
if mkErr != nil {
|
||||
t.Fatal(mkErr)
|
||||
}
|
||||
|
||||
keep := filepath.Join(path, "keep")
|
||||
|
||||
writeErr := os.WriteFile(keep, []byte("still here"), 0o600)
|
||||
if writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr == nil {
|
||||
t.Error("saveFile over an unreplaceable target reported success")
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(keep)
|
||||
if statErr != nil {
|
||||
t.Errorf("the target was damaged by a failed save: %v", statErr)
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("save directory = %v, want no temporary file left behind",
|
||||
names(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// names lists directory entry names for a failure message.
|
||||
func names(entries []os.DirEntry) []string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// assertRestorable checks that path holds a save this program can load,
|
||||
// which is what "the save was taken" has to mean: a file of the right
|
||||
// size proves nothing about a torn encode.
|
||||
func assertRestorable(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := Restore(path, Params{Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Errorf("the saved file does not restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mkBlockedGame builds a game with a save file name and a terminal whose
|
||||
// reads block, for the tests that park the game goroutine.
|
||||
func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := New(Params{Seed: 4242, Term: term})
|
||||
g.NewLevel()
|
||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// driveTerm is a headless Terminal whose script repeats instead of
|
||||
// running out, for the tests that drive the turn loop until something
|
||||
// else finishes rather than for a set number of turns.
|
||||
//
|
||||
// testTerm cannot do that job. Once its script is exhausted it answers
|
||||
// space and newline for ever, and neither takes a turn, so command() —
|
||||
// which loops until the player does something that consumes one, the
|
||||
// `if !g.After { ntimes++ }` in command.c — never returns. A drive with
|
||||
// a turn cap sized to its script never notices; a drive that runs until
|
||||
// the saves are taken wedges inside a single command() call, which is
|
||||
// what a first attempt at issue #36 did.
|
||||
//
|
||||
// Repeating the script is necessary but nowhere near sufficient, and
|
||||
// the difference is what anyone editing one of these scripts has to
|
||||
// know. Most keys take a turn only conditionally. ' ' is the "legal
|
||||
// illegal command" and clears After outright (tables.go). All eight
|
||||
// movement keys clear it whenever the step is refused: a wall or the
|
||||
// map edge (move.go moveResolve), an illegal diagonal (moveTarget), or
|
||||
// a confused step that lands back in place (moveHero). A script of
|
||||
// nothing but those keys wedges exactly the way testTerm's tail does,
|
||||
// repetition or no repetition — with the script set to just " " this
|
||||
// drive hits the 30s package timeout inside command().
|
||||
//
|
||||
// What actually makes the wedge impossible is that the cycle always
|
||||
// contains at least one *unconditional* turn-taker, and the scripts
|
||||
// here carry two: '.', the rest command, whose handler is empty, and
|
||||
// 's', search, which writes After on no path. Nothing refuses either
|
||||
// one — not being blocked in all eight directions, not Held, not stuck
|
||||
// in a bear trap, and not NoCommand > 0, where playTurn skips
|
||||
// executeCommand altogether and After is simply left true. Trim both
|
||||
// out and the wedge this test exists to remove comes straight back.
|
||||
//
|
||||
// One further precondition, from what this fake does not supply:
|
||||
// testTerm's tail answered a newline every other read and this does
|
||||
// not. Nothing reachable from these scripts asks for one — waitFor('\n')
|
||||
// sits on the death and score paths (rip.go, score.go), which fortify
|
||||
// prevents from ever being reached — but a script that could reach them
|
||||
// would park in waitFor for ever.
|
||||
type driveTerm struct {
|
||||
script []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (t *driveTerm) Render(*Window) {}
|
||||
|
||||
func (t *driveTerm) Repaint() {}
|
||||
|
||||
func (t *driveTerm) Fini() {}
|
||||
|
||||
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
|
||||
func (t *driveTerm) Interrupt() {}
|
||||
|
||||
// ReadChar hands out the next scripted key, wrapping at the end.
|
||||
func (t *driveTerm) ReadChar() (byte, bool) {
|
||||
ch := t.script[t.pos]
|
||||
t.pos = (t.pos + 1) % len(t.script)
|
||||
|
||||
return ch, true
|
||||
}
|
||||
|
||||
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
|
||||
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
|
||||
// block, cannot reproduce.
|
||||
type blockingTerm struct {
|
||||
keys chan byte
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingTerm() *blockingTerm {
|
||||
return &blockingTerm{
|
||||
keys: make(chan byte),
|
||||
// Buffered by one and posted to without blocking, the same
|
||||
// contract term.Tcell.Interrupt has with tcell's event queue: an
|
||||
// interrupt that arrives before the read still wakes it.
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *blockingTerm) Render(*Window) {}
|
||||
|
||||
// Repaint has nothing to redraw: this terminal exists for its input
|
||||
// behaviour, and no autosave test types CTRL-R.
|
||||
func (t *blockingTerm) Repaint() {}
|
||||
|
||||
func (t *blockingTerm) Fini() {}
|
||||
|
||||
// Interrupt wakes a blocked ReadChar; called from the saving goroutine.
|
||||
func (t *blockingTerm) Interrupt() {
|
||||
select {
|
||||
case t.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ReadChar blocks until a key arrives or Interrupt wakes it.
|
||||
func (t *blockingTerm) ReadChar() (byte, bool) {
|
||||
select {
|
||||
case ch := <-t.keys:
|
||||
return ch, true
|
||||
case <-t.wake:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// shellTerm is a blockingTerm that also offers a shell escape which stays
|
||||
// in the shell until the test lets it out.
|
||||
type shellTerm struct {
|
||||
*blockingTerm
|
||||
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
// ShellEscape parks the caller in the "shell" until released.
|
||||
func (t *shellTerm) ShellEscape() {
|
||||
close(t.entered)
|
||||
<-t.release
|
||||
}
|
||||
|
||||
// errShellResume is what panickingShellTerm panics with, standing in for
|
||||
// the value term.Tcell.ShellEscape raises when Screen.Resume fails.
|
||||
const errShellResume = "resume failed"
|
||||
|
||||
// panickingShellTerm is a blockingTerm whose shell escape panics on the
|
||||
// way out, the way term.Tcell.ShellEscape does when the screen cannot be
|
||||
// resumed. It records whether Fini ran, which is the thing that must
|
||||
// still happen.
|
||||
type panickingShellTerm struct {
|
||||
*blockingTerm
|
||||
|
||||
restored bool
|
||||
}
|
||||
|
||||
func (t *panickingShellTerm) Fini() { t.restored = true }
|
||||
|
||||
func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }
|
||||
@@ -0,0 +1,497 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bolt geometry of sticks.c fire_bolt, tested on the hand-carved
|
||||
// level built by mkCarvedGame in sticks_test.go.
|
||||
//
|
||||
// Most of these fire from a square that is not the hero's, which is what
|
||||
// chase.c does when a dragon breathes (fire_bolt(&th->t_pos, ...)). That
|
||||
// keeps the hero off the ray, so the run produces exactly one message
|
||||
// and the screen stays readable as a record of where the bolt went — see
|
||||
// litCells.
|
||||
|
||||
// The three names sticks.c fires a bolt under (do_zap's WS_ELECT,
|
||||
// WS_FIRE and WS_COLD arms); fire_bolt prints them and hangs them on the
|
||||
// FLAME weapon-table entry.
|
||||
const (
|
||||
boltName = "bolt"
|
||||
flameName = "flame"
|
||||
iceName = "ice"
|
||||
)
|
||||
|
||||
// litCells reports every non-blank cell of the map area of the screen.
|
||||
//
|
||||
// fire_bolt paints its trail with dirch and then erases it by writing
|
||||
// back chat() for each square it recorded, so on a screen nothing else
|
||||
// has drawn on, the squares left non-blank are exactly the ones the bolt
|
||||
// occupied. Squares it bounced off are absent by construction: C undoes
|
||||
// the record with c1-- and breaks before the mvaddch, so a wall is
|
||||
// neither painted nor erased.
|
||||
func litCells(g *RogueGame) []Coord {
|
||||
var out []Coord
|
||||
// Row 0 is the message line, not the map.
|
||||
for y := 1; y < NumLines; y++ {
|
||||
line := g.scr.Std.Line(y)
|
||||
for x := range len(line) {
|
||||
if line[x] != ' ' {
|
||||
out = append(out, Coord{X: x, Y: y})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// assertErased checks that every square the bolt flew over is showing
|
||||
// the map character underneath it again: fire_bolt's closing loop paints
|
||||
// chat() back over the whole trail, so a bolt leaves no '/' or '\'
|
||||
// behind.
|
||||
func assertErased(t *testing.T, g *RogueGame, cells []Coord) {
|
||||
t.Helper()
|
||||
|
||||
for _, c := range cells {
|
||||
got := g.scr.Std.Line(c.Y)[c.X]
|
||||
if want := g.Level.Char(c.Y, c.X); got != want {
|
||||
t.Errorf("square %v shows %q, want the map's %q: the trail "+
|
||||
"was not erased", c, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoltDirChar covers the dirch switch for all eight directions. C
|
||||
// keys it on dir->y + dir->x: the two sums of zero are the '/' pair, the
|
||||
// two of magnitude two are the '\' pair, and the four axis directions
|
||||
// split on whether y is zero.
|
||||
func TestBoltDirChar(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dir Coord
|
||||
want byte
|
||||
}{
|
||||
{name: "north", dir: Coord{X: 0, Y: -1}, want: '|'},
|
||||
{name: "south", dir: Coord{X: 0, Y: 1}, want: '|'},
|
||||
{name: "east", dir: Coord{X: 1, Y: 0}, want: '-'},
|
||||
{name: "west", dir: Coord{X: -1, Y: 0}, want: '-'},
|
||||
{name: "north east", dir: Coord{X: 1, Y: -1}, want: '/'},
|
||||
{name: "south west", dir: Coord{X: -1, Y: 1}, want: '/'},
|
||||
{name: "north west", dir: Coord{X: -1, Y: -1}, want: '\\'},
|
||||
{name: "south east", dir: Coord{X: 1, Y: 1}, want: '\\'},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := boltDirChar(tt.dir); got != tt.want {
|
||||
t.Errorf("boltDirChar(%v) = %q, want %q", tt.dir, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoltBounces covers the case labels a bolt reflects off, and the
|
||||
// door exception: C jumps to the default arm when the hero is standing
|
||||
// on the door, "otherwise it would loop infinitely".
|
||||
func TestBoltBounces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const heroX, heroY = 5, 5
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ch byte
|
||||
pos Coord
|
||||
want bool
|
||||
}{
|
||||
{name: "vertical wall", ch: '|', pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{name: "horizontal wall", ch: '-', pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{name: "solid rock", ch: ' ', pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{name: "door", ch: Door, pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{
|
||||
name: "the door under the hero",
|
||||
ch: Door,
|
||||
pos: Coord{X: heroX, Y: heroY},
|
||||
want: false,
|
||||
},
|
||||
{name: "floor", ch: Floor, pos: Coord{X: 6, Y: 5}, want: false},
|
||||
{name: "passage", ch: Passage, pos: Coord{X: 6, Y: 5}, want: false},
|
||||
{name: "staircase", ch: Stairs, pos: Coord{X: 6, Y: 5}, want: false},
|
||||
{name: "a monster", ch: 'Z', pos: Coord{X: 6, Y: 5}, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hero := Coord{X: heroX, Y: heroY}
|
||||
if got := boltBounces(tt.ch, hero, tt.pos); got != tt.want {
|
||||
t.Errorf("boltBounces(%q) = %v, want %v", tt.ch, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltFliesStraight is the end-to-end run with nothing in the
|
||||
// way: six squares, BOLT_LENGTH of them, and the last one is where the
|
||||
// bolt stops.
|
||||
func TestFireBoltFliesStraight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 41)
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
|
||||
g.fireBolt(Coord{X: 2, Y: 2}, &dir, flameName)
|
||||
|
||||
want := []Coord{
|
||||
{X: 3, Y: 2}, {X: 4, Y: 2}, {X: 5, Y: 2},
|
||||
{X: 6, Y: 2}, {X: 7, Y: 2}, {X: 8, Y: 2},
|
||||
}
|
||||
|
||||
got := litCells(g)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("bolt path = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
assertErased(t, g, got)
|
||||
|
||||
if g.Msgs.Huh != "" {
|
||||
t.Errorf("a bolt that hit nothing said %q", g.Msgs.Huh)
|
||||
}
|
||||
|
||||
if (dir != Coord{X: 1, Y: 0}) {
|
||||
t.Errorf("direction = %v, want it unchanged", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltBounces covers the reflection rule in both wall
|
||||
// orientations, off a corner, and — the case that separates C's rule
|
||||
// from a plausible wrong one — diagonally off a vertical wall. C negates
|
||||
// *both* components, so a bolt that came in at 45 degrees goes back the
|
||||
// way it came instead of reflecting off the surface.
|
||||
// boltBounceCase is one wall-bounce run: where the bolt sets off, which
|
||||
// way it goes, the wall it must reflect off, and the squares it must end
|
||||
// up having occupied.
|
||||
type boltBounceCase struct {
|
||||
name string
|
||||
start Coord
|
||||
dir Coord
|
||||
wall Coord
|
||||
want []Coord
|
||||
}
|
||||
|
||||
// run fires the case's bolt and checks its whole flight.
|
||||
func (tt boltBounceCase) run(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
g := mkCarvedGame(t, 42)
|
||||
dir := tt.dir
|
||||
|
||||
g.fireBolt(tt.start, &dir, flameName)
|
||||
|
||||
got := litCells(g)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("bolt path = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
assertErased(t, g, got)
|
||||
|
||||
if slices.Contains(got, tt.wall) {
|
||||
t.Errorf("the wall at %v was drawn on; C drops the bounce "+
|
||||
"square from spotpos before the mvaddch", tt.wall)
|
||||
}
|
||||
|
||||
if want := (Coord{X: -tt.dir.X, Y: -tt.dir.Y}); dir != want {
|
||||
t.Errorf("direction = %v after one bounce, want %v", dir, want)
|
||||
}
|
||||
|
||||
if g.Msgs.Huh != "the flame bounces" {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, "the flame bounces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireBoltBounces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []boltBounceCase{
|
||||
{
|
||||
name: "off a vertical wall",
|
||||
start: Coord{X: 3, Y: corridorY},
|
||||
dir: Coord{X: -1, Y: 0},
|
||||
wall: Coord{X: 1, Y: corridorY},
|
||||
// Five squares, not six: the square in front of the wall is
|
||||
// flown over twice, and C charges spotpos for both.
|
||||
want: []Coord{
|
||||
{X: 2, Y: 4}, {X: 3, Y: 4}, {X: 4, Y: 4},
|
||||
{X: 5, Y: 4}, {X: 6, Y: 4},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off a horizontal wall",
|
||||
start: Coord{X: 5, Y: 2},
|
||||
dir: Coord{X: 0, Y: -1},
|
||||
wall: Coord{X: 5, Y: 1},
|
||||
want: []Coord{
|
||||
{X: 5, Y: 2}, {X: 5, Y: 3}, {X: 5, Y: 4},
|
||||
{X: 5, Y: 5}, {X: 5, Y: 6}, {X: 5, Y: 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off a corner",
|
||||
start: Coord{X: 3, Y: 3},
|
||||
dir: Coord{X: -1, Y: -1},
|
||||
wall: Coord{X: 1, Y: 1},
|
||||
want: []Coord{
|
||||
{X: 2, Y: 2}, {X: 3, Y: 3}, {X: 4, Y: 4},
|
||||
{X: 5, Y: 5}, {X: 6, Y: 6},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diagonally off a vertical wall",
|
||||
start: Coord{X: 3, Y: corridorY},
|
||||
dir: Coord{X: -1, Y: -1},
|
||||
wall: Coord{X: 1, Y: 2},
|
||||
want: []Coord{
|
||||
{X: 2, Y: 3}, {X: 3, Y: 4}, {X: 4, Y: 5},
|
||||
{X: 5, Y: 6}, {X: 6, Y: 7},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tt.run(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltReboundsIntoHero covers the hit_hero/changed pair: a bolt
|
||||
// the hero fires starts unable to hit him, and the first bounce flips
|
||||
// that, so a wall one square away throws his own bolt back at him.
|
||||
func TestFireBoltReboundsIntoHero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lvl int
|
||||
wantMsg string
|
||||
wantHurt bool
|
||||
}{
|
||||
{
|
||||
name: "the hero saves",
|
||||
lvl: saveProofLvl,
|
||||
wantMsg: "the flame whizzes by you",
|
||||
wantHurt: false,
|
||||
},
|
||||
{
|
||||
name: "the hero is hit",
|
||||
lvl: 1,
|
||||
wantMsg: "you are hit by the flame",
|
||||
wantHurt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 43)
|
||||
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
|
||||
fortify(g) // a bolt to the face must not exit the test binary
|
||||
g.Player.Stats.Lvl = tt.lvl
|
||||
|
||||
pinRng(t, g, d20, 1) // the lowest save throw there is
|
||||
|
||||
hp := g.Player.Stats.HP
|
||||
dir := Coord{X: -1, Y: 0}
|
||||
|
||||
g.fireBolt(g.Player.Pos, &dir, flameName)
|
||||
|
||||
if g.Msgs.Huh != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
|
||||
}
|
||||
|
||||
lost := hp - g.Player.Stats.HP
|
||||
if hurt := lost > 0; hurt != tt.wantHurt {
|
||||
t.Errorf("hero lost %d hit points, want hurt = %v",
|
||||
lost, tt.wantHurt)
|
||||
}
|
||||
// roll(6, 6) is six to thirty-six.
|
||||
if tt.wantHurt && (lost < 6 || lost > 36) {
|
||||
t.Errorf("hero lost %d hit points, want 6..36", lost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltFromDoorUnderHeroTerminates covers the guard C wrote the
|
||||
// door case for: the hero standing on a door and firing into the wall
|
||||
// that door sits in. Without the ce(hero, pos) exception the bolt
|
||||
// bounces on his own square forever, never recording a spot and never
|
||||
// filling spotpos, and fire_bolt does not return — this test hangs
|
||||
// rather than fails if the exception is lost.
|
||||
func TestFireBoltFromDoorUnderHeroTerminates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 44)
|
||||
placeHero(g, Coord{X: doorAX, Y: corridorY})
|
||||
fortify(g)
|
||||
|
||||
pinRng(t, g, d20, 1) // no save: the strike ends the flight
|
||||
|
||||
hp := g.Player.Stats.HP
|
||||
dir := Coord{X: 0, Y: -1} // north, into the wall the door is in
|
||||
|
||||
g.fireBolt(g.Player.Pos, &dir, boltName)
|
||||
|
||||
if g.Msgs.Huh != "you are hit by the bolt" {
|
||||
t.Errorf("message = %q, want the hero to be hit", g.Msgs.Huh)
|
||||
}
|
||||
|
||||
if lost := hp - g.Player.Stats.HP; lost < 6 || lost > 36 {
|
||||
t.Errorf("hero lost %d hit points, want 6..36", lost)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltStrikesMonster covers the monster arm both ways, and the
|
||||
// dragon's immunity to flame that C spells out in the same breath.
|
||||
func TestFireBoltStrikesMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
typ byte
|
||||
lvl int
|
||||
bolt string
|
||||
wantMsg string
|
||||
wantHurt bool
|
||||
}{
|
||||
{
|
||||
name: "it fails its save",
|
||||
typ: 'Z',
|
||||
lvl: 1,
|
||||
bolt: boltName,
|
||||
wantMsg: "the bolt hits the zombie",
|
||||
wantHurt: true,
|
||||
},
|
||||
{
|
||||
name: "it saves",
|
||||
typ: 'Z',
|
||||
lvl: saveProofLvl,
|
||||
bolt: boltName,
|
||||
wantMsg: "the bolt whizzes past the zombie",
|
||||
wantHurt: false,
|
||||
},
|
||||
{
|
||||
name: "a dragon shrugs off a flame",
|
||||
typ: 'D',
|
||||
lvl: 1,
|
||||
bolt: flameName,
|
||||
wantMsg: "the flame bounces off the dragon",
|
||||
wantHurt: false,
|
||||
},
|
||||
{
|
||||
name: "but not a lightning bolt",
|
||||
typ: 'D',
|
||||
lvl: 1,
|
||||
bolt: boltName,
|
||||
wantMsg: "the bolt hits the dragon",
|
||||
wantHurt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 45)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
|
||||
tp := putMonster(g, tt.typ, Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = tt.lvl
|
||||
tp.Stats.HP = 500 // enough to survive 6x6 and stay assertable
|
||||
|
||||
pinRng(t, g, d20, 1) // the lowest save throw there is
|
||||
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
g.fireBolt(g.Player.Pos, &dir, tt.bolt)
|
||||
|
||||
if g.Msgs.Huh != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
|
||||
}
|
||||
|
||||
if hurt := tp.Stats.HP < 500; hurt != tt.wantHurt {
|
||||
t.Errorf("monster hit points = %d, want hurt = %v",
|
||||
tp.Stats.HP, tt.wantHurt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltMissedMonsterWakesUp covers the rest of the miss arm: a
|
||||
// bolt the hero fired sets the monster running (runto) before it says
|
||||
// what it whizzed past, and the bolt flies on for its full length.
|
||||
func TestFireBoltMissedMonsterWakesUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 46)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = saveProofLvl
|
||||
tp.Flags.Clear(Awake)
|
||||
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
g.fireBolt(g.Player.Pos, &dir, boltName)
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the monster the bolt missed is still asleep")
|
||||
}
|
||||
|
||||
if tp.Dest != &g.Player.Pos {
|
||||
t.Error("the woken monster is not chasing the hero")
|
||||
}
|
||||
|
||||
if tp.OldCh != Floor {
|
||||
t.Errorf("under-character = %q, want %q: fire_bolt records chat() "+
|
||||
"before it resolves the save", tp.OldCh, Floor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltMissSpeaksEvenForAnM pins the "ch != 'M' ||
|
||||
// tp->t_disguise == 'M'" guard on C's miss message, which reads as
|
||||
// though something looking like an 'M' can be missed silently. It
|
||||
// cannot: ch comes from winat, and winat *is* t_disguise whenever a
|
||||
// monster stands there (rogue.h 57), so ch == 'M' implies
|
||||
// t_disguise == 'M' and the condition is always true. The guard is
|
||||
// vestigial — 'M' was the mimic in earlier Rogues — and a port that
|
||||
// "tidied" it into a real silence would go quiet where C speaks.
|
||||
func TestFireBoltMissSpeaksEvenForAnM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 47)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
|
||||
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = saveProofLvl
|
||||
tp.Flags.Clear(Awake)
|
||||
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
g.fireBolt(g.Player.Pos, &dir, boltName)
|
||||
|
||||
const want = "the bolt whizzes past the medusa"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the missed medusa was not set running")
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// chase.c — code for one creature to chase another.
|
||||
@@ -157,7 +158,9 @@ func (g *RogueGame) chaseRooms(th *Monster) (*Room, *Room, bool) {
|
||||
// nearest exit toward its desire when it is in a different room, or the
|
||||
// desire itself. shot means a dragon breathed flame instead of moving
|
||||
// (the goal loop of chase.c do_chase).
|
||||
func (g *RogueGame) chaseGoal(th *Monster, rer, ree *Room, door bool, mindist int) (Coord, bool) {
|
||||
func (g *RogueGame) chaseGoal(
|
||||
th *Monster, rer, ree *Room, door bool, mindist int,
|
||||
) (Coord, bool) {
|
||||
var this Coord
|
||||
|
||||
for {
|
||||
|
||||
+98
-2
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// command.c — read and execute the user commands.
|
||||
@@ -7,6 +8,13 @@ package game
|
||||
func (g *RogueGame) command() {
|
||||
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
|
||||
if p.On(Hasted) {
|
||||
ntimes++
|
||||
@@ -390,6 +398,36 @@ func (g *RogueGame) identifyTrapCommand() {
|
||||
}
|
||||
}
|
||||
|
||||
// wizardToggleCommand handles '+': leave wizard mode (command.c
|
||||
// command). C's arm lives in the main command switch under
|
||||
// #ifdef MASTER, not in the wizard sub-switch, so it is reachable
|
||||
// whether or not wizard is set.
|
||||
//
|
||||
// The entry half is deliberately not ported. C ran wizard = passwd(),
|
||||
// which compared a DES-crypted answer against a compiled-in password;
|
||||
// this port drops that machinery and makes wizard mode configuration
|
||||
// instead (ROGUE_WIZARD, ARCHITECTURE.md §9). A password check that is
|
||||
// gone is a password check that can never succeed, so the else arm
|
||||
// reduces to exactly what C did when the answer was wrong: wizard stays
|
||||
// off and the game says "sorry". No prompt is shown, since nothing typed
|
||||
// into it could change the outcome, and the noscore/turn_see(FALSE)
|
||||
// bookkeeping of C's success branch is unreachable and so is absent.
|
||||
func (g *RogueGame) wizardToggleCommand() {
|
||||
g.After = false
|
||||
|
||||
if !g.Wizard {
|
||||
g.msg("sorry")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
g.Wizard = false
|
||||
// Re-hide the monsters wizard sight was showing: without this there
|
||||
// is no way back to normal visibility.
|
||||
g.turnSee(true)
|
||||
g.msg("not wizard any more")
|
||||
}
|
||||
|
||||
// wizardCommand handles the MASTER debug commands (command.c).
|
||||
func (g *RogueGame) wizardCommand(ch byte) {
|
||||
p := &g.Player
|
||||
@@ -794,6 +832,7 @@ func (g *RogueGame) call() {
|
||||
func (g *RogueGame) callTarget(obj *Object) (*ObjInfo, string, *string, bool) {
|
||||
it := &g.Items
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindRing:
|
||||
return &it.Rings[obj.Which], it.RingStones[obj.Which], nil, true
|
||||
@@ -815,7 +854,9 @@ func (g *RogueGame) callTarget(obj *Object) (*ObjInfo, string, *string, bool) {
|
||||
// callPrelude resolves the item's current name, reporting a previous
|
||||
// guess; ok is false when the item is already identified (command.c
|
||||
// call).
|
||||
func (g *RogueGame) callPrelude(op *ObjInfo, elsewise string, guess *string) (string, *string, bool) {
|
||||
func (g *RogueGame) callPrelude(
|
||||
op *ObjInfo, elsewise string, guess *string,
|
||||
) (string, *string, bool) {
|
||||
fromGuess := false
|
||||
|
||||
if op != nil {
|
||||
@@ -886,7 +927,7 @@ func (g *RogueGame) shell() {
|
||||
if se, ok := g.scr.term.(interface{ ShellEscape() }); ok {
|
||||
g.InShell = true
|
||||
|
||||
se.ShellEscape()
|
||||
g.runShellEscape(se)
|
||||
|
||||
g.InShell = false
|
||||
g.refresh()
|
||||
@@ -894,3 +935,58 @@ func (g *RogueGame) shell() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRedrawCommandForcesFullRepaint pins CTRL-R to a forced repaint
|
||||
// rather than an ordinary refresh. C's arm is "after = FALSE;
|
||||
// clearok(curscr, TRUE); wrefresh(curscr);" (command.c), and the
|
||||
// clearok is the command: a diffing refresh compares the new frame
|
||||
// against the device's record of the old one and sends nothing when they
|
||||
// agree, which is exactly the situation after some other program has
|
||||
// scribbled on the terminal. Only the terminal can tell the difference,
|
||||
// so the test watches the terminal rather than the window contents.
|
||||
func TestRedrawCommandForcesFullRepaint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
|
||||
term, ok := g.scr.term.(*testTerm)
|
||||
if !ok {
|
||||
t.Fatal("game terminal is not a testTerm")
|
||||
}
|
||||
|
||||
before := term.repaints
|
||||
g.After = true
|
||||
|
||||
g.dispatch(CTRL('R'))
|
||||
|
||||
if term.repaints != before+1 {
|
||||
t.Errorf("terminal repainted %d times, want %d: CTRL-R did not force "+
|
||||
"a full redraw", term.repaints-before, 1)
|
||||
}
|
||||
|
||||
if g.After {
|
||||
t.Error("CTRL-R consumed a turn; C sets after = FALSE")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ type Creature struct {
|
||||
Type byte // what it is: 'A'..'Z' for monsters, '@' for the player
|
||||
Disguise byte // what mimic looks like
|
||||
OldCh byte // character that was where it was
|
||||
Dest *Coord // where it is running to — aliases live coords (hero pos, room gold, another monster's pos)
|
||||
// Dest is where it is running to — aliases live coords (hero pos,
|
||||
// room gold, another monster's pos).
|
||||
Dest *Coord
|
||||
Flags CreatureFlags
|
||||
Stats Stats
|
||||
Room *Room // current room for thing
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// daemons.c — the daemon and fuse callbacks, dispatched by DaemonID.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
@@ -6,6 +7,8 @@ import "testing"
|
||||
// including its junk-tolerant edges: the bestiary placeholder "%%%x0" and
|
||||
// the flytrap reset "000x0" both mean a single 0x0 attack.
|
||||
func TestParseDice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
@@ -32,6 +35,8 @@ func TestParseDice(t *testing.T) {
|
||||
// The bestiary and weapon tables must parse to at least one attack each so
|
||||
// every creature and weapon actually swings.
|
||||
func TestTablesHaveDice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := newGameData()
|
||||
for i, m := range data.monsterTable {
|
||||
if len(m.Stats.Dmg) == 0 {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This file is the standing form of the issue #31 audit: every case
|
||||
// label in C's command switch against this port's dispatch. It exists
|
||||
// because a missing dispatch entry is the one porting error that leaves
|
||||
// no trace at build time. The port is function-by-function, so every C
|
||||
// function has a Go counterpart and a dropped key dangles nothing and
|
||||
// fails to compile nowhere; it simply answers "illegal command" the
|
||||
// first time a player presses it. That is how '+' (issue #11) survived
|
||||
// until PR #30 found it by accident.
|
||||
//
|
||||
// The tables below are transcribed from origin/c-master:command.c, with
|
||||
// the C line numbers alongside. Read them there with rogue.h 52-53 in
|
||||
// hand:
|
||||
//
|
||||
// #define when break;case
|
||||
// #define otherwise break;default
|
||||
//
|
||||
// The labels are therefore written "when 'x':", and a grep for "case "
|
||||
// finds ten of the eighty. CTRL is extern.h:113, (c & 037); ESCAPE is
|
||||
// rogue.h:121, 27.
|
||||
//
|
||||
// There are two switches and the split between them is load-bearing. A
|
||||
// key C answers from the main switch (151-427) must be answered here
|
||||
// whether or not wizard mode is on. A key C answers only from the
|
||||
// "if (wizard) switch (ch)" sub-switch (369-423) must not be reachable
|
||||
// outside it. '+' was a divergence in ordinary play, not just in wizard
|
||||
// mode, precisely because it is a main-switch key.
|
||||
//
|
||||
// The whole sub-switch, and '+' with it, is #ifdef MASTER. This port
|
||||
// targets the MASTER build: all four #ifdef MASTER sites in command.c
|
||||
// (67, 128, 317, 368) are ported unconditionally, as is sticks.c 237.
|
||||
|
||||
// cMainSwitchTableKeys are the main-switch labels whose arms are a plain
|
||||
// call, and which this port therefore answers from commandHandlers.
|
||||
func cMainSwitchTableKeys() []byte {
|
||||
return []byte{
|
||||
',', // 153
|
||||
'!', // 180
|
||||
'h', 'j', 'k', 'l', 'y', 'u', 'b', 'n', // 181-188 do_move
|
||||
'H', 'J', 'K', 'L', 'Y', 'U', 'B', 'N', // 189-196 do_run
|
||||
't', // 241
|
||||
'q', 'Q', 'i', 'I', 'd', 'r', 'e', 'w', // 258-269
|
||||
'W', 'T', 'P', 'R', 'o', 'c', // 270-275
|
||||
'>', '<', '?', '/', 's', 'z', 'D', // 276-286
|
||||
CTRL('P'), CTRL('R'), // 287-291
|
||||
'v', // 292
|
||||
'S', // 295
|
||||
'.', // 298 rest
|
||||
' ', // 299 "legal" illegal command
|
||||
'^', // 300
|
||||
'+', // 318 (#ifdef MASTER)
|
||||
Escape, // 339
|
||||
')', ']', '=', // 354-360
|
||||
'@', // 361
|
||||
}
|
||||
}
|
||||
|
||||
// cMainSwitchMultiStepKeys are the main-switch labels whose arms need
|
||||
// more than a call — C's "goto over" re-dispatch, or the F-to-f
|
||||
// fallthrough — and which this port therefore answers from dispatchKey's
|
||||
// own switch rather than from commandHandlers. They are main-switch keys
|
||||
// all the same, and a player reaches them without wizard mode.
|
||||
func cMainSwitchMultiStepKeys() []byte {
|
||||
return []byte{
|
||||
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'), // 197
|
||||
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'), // 198
|
||||
'F', // 214 sets kamikaze, then falls through
|
||||
'f', // 217
|
||||
'a', // 246
|
||||
'm', // 344
|
||||
}
|
||||
}
|
||||
|
||||
// cWizardSwitchKeys are the labels of the "if (wizard) switch (ch)"
|
||||
// sub-switch, which sits inside the main switch's otherwise: arm.
|
||||
func cWizardSwitchKeys() []byte {
|
||||
return []byte{
|
||||
'|', // 371
|
||||
'C', // 372
|
||||
'$', // 373
|
||||
CTRL('G'), CTRL('W'), // 374-375
|
||||
CTRL('D'), CTRL('A'), // 376-377
|
||||
CTRL('F'), CTRL('T'), // 378-379
|
||||
CTRL('E'), CTRL('C'), // 380-381
|
||||
CTRL('X'), // 382
|
||||
CTRL('~'), // 383
|
||||
CTRL('I'), // 390
|
||||
'*', // 419
|
||||
}
|
||||
}
|
||||
|
||||
// assertNotIllegalCommand fails if the top line reports the key as
|
||||
// illegal. illcom is the only thing that writes that message, so it is a
|
||||
// reliable "the dispatch had no arm for this key" probe: it holds
|
||||
// whether the arm printed its own message, printed nothing, or cleared
|
||||
// the line on the way out.
|
||||
func assertNotIllegalCommand(t *testing.T, g *RogueGame, ch byte) {
|
||||
t.Helper()
|
||||
|
||||
// End() upper-cases the first letter, so match from the second.
|
||||
if line := g.scr.Std.Line(0); strings.Contains(line, "llegal command") {
|
||||
t.Errorf("dispatching '%s' reached illcom (top line %q); C answers "+
|
||||
"it from command.c's switch", unctrl(ch), strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandHandlersMatchCMainSwitch pins commandHandlers to exactly the
|
||||
// set of main-switch keys C answers with a plain call. It is checked in
|
||||
// both directions on purpose. A missing key is the '+' bug. An extra key
|
||||
// is the same bug mirrored: the most likely way to acquire one is to
|
||||
// promote a key out of the wizard sub-switch, which would make a MASTER
|
||||
// debug command available in ordinary play.
|
||||
func TestCommandHandlersMatchCMainSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handlers := newGameData().commandHandlers
|
||||
keys := cMainSwitchTableKeys()
|
||||
|
||||
want := make(map[byte]bool, len(keys))
|
||||
|
||||
for _, ch := range keys {
|
||||
want[ch] = true
|
||||
|
||||
if _, ok := handlers[ch]; !ok {
|
||||
t.Errorf("commandHandlers has no entry for '%s'; C answers it "+
|
||||
"from the main command.c switch, so this port says "+
|
||||
"\"illegal command\" where C does not", unctrl(ch))
|
||||
}
|
||||
}
|
||||
|
||||
for ch := range handlers {
|
||||
if !want[ch] {
|
||||
t.Errorf("commandHandlers has an entry for '%s' that C's main "+
|
||||
"switch does not; if C answers it only under if (wizard), "+
|
||||
"it belongs in wizardCommand", unctrl(ch))
|
||||
}
|
||||
}
|
||||
|
||||
if len(want) != len(keys) {
|
||||
t.Errorf("cMainSwitchTableKeys lists %d keys, %d of them distinct; "+
|
||||
"a duplicate hides a missing key", len(keys), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchKeyAnswersCMultiStepKeys covers the main-switch keys that
|
||||
// commandHandlers cannot hold, which the set-equality test above cannot
|
||||
// see. Removing one of these from dispatchKey's switch is just as silent
|
||||
// as removing a map entry: it falls into the default arm and lands on
|
||||
// illcom, so that is what is checked.
|
||||
func TestDispatchKeyAnswersCMultiStepKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ch := range cMainSwitchMultiStepKeys() {
|
||||
t.Run(unctrl(ch), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
// Not a wizard: these are ordinary-play keys, so the default
|
||||
// arm they must not reach is illcom itself.
|
||||
g.Wizard = false
|
||||
g.Options.Terse = false
|
||||
// 'a' replays the last command; give it one to replay so it
|
||||
// takes its re-dispatch arm rather than its complaint arm.
|
||||
g.LastComm = '.'
|
||||
// F, f and m prompt for a direction. Escape backs out of the
|
||||
// prompt, which keeps them from moving the hero or starting a
|
||||
// fight while still proving their arm ran.
|
||||
setInput(t, g, Escape, Escape, Escape)
|
||||
|
||||
next, again := g.dispatchKey(ch)
|
||||
t.Logf("dispatchKey(%s) = %s, again=%v",
|
||||
unctrl(ch), unctrl(next), again)
|
||||
|
||||
assertNotIllegalCommand(t, g, ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchKeyRedispatchesCtrlDirections is the positive half of the
|
||||
// test above for the eight ctrl-directions: C's arm converts the key to
|
||||
// its upper-case run command and does "goto over" (command.c 197-213),
|
||||
// which this port spells as a true second result. Checking the returned
|
||||
// key, and not merely that illcom was missed, is what would catch the
|
||||
// arm being present but wired to the wrong direction.
|
||||
func TestDispatchKeyRedispatchesCtrlDirections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ch := range []byte{
|
||||
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'),
|
||||
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'),
|
||||
} {
|
||||
t.Run(unctrl(ch), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
|
||||
// C's "ch += ('A' - CTRL('A'))": ctrl-h becomes 'H'.
|
||||
wantCh := ch + 'A' - CTRL('A')
|
||||
|
||||
next, again := g.dispatchKey(ch)
|
||||
if !again {
|
||||
t.Fatalf("dispatchKey(%s) did not ask to re-dispatch; C's "+
|
||||
"arm ends in goto over", unctrl(ch))
|
||||
}
|
||||
|
||||
if next != wantCh {
|
||||
t.Errorf("dispatchKey(%s) re-dispatched as %q, want %q",
|
||||
unctrl(ch), next, wantCh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWizardDispatchAnswersCWizardSwitch is the same guard for the
|
||||
// MASTER sub-switch, driven through dispatchKey rather than through
|
||||
// wizardCommand directly so that the routing is covered too: these keys
|
||||
// must be answered because wizard mode is on, not because they leaked
|
||||
// into commandHandlers.
|
||||
func TestWizardDispatchAnswersCWizardSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ch := range cWizardSwitchKeys() {
|
||||
t.Run(unctrl(ch), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
g.Wizard = true
|
||||
// ctrl-a is "level--; new_level()", so start deep enough for
|
||||
// it to have somewhere to go.
|
||||
g.Depth = 5
|
||||
// Escape backs out of the item and type prompts that ctrl-w,
|
||||
// ctrl-~, 'C' and '*' put up. testTerm keeps answering after
|
||||
// the script runs out, so nothing here can block.
|
||||
setInput(t, g, Escape, Escape, Escape, Escape)
|
||||
|
||||
g.dispatchKey(ch)
|
||||
|
||||
assertNotIllegalCommand(t, g, ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
@@ -7,7 +8,7 @@ import "testing"
|
||||
func mkGameInput(t *testing.T) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := NewGame(Config{Seed: 5, Term: &testTerm{}})
|
||||
g := New(Params{Seed: 5, Term: &testTerm{}})
|
||||
g.NewLevel()
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
@@ -37,6 +38,8 @@ func setInput(t *testing.T, g *RogueGame, input ...byte) {
|
||||
}
|
||||
|
||||
func TestQuaffHealingPotion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
@@ -61,6 +64,8 @@ func TestQuaffHealingPotion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
@@ -88,6 +93,8 @@ func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadEnchantArmor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
scr := newObject()
|
||||
scr.Kind = KindScroll
|
||||
@@ -105,6 +112,7 @@ func TestReadEnchantArmor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Note: this must not use a greedy monster ('O' orc, ISGREED): the C
|
||||
// wake_monster gold-guarding check has no ISHELD guard, so the
|
||||
// look(TRUE) at the end of read_scroll immediately re-wakes greedy
|
||||
@@ -133,6 +141,8 @@ func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
|
||||
// (orc) held by a scroll is re-woken by the look(TRUE) that read_scroll
|
||||
// performs, ending up both held and running again.
|
||||
func TestHoldScrollGreedyMonsterQuirk(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
tp := spawnAdjacent(g, 'O')
|
||||
tp.Flags.Set(Awake)
|
||||
@@ -158,6 +168,8 @@ func TestHoldScrollGreedyMonsterQuirk(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestZapSlowMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
tp := spawnAdjacent(g, 'Z')
|
||||
stick := newObject()
|
||||
@@ -182,8 +194,78 @@ 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) {
|
||||
g := NewGame(Config{Seed: 1})
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 1})
|
||||
g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow")
|
||||
|
||||
if !g.Options.Terse {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "strconv"
|
||||
|
||||
+12
-19
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
@@ -7,7 +8,7 @@ import "testing"
|
||||
func mkGame(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := NewGame(Config{Seed: seed, Term: &testTerm{}})
|
||||
g := New(Params{Seed: seed, Term: &testTerm{}})
|
||||
g.NewLevel()
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
@@ -25,6 +26,8 @@ func spawnAdjacent(g *RogueGame, typ byte) *Monster {
|
||||
}
|
||||
|
||||
func TestRollEmParsesMultiAttackDice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 42)
|
||||
att := &Creature{Stats: Stats{Str: 16, Lvl: 20, Dmg: dice("1x4/1x4/1x4")}}
|
||||
def := &Creature{Stats: Stats{ArmorClass: 10, HP: 1000}}
|
||||
@@ -43,6 +46,8 @@ func TestRollEmParsesMultiAttackDice(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFightKillsMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 7)
|
||||
tp := spawnAdjacent(g, 'B') // bat: 1 hit die
|
||||
tp.Stats.HP = 1
|
||||
@@ -64,6 +69,8 @@ func TestFightKillsMonster(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAttackHurtsPlayer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 9)
|
||||
tp := spawnAdjacent(g, 'T') // troll: 1x8/1x8/2x6
|
||||
tp.Stats.Lvl = 20 // always hits
|
||||
@@ -79,25 +86,9 @@ func TestAttackHurtsPlayer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeathUnwindsWithGameEnd(t *testing.T) {
|
||||
g := mkGame(t, 11)
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
if _, ok := r.(gameEnd); !ok {
|
||||
t.Fatalf("death did not unwind with gameEnd, got %v", r)
|
||||
}
|
||||
|
||||
if g.Playing {
|
||||
t.Error("still playing after death")
|
||||
}
|
||||
}()
|
||||
|
||||
g.Options.Tombstone = false
|
||||
g.death('K')
|
||||
}
|
||||
|
||||
func TestRunnersChaseHero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 3)
|
||||
// Place a hobgoblin a few squares away in the hero's room and set it
|
||||
// running at the hero.
|
||||
@@ -122,6 +113,8 @@ func TestRunnersChaseHero(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKilledLeprechaunDropsGoldViaFall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 21)
|
||||
tp := spawnAdjacent(g, 'L')
|
||||
tp.Stats.HP = 0
|
||||
|
||||
+105
-44
@@ -1,5 +1,8 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ItemLore is the per-game item identity state: the randomized appearance
|
||||
// names and the seven mutable ObjInfo tables (extern.c/init.c).
|
||||
type ItemLore struct {
|
||||
@@ -31,9 +34,9 @@ type Options struct {
|
||||
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
|
||||
}
|
||||
|
||||
// Config carries everything needed to construct a game.
|
||||
type Config struct {
|
||||
Seed int32 // dungeon number; the caller derives it (time+pid or SEED env)
|
||||
// Params carries everything needed to construct a game.
|
||||
type Params struct {
|
||||
Seed int32 // dungeon number; caller derives it (time+pid or SEED)
|
||||
Name string // player name (overridden by ROGUEOPTS name=)
|
||||
RogueOpts string // the ROGUEOPTS environment string
|
||||
Home string // home directory (save file default location)
|
||||
@@ -44,7 +47,7 @@ type Config struct {
|
||||
|
||||
// RogueGame is one complete game of Rogue: every piece of state that was a
|
||||
// global (or file-scope static) in the C sources, plus the terminal it is
|
||||
// played on. Construct with NewGame, then call Run.
|
||||
// played on. Construct with New, then call Run.
|
||||
//
|
||||
// The struct grows with the port; fields appear in the phase that ports the
|
||||
// code owning them.
|
||||
@@ -139,27 +142,83 @@ type RogueGame struct {
|
||||
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
|
||||
restored bool // game came from a save file; Run skips setup
|
||||
|
||||
// sigSave carries signal-triggered autosave requests from the signal
|
||||
// goroutine to the game goroutine, which is the only one allowed to
|
||||
// touch the state above (issue #24). Buffered by one: the handler
|
||||
// reads exactly one signal, so there is never more than one request.
|
||||
// See AutoSaveOnSignal and serviceAutoSaveRequest in save.go.
|
||||
sigSave chan *autoSaveRequest
|
||||
|
||||
// data is the game's copy of the static tables (extern.c and friends).
|
||||
data *gameData
|
||||
}
|
||||
|
||||
// NewGame builds a game from cfg, seeds the RNG, and randomizes the item
|
||||
// Greeting is the line C printed on stdout while the player waited for
|
||||
// the dungeon to be dug, immediately before initscr() (main.c main). The
|
||||
// caller prints it before the terminal package takes the screen, which is
|
||||
// where initscr() sat; there is no trailing newline in either wording,
|
||||
// because C followed the printf with fflush and let curses have the
|
||||
// display.
|
||||
//
|
||||
// Only the wizard wording is #ifdef MASTER in C, and it carries the
|
||||
// dungeon number, which is the seed (main.c assigns seed = dnum right
|
||||
// after choosing dnum). The other wording is unconditional.
|
||||
//
|
||||
// The name is C's whoami, resolved the way main.c resolves it: parse_opts
|
||||
// runs before the printf, so a ROGUEOPTS "name=" setting is what the
|
||||
// player is greeted by, and the account name is only the fallback. New
|
||||
// does the same parse a moment later; doing it here too is safe because
|
||||
// ParseOpts does nothing but assign into the fields it is handed — no
|
||||
// RNG, no screen — so it cannot disturb the item tables the seed-compat
|
||||
// golden pins.
|
||||
//
|
||||
// The game it parses into is a throwaway, but it is built the way New
|
||||
// builds the real one, because ParseOpts handles every option and not
|
||||
// just the one this function reads: "inven=" is matched against the
|
||||
// inv_t_name[] table and "file=~/..." against the home directory, both
|
||||
// of which live on the game. A greeting that skimped on them faulted on
|
||||
// a perfectly legal ROGUEOPTS before the player saw a single character.
|
||||
func Greeting(params Params) string {
|
||||
whoami := params.Name
|
||||
|
||||
if params.RogueOpts != "" {
|
||||
opts := &RogueGame{
|
||||
data: newGameData(),
|
||||
Whoami: params.Name,
|
||||
Home: params.Home,
|
||||
}
|
||||
opts.ParseOpts(params.RogueOpts)
|
||||
|
||||
whoami = opts.Whoami
|
||||
}
|
||||
|
||||
if params.Wizard {
|
||||
return fmt.Sprintf("Hello %s, welcome to dungeon #%d",
|
||||
whoami, params.Seed)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Hello %s, just a moment while I dig the dungeon...", whoami)
|
||||
}
|
||||
|
||||
// New builds a game from params, seeds the RNG, and randomizes the item
|
||||
// appearance tables (the front half of main.c main(); the player roll-up
|
||||
// and first level arrive with later porting phases).
|
||||
func NewGame(cfg Config) *RogueGame {
|
||||
func New(params Params) *RogueGame {
|
||||
g := &RogueGame{
|
||||
data: newGameData(),
|
||||
Rng: &Rng{Seed: cfg.Seed},
|
||||
Dnum: int(cfg.Seed),
|
||||
Whoami: cfg.Name,
|
||||
Rng: &Rng{Seed: params.Seed},
|
||||
Dnum: int(params.Seed),
|
||||
Whoami: params.Name,
|
||||
Fruit: "slime-mold",
|
||||
Home: cfg.Home,
|
||||
Wizard: cfg.Wizard,
|
||||
NoScore: cfg.Wizard,
|
||||
Home: params.Home,
|
||||
Wizard: params.Wizard,
|
||||
NoScore: params.Wizard,
|
||||
Playing: true,
|
||||
Depth: 1,
|
||||
ScorePath: cfg.ScorePath,
|
||||
ScorePath: params.ScorePath,
|
||||
LastScore: -1,
|
||||
sigSave: make(chan *autoSaveRequest, 1),
|
||||
}
|
||||
g.Options = Options{
|
||||
SeeFloor: true,
|
||||
@@ -168,17 +227,17 @@ func NewGame(cfg Config) *RogueGame {
|
||||
}
|
||||
g.InvDescribe = true
|
||||
g.Msgs.SaveMsg = true
|
||||
g.scr = NewScreen(cfg.Term)
|
||||
g.scr = NewScreen(params.Term)
|
||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||
g.FileName = cfg.Home + "/rogue.save"
|
||||
g.FileName = params.Home + "/rogue.save"
|
||||
|
||||
g.rogueOpts = cfg.RogueOpts
|
||||
if cfg.Wizard {
|
||||
g.rogueOpts = params.RogueOpts
|
||||
if params.Wizard {
|
||||
g.Player.Flags.Set(SenseMonsters)
|
||||
}
|
||||
|
||||
if cfg.RogueOpts != "" {
|
||||
g.ParseOpts(cfg.RogueOpts)
|
||||
if params.RogueOpts != "" {
|
||||
g.ParseOpts(params.RogueOpts)
|
||||
}
|
||||
|
||||
g.Monsters = g.data.monsterTable
|
||||
@@ -199,37 +258,45 @@ func NewGame(cfg Config) *RogueGame {
|
||||
}
|
||||
|
||||
// Run plays the game to its end: the back half of main.c main() plus
|
||||
// playit(). It returns after death, victory, quitting, or saving.
|
||||
func (g *RogueGame) Run() error {
|
||||
// A gameEnd panic is the port's my_exit(): recovering it here makes
|
||||
// Run return normally (zero values), restoring the terminal via the
|
||||
// caller's defers.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(gameEnd); ok {
|
||||
return // normal game over / save exit
|
||||
// playit(). It does not return — the game ends by exiting the process
|
||||
// (see myExit); one game run is one process.
|
||||
func (g *RogueGame) Run() {
|
||||
g.startLevel()
|
||||
g.playit()
|
||||
}
|
||||
|
||||
// startLevel draws the first level and starts the standing daemons and
|
||||
// fuses for a fresh game; a restored game brings its own (the back half
|
||||
// of main.c main()).
|
||||
func (g *RogueGame) startLevel() {
|
||||
if g.restored {
|
||||
return
|
||||
}
|
||||
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
if !g.restored {
|
||||
g.NewLevel() // draw current level
|
||||
// Start up daemons and fuses
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
}
|
||||
|
||||
g.playit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playit is the main loop of the program (main.c playit).
|
||||
func (g *RogueGame) playit() {
|
||||
g.prePlay()
|
||||
|
||||
for g.Playing {
|
||||
g.command() // command execution
|
||||
}
|
||||
|
||||
g.endit()
|
||||
}
|
||||
|
||||
// prePlay does the option and position setup at the top of playit,
|
||||
// before the command loop (main.c playit). It is split out so tests can
|
||||
// drive a bounded number of turns; the loop itself never returns,
|
||||
// because game-over exits the process.
|
||||
func (g *RogueGame) prePlay() {
|
||||
// set up defaults for modern terminals: curses' md_hasclreol() is
|
||||
// always true, so the C default inventory style applies
|
||||
if !g.restored {
|
||||
@@ -242,13 +309,7 @@ func (g *RogueGame) playit() {
|
||||
}
|
||||
|
||||
g.Oldpos = g.Player.Pos
|
||||
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
for g.Playing {
|
||||
g.command() // command execution
|
||||
}
|
||||
|
||||
g.endit()
|
||||
}
|
||||
|
||||
// endit exits the game (main.c endit).
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGreeting pins both wordings of main.c's pre-initscr printf byte for
|
||||
// byte. The wizard one carries dnum, which main.c has just assigned to
|
||||
// seed, so it is the seed the player sees. Neither ends in a newline: C
|
||||
// printed, flushed, and handed the display to curses.
|
||||
func TestGreeting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The account name main.c copies into whoami when ROGUEOPTS does not
|
||||
// name the player itself.
|
||||
const account = "conan"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
params Params
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
params: Params{Name: account, Seed: 4242},
|
||||
want: "Hello conan, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
name: "wizard names the dungeon",
|
||||
params: Params{Name: account, Seed: 4242, Wizard: true},
|
||||
want: "Hello conan, welcome to dungeon #4242",
|
||||
},
|
||||
{
|
||||
// parse_opts runs before the printf in main.c, and whoami
|
||||
// falls back to the account name only when ROGUEOPTS left it
|
||||
// empty, so the option is what the player is greeted by.
|
||||
name: "ROGUEOPTS name wins over the account name",
|
||||
params: Params{
|
||||
Name: account, Seed: 7, RogueOpts: "name=Rodney",
|
||||
},
|
||||
want: "Hello Rodney, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
name: "ROGUEOPTS without a name keeps the account name",
|
||||
params: Params{
|
||||
Name: account, Seed: 7, RogueOpts: "terse,fruit=mango",
|
||||
},
|
||||
want: "Hello conan, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
// ParseOpts reaches every option, not just name=, and the
|
||||
// inventory style is matched against a table (options.c
|
||||
// parse_opts, inv_t_name[]) that lives in the game data. A
|
||||
// greeting parsed on a game without those tables faulted on
|
||||
// this ROGUEOPTS before it could print anything at all.
|
||||
name: "ROGUEOPTS inventory style parses without a fault",
|
||||
params: Params{
|
||||
Name: account, Seed: 7, RogueOpts: "inven=slow,name=Rodney",
|
||||
},
|
||||
want: "Hello Rodney, just a moment while I dig the dungeon...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Greeting(tc.params)
|
||||
if got != tc.want {
|
||||
t.Errorf("Greeting() = %q, want %q", got, tc.want)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(got, "\n") {
|
||||
t.Error("greeting ends in a newline; C's printf did not")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "strings"
|
||||
@@ -169,7 +170,8 @@ func (g *RogueGame) initProbs() {
|
||||
sumProbs(g.Items.Scrolls[:])
|
||||
sumProbs(g.Items.Rings[:])
|
||||
sumProbs(g.Items.Sticks[:])
|
||||
sumProbs(g.Items.Weapons[:NumWeaponTypes]) // C sums MAXWEAPONS, excluding the flame entry
|
||||
// C sums MAXWEAPONS, excluding the flame entry.
|
||||
sumProbs(g.Items.Weapons[:NumWeaponTypes])
|
||||
sumProbs(g.Items.Armors[:])
|
||||
}
|
||||
|
||||
|
||||
+26
-1
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -165,8 +166,31 @@ func stepOk(ch byte) bool {
|
||||
|
||||
// readchar reads and returns a character, checking for gross input errors
|
||||
// (io.c readchar).
|
||||
//
|
||||
// Waiting for a key is where the game spends nearly all of its wall
|
||||
// clock, so it is also where a signal-triggered autosave usually finds
|
||||
// it: a dropped connection lands while the player is thinking, not
|
||||
// mid-turn. Terminal.Interrupt wakes the read for exactly that, and the
|
||||
// save runs here, on the game goroutine, before reading again.
|
||||
//
|
||||
// What that buys is a snapshot taken by the goroutine that owns the
|
||||
// state, so it is internally consistent and restorable. It is never a
|
||||
// between-commands snapshot: readchar is reached from readCommand at the
|
||||
// top of a turn that has already run its BEFORE daemons and turnUpkeep,
|
||||
// and from prompts raised part-way through a command — --More--,
|
||||
// askOverwrite, getStr, the direction and pack prompts — by which point
|
||||
// the command has mutated state as well. See serviceAutoSaveRequest
|
||||
// (save.go) for the full statement of what the handoff guarantees and
|
||||
// what it costs the player.
|
||||
func (g *RogueGame) readchar() byte {
|
||||
ch := g.scr.term.ReadChar()
|
||||
for {
|
||||
ch, ok := g.scr.term.ReadChar()
|
||||
if !ok {
|
||||
g.serviceAutoSaveRequest()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == 3 { // ^C
|
||||
g.quit(0)
|
||||
|
||||
@@ -174,6 +198,7 @@ func (g *RogueGame) readchar() byte {
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
}
|
||||
|
||||
// statusCache is the set of static shadow variables in io.c status() that
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// Place describes a spot on the level map (rogue.h PLACE).
|
||||
|
||||
+7
-2
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// misc.c — look() display maintenance, direction input, eating, level-ups,
|
||||
@@ -130,7 +131,9 @@ func lookForeignPassage(s *lookScan, fp PlaceFlags, ch byte) bool {
|
||||
|
||||
// lookDiagonalBlocked hides diagonal door/passage squares the hero could
|
||||
// not actually step to (misc.c look).
|
||||
func (g *RogueGame) lookDiagonalBlocked(s *lookScan, fp PlaceFlags, ch byte, y, x int) bool {
|
||||
func (g *RogueGame) lookDiagonalBlocked(
|
||||
s *lookScan, fp PlaceFlags, ch byte, y, x int,
|
||||
) bool {
|
||||
if !fp.Has(FPassage) && ch != Door {
|
||||
return false
|
||||
}
|
||||
@@ -167,7 +170,9 @@ func (g *RogueGame) lookShow(s *lookScan, tp *Monster, ch byte, y, x int) bool {
|
||||
// lookCellChar picks what the square shows: trip rendering for empty
|
||||
// squares, waking and disguises for monsters. skip means the square is
|
||||
// not drawn at all (the monster switch of the look loop).
|
||||
func (g *RogueGame) lookCellChar(s *lookScan, tp *Monster, y, x int, ch byte) (byte, bool) {
|
||||
func (g *RogueGame) lookCellChar(
|
||||
s *lookScan, tp *Monster, y, x int, ch byte,
|
||||
) (byte, bool) {
|
||||
p := &g.Player
|
||||
|
||||
switch {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// monsters.c — monster creation and saving throws.
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// move.c — hero movement commands.
|
||||
@@ -60,7 +61,9 @@ func (g *RogueGame) moveHero(dy, dx int) {
|
||||
// moveResolve acts on the square the hero stepped at: a wall may turn a
|
||||
// passage runner (reported with the new deltas); anything else completes
|
||||
// or refuses the move (the switch of move.c do_move).
|
||||
func (g *RogueGame) moveResolve(nh Coord, ch byte, fl PlaceFlags, dy, dx int) (bool, int, int) {
|
||||
func (g *RogueGame) moveResolve(
|
||||
nh Coord, ch byte, fl PlaceFlags, dy, dx int,
|
||||
) (bool, int, int) {
|
||||
switch ch {
|
||||
case ' ', '|', '-':
|
||||
if turn, ndy, ndx := g.passageTurn(dy, dx); turn {
|
||||
@@ -343,7 +346,8 @@ func (g *RogueGame) trapMystery(Coord) {
|
||||
case 0:
|
||||
g.msg("you are suddenly in a parallel dimension")
|
||||
case 1:
|
||||
g.msg("the light in here suddenly seems %s", g.data.rainbow[g.rnd(len(g.data.rainbow))])
|
||||
g.msg("the light in here suddenly seems %s",
|
||||
g.data.rainbow[g.rnd(len(g.data.rainbow))])
|
||||
case 2:
|
||||
g.msg("you feel a sting in the side of your neck")
|
||||
case 3:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// new_level.c — dig and draw a new level.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -8,7 +9,7 @@ import (
|
||||
func genLevel(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := NewGame(Config{Seed: seed})
|
||||
g := New(Params{Seed: seed})
|
||||
g.NewLevel()
|
||||
|
||||
return g
|
||||
@@ -35,6 +36,8 @@ func renderMap(g *RogueGame) string {
|
||||
}
|
||||
|
||||
func TestNewLevelInvariants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, seed := range []int32{1, 12345, 2026, 99999} {
|
||||
g := genLevel(t, seed)
|
||||
|
||||
@@ -142,6 +145,8 @@ func checkStartingKit(t *testing.T, g *RogueGame, seed int32) {
|
||||
}
|
||||
|
||||
func TestNewLevelDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := renderMap(genLevel(t, 12345))
|
||||
|
||||
b := renderMap(genLevel(t, 12345))
|
||||
@@ -158,8 +163,10 @@ func TestNewLevelDeterministic(t *testing.T) {
|
||||
// TestDeeperLevels exercises generation across many depths and seeds —
|
||||
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
|
||||
func TestDeeperLevels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, seed := range []int32{7, 42, 1000, 31337} {
|
||||
g := NewGame(Config{Seed: seed})
|
||||
g := New(Params{Seed: seed})
|
||||
for depth := 1; depth <= 30; depth++ {
|
||||
g.Depth = depth
|
||||
g.NewLevel()
|
||||
|
||||
@@ -38,6 +38,7 @@ const (
|
||||
|
||||
// Glyph returns the map/display character for this kind of object.
|
||||
func (k ObjectKind) Glyph() byte {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch k {
|
||||
case KindPotion:
|
||||
return Potion
|
||||
@@ -64,6 +65,7 @@ func (k ObjectKind) Glyph() byte {
|
||||
|
||||
// String names the category the way the C type_name() did.
|
||||
func (k ObjectKind) String() string {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch k {
|
||||
case KindPotion:
|
||||
return potionName
|
||||
@@ -118,6 +120,7 @@ func (k ObjectKind) MergesInPack() bool {
|
||||
// stringRest names the remaining kinds, including the ring-or-stick
|
||||
// prompt pseudo-kind (the tail of the C type_name switch).
|
||||
func (k ObjectKind) stringRest() string {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch k {
|
||||
case KindWand:
|
||||
return "wand or staff"
|
||||
@@ -161,6 +164,38 @@ func newObject() *Object {
|
||||
return &Object{Launch: noWeapon}
|
||||
}
|
||||
|
||||
// whichLimit reports how many entries a kind's per-kind tables hold, so
|
||||
// Which is a legal index exactly while 0 <= Which < whichLimit(Kind).
|
||||
// Kinds whose Which is not a table index at all — food (0 ration, 1 the
|
||||
// fruit), the amulet, gold, and the KindNone an unrecognized glyph maps
|
||||
// to — report 0, meaning any value is acceptable, which is what C did
|
||||
// with them too.
|
||||
//
|
||||
// Weapons count one past NumWeaponTypes on purpose: Items.Weapons is
|
||||
// sized NumWeaponTypes+1 for the WeaponFlame dragon-breath entry, and
|
||||
// fireBolt really does set Which = WeaponFlame on a live Object. That
|
||||
// entry has no init_dam[] row, so initWeapon and createObj bound
|
||||
// themselves by NumWeaponTypes instead.
|
||||
func whichLimit(kind ObjectKind) int {
|
||||
//nolint:exhaustive // the remaining kinds do not index a per-kind table
|
||||
switch kind {
|
||||
case KindPotion:
|
||||
return int(NumPotionTypes)
|
||||
case KindScroll:
|
||||
return int(NumScrollTypes)
|
||||
case KindRing:
|
||||
return int(NumRingTypes)
|
||||
case KindWand:
|
||||
return int(NumWandTypes)
|
||||
case KindArmor:
|
||||
return int(NumArmorTypes)
|
||||
case KindWeapon:
|
||||
return int(NumWeaponTypes) + 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// PotionKind returns Which as a potion kind; valid only for KindPotion.
|
||||
func (o *Object) PotionKind() PotionKind { return PotionKind(o.Which) }
|
||||
|
||||
@@ -179,6 +214,36 @@ func (o *Object) WeaponKind() WeaponKind { return WeaponKind(o.Which) }
|
||||
// ArmorKind returns Which as an armor kind; valid only for KindArmor.
|
||||
func (o *Object) ArmorKind() ArmorKind { return ArmorKind(o.Which) }
|
||||
|
||||
// hasValidWhich reports whether Which is a legal index into this
|
||||
// object's per-kind tables. Objects the game builds itself always
|
||||
// satisfy it; the wizard-create command and a restored save file are the
|
||||
// only two ways a malformed one can appear, and both reject it (see
|
||||
// createObj and validateSnapshotObjects).
|
||||
//
|
||||
// The Which >= 0 arm is unreachable from the keyboard: createObj derives
|
||||
// Which with byte arithmetic (int(ch-'a') + 10), which wraps to a large
|
||||
// positive value rather than going negative. It is kept as
|
||||
// defense-in-depth for the non-keyboard source — a decoded save file,
|
||||
// where Which is an int off the wire and can hold anything — and is
|
||||
// exercised there by TestRestoreRejectsOutOfRangeWhich.
|
||||
func (o *Object) hasValidWhich() bool {
|
||||
limit := whichLimit(o.Kind)
|
||||
|
||||
return limit == 0 || (o.Which >= 0 && o.Which < limit)
|
||||
}
|
||||
|
||||
// wizardCanCreate reports whether Which names something the wizard-create
|
||||
// command can actually build. It is hasValidWhich narrowed for weapons:
|
||||
// WeaponFlame owns a name-table slot but no init_dam[] row, so asking for
|
||||
// it would leave initWeapon nothing to copy.
|
||||
func (o *Object) wizardCanCreate() bool {
|
||||
if o.Kind == KindWeapon {
|
||||
return o.Which >= 0 && o.Which < int(NumWeaponTypes)
|
||||
}
|
||||
|
||||
return o.hasValidWhich()
|
||||
}
|
||||
|
||||
// attachObj is list.c attach(): push item onto the front of a list.
|
||||
func attachObj(list *[]*Object, item *Object) {
|
||||
*list = append([]*Object{item}, *list...)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "strings"
|
||||
@@ -118,6 +119,7 @@ func boolStr(b bool) string {
|
||||
// getOpt reads a new value for an option (options.c get_bool/get_sf/
|
||||
// get_inv_t/get_str dispatch).
|
||||
func (g *RogueGame) getOpt(op *optDesc) int {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch op.kind {
|
||||
case optBool:
|
||||
return g.getBool(op.boolP)
|
||||
|
||||
+7
-2
@@ -156,7 +156,9 @@ func packScanWhich(pack []*Object, obj *Object, i, lp int) (int, int) {
|
||||
// anything else marks the insertion point (the matched-subtype switch
|
||||
// of pack.c add_pack). It returns the resulting entry, the insert-after
|
||||
// index, whether a merge happened, and ok false when the pack is full.
|
||||
func (g *RogueGame) packMatch(obj, op *Object, i int, fromFloor bool) (*Object, int, bool, bool) {
|
||||
func (g *RogueGame) packMatch(
|
||||
obj, op *Object, i int, fromFloor bool,
|
||||
) (*Object, int, bool, bool) {
|
||||
switch {
|
||||
case op.Kind.MergesInPack():
|
||||
if !g.packRoom(fromFloor, obj) {
|
||||
@@ -175,7 +177,9 @@ func (g *RogueGame) packMatch(obj, op *Object, i int, fromFloor bool) (*Object,
|
||||
|
||||
// packMatchGroup rejoins a grouped missile bundle with its group entry
|
||||
// (the o_group arm of pack.c add_pack).
|
||||
func (g *RogueGame) packMatchGroup(obj *Object, i int, fromFloor bool) (*Object, int, bool, bool) {
|
||||
func (g *RogueGame) packMatchGroup(
|
||||
obj *Object, i int, fromFloor bool,
|
||||
) (*Object, int, bool, bool) {
|
||||
p := &g.Player
|
||||
|
||||
lp := i
|
||||
@@ -333,6 +337,7 @@ func (g *RogueGame) pickUp(ch byte) {
|
||||
// everything, KindCallable takes anything nameable (not food, not the
|
||||
// amulet), KindRingOrStick takes rings and wands.
|
||||
func matchesFilter(kind ObjectKind, item *Object) bool {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch kind {
|
||||
case KindNone:
|
||||
return true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// passages.c — draw the connecting passages.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
@@ -41,13 +42,16 @@ func (g *RogueGame) quaff() {
|
||||
|
||||
g.leavePack(obj, false, false)
|
||||
|
||||
if h := g.data.quaffHandlers[obj.PotionKind()]; h != nil {
|
||||
if h := g.data.quaffHandler(obj); h != nil {
|
||||
h(g, trip)
|
||||
}
|
||||
|
||||
g.status()
|
||||
// Throw the item away
|
||||
// Throw the item away. A malformed potion has no lore entry to name,
|
||||
// so it is drunk for no effect and never prompts to be called anything.
|
||||
if obj.hasValidWhich() {
|
||||
g.callIt(&g.Items.Potions[obj.Which])
|
||||
}
|
||||
}
|
||||
|
||||
// The per-potion effect handlers, dispatched through
|
||||
@@ -261,9 +265,10 @@ func (g *RogueGame) applyPotionFuse(kind PotionKind, knowit bool) {
|
||||
|
||||
// isMagic reports whether an object radiates magic (potions.c is_magic).
|
||||
func (g *RogueGame) isMagic(o *Object) bool {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch o.Kind {
|
||||
case KindArmor:
|
||||
return o.Flags.Has(Protected) || o.ArmorClass != g.data.aClass[o.Which]
|
||||
return o.Flags.Has(Protected) || o.ArmorClass != g.data.armorClass(o.Which)
|
||||
case KindWeapon:
|
||||
return o.HPlus != 0 || o.DPlus != 0
|
||||
case KindPotion, KindScroll, KindWand, KindRing, KindAmulet:
|
||||
|
||||
@@ -33,6 +33,7 @@ func (g *RogueGame) ringOn() {
|
||||
p.CurRing[ring] = obj
|
||||
|
||||
// Calculate the effect it has on the poor guy.
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingAddStrength:
|
||||
g.changeStrength(obj.Bonus)
|
||||
@@ -171,6 +172,7 @@ func ringNum(_ *RogueGame, obj *Object) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingProtection, RingAddStrength, RingIncreaseDamage, RingDexterity:
|
||||
return fmt.Sprintf(" [%s]", num(obj.Bonus, 0, Ring))
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rings_test.go covers rings.c — ring_on, gethand, ring_off, ring_eat and
|
||||
// ring_num — plus the ring arm of things.c dropcheck (dropRing), which is
|
||||
// what actually takes a worn ring off.
|
||||
//
|
||||
// Every expected value below is transcribed from the C reference on the
|
||||
// origin/c-master branch (rings.c, rogue.h, things.c), not from what this
|
||||
// port happens to return. A test that asserts the current Go behaviour
|
||||
// cannot catch the port drifting away from C, which is the only thing
|
||||
// these tests exist to do.
|
||||
//
|
||||
// The C constants in play (rogue.h 122-123 and 275-289):
|
||||
//
|
||||
// #define LEFT 0 #define RIGHT 1
|
||||
// R_PROTECT 0 R_ADDSTR 1 R_SUSTSTR 2 R_SEARCH 3
|
||||
// R_SEEINVIS 4 R_NOP 5 R_AGGR 6 R_ADDHIT 7
|
||||
// R_ADDDAM 8 R_REGEN 9 R_DIGEST 10 R_TELEPORT 11
|
||||
// R_STEALTH 12 R_SUSTARM 13 MAXRINGS 14
|
||||
//
|
||||
// The Go RingKind iota (types.go 303-316) runs in that same order, so a C
|
||||
// uses[] index and a Go RingKind are the same number. R_ADDHIT is the
|
||||
// dexterity ring (RingDexterity) and R_ADDDAM is RingIncreaseDamage.
|
||||
//
|
||||
// Nothing here can kill the hero — no ring path in rings.c touches HP,
|
||||
// food, or experience — so these tests need no fortify() pinning.
|
||||
|
||||
// C message text, verbatim, in the terse/verbose pairs C picks between.
|
||||
const (
|
||||
cWearingTwo = "you already have a ring on each hand"
|
||||
cWearingTwoTerse = "wearing two"
|
||||
cNotARing = "it would be difficult to wrap that around a finger"
|
||||
cNotARingTerse = "not a ring"
|
||||
cNoRings = "you aren't wearing any rings"
|
||||
cNoRingsTerse = "no rings"
|
||||
cInUse = "That's already in use"
|
||||
cCursed = "you can't. It appears to be cursed"
|
||||
)
|
||||
|
||||
// ringSeed is the fixed seed every test here runs on: nothing in rings.c
|
||||
// depends on the layout, but the RNG stream must be reproducible for the
|
||||
// ring_eat chance rolls.
|
||||
const ringSeed = 5
|
||||
|
||||
// mkRingGame builds a headless game with a clear message line, so that a
|
||||
// leftover mpos cannot turn the next msg() into a --More-- that eats the
|
||||
// scripted keystrokes.
|
||||
func mkRingGame(t *testing.T) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := mkGame(t, ringSeed)
|
||||
g.Msgs.Mpos = 0
|
||||
g.Msgs.Huh = ""
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// handKeys scripts an answer to gethand and appends an abort tail. Without
|
||||
// it, a port that stopped accepting the key under test would reprompt
|
||||
// forever against the headless terminal's filler input, and the test would
|
||||
// die of the 30s timeout instead of failing on its own assertion. The
|
||||
// space acknowledges the reprompt's --More-- and the ESCAPE makes gethand
|
||||
// give up, so the assertion gets to run and say what actually went wrong.
|
||||
// For the same reason, a call that must not prompt at all is scripted with
|
||||
// a lone ESCAPE rather than an empty script.
|
||||
func handKeys(keys ...byte) []byte {
|
||||
return append(keys, ' ', Escape)
|
||||
}
|
||||
|
||||
// mkRing builds a ring of the given kind; bonus is C's o_arm.
|
||||
func mkRing(kind RingKind, bonus int) *Object {
|
||||
obj := newObject()
|
||||
obj.Kind = KindRing
|
||||
obj.Which = int(kind)
|
||||
obj.Bonus = bonus
|
||||
obj.Count = 1
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// wear puts a ring straight onto a hand the way a restored save would,
|
||||
// bypassing ring_on's prompting and effects.
|
||||
func wear(g *RogueGame, hand int, obj *Object) *Object {
|
||||
give(g, obj)
|
||||
g.Player.CurRing[hand] = obj
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func handDesc(obj *Object) string {
|
||||
if obj == nil {
|
||||
return "empty"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ring kind %d", obj.RingKind())
|
||||
}
|
||||
|
||||
// assertHands pins both hands at once, which is what "no state change"
|
||||
// means for every rejection path in ring_on.
|
||||
func assertHands(t *testing.T, g *RogueGame, left, right *Object) {
|
||||
t.Helper()
|
||||
|
||||
if g.Player.CurRing[Left] != left {
|
||||
t.Errorf("left hand = %s, want %s",
|
||||
handDesc(g.Player.CurRing[Left]), handDesc(left))
|
||||
}
|
||||
|
||||
if g.Player.CurRing[Right] != right {
|
||||
t.Errorf("right hand = %s, want %s",
|
||||
handDesc(g.Player.CurRing[Right]), handDesc(right))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnUsesTheHandTheHeroPicks covers the first arm of C's ring_on
|
||||
// hand choice: "if (cur_ring[LEFT] == NULL && cur_ring[RIGHT] == NULL)
|
||||
// { if ((ring = gethand()) < 0) return; }".
|
||||
func TestRingOnUsesTheHandTheHeroPicks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key byte
|
||||
hand int
|
||||
}{
|
||||
{"lower l", 'l', Left},
|
||||
{"upper L", 'L', Left},
|
||||
{"lower r", 'r', Right},
|
||||
{"upper R", 'R', Right},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAdornment, 0)
|
||||
ch := give(g, ring)
|
||||
setInput(t, g, handKeys(ch, tc.key)...)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if tc.hand == Left {
|
||||
assertHands(t, g, ring, nil)
|
||||
} else {
|
||||
assertHands(t, g, nil, ring)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnEscapeFromGethandWearsNothing is the "< 0" half of that arm.
|
||||
func TestRingOnEscapeFromGethandWearsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAdornment, 0)
|
||||
ch := give(g, ring)
|
||||
setInput(t, g, ch, Escape)
|
||||
|
||||
g.ringOn()
|
||||
assertHands(t, g, nil, nil)
|
||||
}
|
||||
|
||||
// TestRingOnTakesTheOnlyFreeHandWithoutAsking covers C's second and third
|
||||
// arms — "else if (cur_ring[LEFT] == NULL) ring = LEFT" and the RIGHT
|
||||
// mirror — which must not prompt. The scripted hand key is deliberately
|
||||
// the wrong hand: a port that asked anyway would consume it and put the
|
||||
// ring on the occupied side's opposite, failing here instead of hanging.
|
||||
func TestRingOnTakesTheOnlyFreeHandWithoutAsking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
worn int
|
||||
free int
|
||||
badKey byte
|
||||
}{
|
||||
{"left already worn", Left, Right, 'l'},
|
||||
{"right already worn", Right, Left, 'r'},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
old := wear(g, tc.worn, mkRing(RingStealth, 0))
|
||||
ring := mkRing(RingAdornment, 0)
|
||||
ch := give(g, ring)
|
||||
setInput(t, g, handKeys(ch, tc.badKey)...)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Player.CurRing[tc.free] != ring {
|
||||
t.Errorf("free hand = %s, want the new ring",
|
||||
handDesc(g.Player.CurRing[tc.free]))
|
||||
}
|
||||
|
||||
if g.Player.CurRing[tc.worn] != old {
|
||||
t.Error("ring_on disturbed the hand that was already worn")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnWithBothHandsFullIsRejected covers C's final else arm. The
|
||||
// trailing ESCAPE is scripted so that a port which wrongly fell through
|
||||
// to gethand() aborts instead of looping on the exhausted script.
|
||||
func TestRingOnWithBothHandsFullIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{"wearing two, verbose", false, cWearingTwo},
|
||||
{"wearing two, terse", true, cWearingTwoTerse},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
g.Options.Terse = tc.terse
|
||||
left := wear(g, Left, mkRing(RingStealth, 0))
|
||||
right := wear(g, Right, mkRing(RingRegeneration, 0))
|
||||
ch := give(g, mkRing(RingAdornment, 0))
|
||||
setInput(t, g, ch, Escape)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Msgs.Huh != tc.want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
|
||||
}
|
||||
|
||||
assertHands(t, g, left, right)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnRejectsANonRing covers C's "if (obj->o_type != RING)" guard.
|
||||
func TestRingOnRejectsANonRing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{"not a ring, verbose", false, cNotARing},
|
||||
{"not a ring, terse", true, cNotARingTerse},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
g.Options.Terse = tc.terse
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
pot.Which = int(PotionHealing)
|
||||
ch := give(g, pot)
|
||||
setInput(t, g, ch, Escape)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Msgs.Huh != tc.want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
|
||||
}
|
||||
|
||||
assertHands(t, g, nil, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnRejectsARingAlreadyWorn covers C's "if (is_current(obj))
|
||||
// return", which sits between the type check and the hand choice.
|
||||
func TestRingOnRejectsARingAlreadyWorn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
worn := wear(g, Left, mkRing(RingStealth, 0))
|
||||
setInput(t, g, worn.PackCh, Escape)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Msgs.Huh != cInUse {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, cInUse)
|
||||
}
|
||||
|
||||
assertHands(t, g, worn, nil)
|
||||
}
|
||||
|
||||
// TestRingOnAddStrengthAndRingOffReverseEachOther pins the R_ADDSTR arm
|
||||
// of ring_on ("case R_ADDSTR: chg_str(obj->o_arm)") against the R_ADDSTR
|
||||
// arm of things.c dropcheck ("chg_str(-obj->o_arm)").
|
||||
func TestRingOnAddStrengthAndRingOffReverseEachOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAddStrength, 2)
|
||||
ch := give(g, ring)
|
||||
base := g.Player.Stats.Str
|
||||
|
||||
setInput(t, g, handKeys(ch, 'l')...)
|
||||
g.ringOn()
|
||||
|
||||
if g.Player.Stats.Str != base+2 {
|
||||
t.Errorf("strength after wearing = %d, want %d",
|
||||
g.Player.Stats.Str, base+2)
|
||||
}
|
||||
|
||||
assertHands(t, g, ring, nil)
|
||||
|
||||
// Only the left hand is worn, so ring_off's "else if (cur_ring[RIGHT]
|
||||
// == NULL) ring = LEFT" arm picks it with no prompt.
|
||||
setInput(t, g, Escape)
|
||||
g.ringOff()
|
||||
|
||||
if g.Player.Stats.Str != base {
|
||||
t.Errorf("strength after removal = %d, want %d",
|
||||
g.Player.Stats.Str, base)
|
||||
}
|
||||
|
||||
assertHands(t, g, nil, nil)
|
||||
}
|
||||
|
||||
// TestRingOnSeeInvisibleAndRingOffUndoIt pins the R_SEEINVIS arms:
|
||||
// invis_on() on the way in, unsee() plus extinguish(unsee) on the way
|
||||
// out. The pending fuse stands in for a potion of see invisible still
|
||||
// running, which is the only way the extinguish is observable.
|
||||
func TestRingOnSeeInvisibleAndRingOffUndoIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingSeeInvisible, 0)
|
||||
ch := give(g, ring)
|
||||
|
||||
setInput(t, g, handKeys(ch, 'r')...)
|
||||
g.ringOn()
|
||||
|
||||
if !g.Player.On(CanSeeInvisible) {
|
||||
t.Error("ring of see invisible did not set CanSeeInvisible")
|
||||
}
|
||||
|
||||
g.Fuse(DUnsee, 0, 100, After)
|
||||
|
||||
setInput(t, g, Escape)
|
||||
g.ringOff()
|
||||
|
||||
if g.Player.On(CanSeeInvisible) {
|
||||
t.Error("taking the ring off left CanSeeInvisible set")
|
||||
}
|
||||
|
||||
if g.findSlot(DUnsee) != nil {
|
||||
t.Error("taking the ring off did not extinguish the unsee fuse")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnAggravateMonstersWakesThem pins the R_AGGR arm, which calls
|
||||
// aggravate() — misc.c walks every monster through runTo, setting ISRUN.
|
||||
func TestRingOnAggravateMonstersWakesThem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
tp := spawnAdjacent(g, 'Z')
|
||||
tp.Flags.Clear(Awake)
|
||||
|
||||
ring := mkRing(RingAggravateMonsters, 0)
|
||||
ch := give(g, ring)
|
||||
|
||||
setInput(t, g, handKeys(ch, 'l')...)
|
||||
g.ringOn()
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("ring of aggravate monsters did not wake the monster")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGethand covers rings.c gethand end to end. The bad-key case needs
|
||||
// the extra space: the reprompt happens with mpos still set from "please
|
||||
// type L or R", so endmsg puts up a --More-- that wait_for absorbs.
|
||||
func TestGethand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input []byte
|
||||
want int
|
||||
}{
|
||||
{"l", []byte{'l'}, Left},
|
||||
{"L", []byte{'L'}, Left},
|
||||
{"r", []byte{'r'}, Right},
|
||||
{"R", []byte{'R'}, Right},
|
||||
{"escape aborts", []byte{Escape}, -1},
|
||||
{"bad key reprompts", []byte{'x', ' ', 'r'}, Right},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
setInput(t, g, handKeys(tc.input...)...)
|
||||
|
||||
if got := g.gethand(); got != tc.want {
|
||||
t.Errorf("gethand() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOffWithNoRingsSaysSo covers ring_off's first arm.
|
||||
func TestRingOffWithNoRingsSaysSo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{"no rings, verbose", false, cNoRings},
|
||||
{"no rings, terse", true, cNoRingsTerse},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
g.Options.Terse = tc.terse
|
||||
setInput(t, g, Escape)
|
||||
|
||||
g.ringOff()
|
||||
|
||||
if g.Msgs.Huh != tc.want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOffWithBothHandsWornAsksWhich covers ring_off's else arm, both
|
||||
// the answer and the "(ring = gethand()) < 0" abort.
|
||||
func TestRingOffWithBothHandsWornAsksWhich(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key byte
|
||||
gone int
|
||||
stays int
|
||||
}{
|
||||
{"takes off the left", 'l', Left, Right},
|
||||
{"takes off the right", 'r', Right, Left},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
rings := [2]*Object{
|
||||
Left: wear(g, Left, mkRing(RingStealth, 0)),
|
||||
Right: wear(g, Right, mkRing(RingRegeneration, 0)),
|
||||
}
|
||||
setInput(t, g, handKeys(tc.key)...)
|
||||
|
||||
g.ringOff()
|
||||
|
||||
if g.Player.CurRing[tc.gone] != nil {
|
||||
t.Errorf("chosen hand still holds %s",
|
||||
handDesc(g.Player.CurRing[tc.gone]))
|
||||
}
|
||||
|
||||
if g.Player.CurRing[tc.stays] != rings[tc.stays] {
|
||||
t.Error("ring_off cleared the hand that was not chosen")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOffEscapeKeepsBothRings is the abort half of that arm.
|
||||
func TestRingOffEscapeKeepsBothRings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
left := wear(g, Left, mkRing(RingStealth, 0))
|
||||
right := wear(g, Right, mkRing(RingRegeneration, 0))
|
||||
setInput(t, g, Escape)
|
||||
|
||||
g.ringOff()
|
||||
assertHands(t, g, left, right)
|
||||
}
|
||||
|
||||
// TestRingOffCursedRingStaysOn covers the dropcheck gate ring_off runs
|
||||
// its removal through: things.c returns FALSE for an ISCURSED item after
|
||||
// printing this message, and the hand is left alone.
|
||||
func TestRingOffCursedRingStaysOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAddStrength, -1)
|
||||
ring.Flags.Set(Cursed)
|
||||
wear(g, Left, ring)
|
||||
setInput(t, g, Escape)
|
||||
|
||||
g.ringOff()
|
||||
|
||||
if g.Msgs.Huh != cCursed {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, cCursed)
|
||||
}
|
||||
|
||||
assertHands(t, g, ring, nil)
|
||||
}
|
||||
|
||||
// cRingUse is one entry of the rings.c ring_eat uses[] table.
|
||||
type cRingUse struct {
|
||||
kind RingKind
|
||||
name string // the C R_ name, for failure messages
|
||||
uses int
|
||||
}
|
||||
|
||||
// cRingUses transcribes ring_eat's static uses[] verbatim:
|
||||
//
|
||||
// static int uses[] = {
|
||||
// 1, /* R_PROTECT */ 1, /* R_ADDSTR */
|
||||
// 1, /* R_SUSTSTR */ -3, /* R_SEARCH */
|
||||
// -5, /* R_SEEINVIS */ 0, /* R_NOP */
|
||||
// 0, /* R_AGGR */ -3, /* R_ADDHIT */
|
||||
// -3, /* R_ADDDAM */ 2, /* R_REGEN */
|
||||
// -2, /* R_DIGEST */ 0, /* R_TELEPORT */
|
||||
// 1, /* R_STEALTH */ 1 /* R_SUSTARM */
|
||||
// };
|
||||
//
|
||||
// A negative entry is not a cost: C computes eat = (rnd(-eat) == 0), a
|
||||
// one-in-n chance of a single unit. R_DIGEST then flips the sign, so slow
|
||||
// digestion returns 0 or -1 and is the only ring that gives food back.
|
||||
func cRingUses() []cRingUse {
|
||||
return []cRingUse{
|
||||
{RingProtection, "R_PROTECT", 1},
|
||||
{RingAddStrength, "R_ADDSTR", 1},
|
||||
{RingSustainStrength, "R_SUSTSTR", 1},
|
||||
{RingSearching, "R_SEARCH", -3},
|
||||
{RingSeeInvisible, "R_SEEINVIS", -5},
|
||||
{RingAdornment, "R_NOP", 0},
|
||||
{RingAggravateMonsters, "R_AGGR", 0},
|
||||
{RingDexterity, "R_ADDHIT", -3},
|
||||
{RingIncreaseDamage, "R_ADDDAM", -3},
|
||||
{RingRegeneration, "R_REGEN", 2},
|
||||
{RingSlowDigestion, "R_DIGEST", -2},
|
||||
{RingTeleportation, "R_TELEPORT", 0},
|
||||
{RingStealth, "R_STEALTH", 1},
|
||||
{RingMaintainArmor, "R_SUSTARM", 1},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingEatMatchesTheCUsesTable exercises all fourteen ring kinds, both
|
||||
// hands, against the C table above. This is the highest-value assertion in
|
||||
// the file: ring_eat feeds the hunger clock through daemons.c, so a wrong
|
||||
// entry is a silent, slow divergence from C that no other test would see.
|
||||
func TestRingEatMatchesTheCUsesTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range cRingUses() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, hand := range []int{Left, Right} {
|
||||
g := mkRingGame(t)
|
||||
g.Player.CurRing[hand] = mkRing(tc.kind, 0)
|
||||
|
||||
if tc.uses >= 0 {
|
||||
assertFixedRingEat(t, g, hand, tc)
|
||||
} else {
|
||||
assertChanceRingEat(t, g, hand, tc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertFixedRingEat checks a non-negative uses[] entry. C returns it
|
||||
// unchanged and, just as importantly, never reaches rnd() on that path —
|
||||
// so the generator must be untouched, or the whole game's RNG stream
|
||||
// desynchronises from C's and seed compatibility is gone.
|
||||
func assertFixedRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
|
||||
t.Helper()
|
||||
|
||||
for range 4 {
|
||||
before := *g.Rng
|
||||
|
||||
if got := g.ringEat(hand); got != tc.uses {
|
||||
t.Fatalf("ringEat(%d) for %s = %d, want C uses[] entry %d",
|
||||
hand, tc.name, got, tc.uses)
|
||||
}
|
||||
|
||||
if *g.Rng != before {
|
||||
t.Fatalf("ringEat for %s called rnd(); C only does that for a "+
|
||||
"negative uses[] entry", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertChanceRingEat checks a negative uses[] entry. Each call is replayed
|
||||
// against C's own expression from the identical generator state, which pins
|
||||
// the one-in-n denominator, the sign flip R_DIGEST gets, and the fact that
|
||||
// exactly one rnd() call is spent. The frequency check on top of that
|
||||
// fails loudly on a wrong denominator even if the replay were ever
|
||||
// weakened to agree with the code by construction.
|
||||
func assertChanceRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
|
||||
t.Helper()
|
||||
|
||||
const trials = 4000
|
||||
|
||||
sign := 1
|
||||
if tc.kind == RingSlowDigestion {
|
||||
sign = -1 // rings.c: if (ring->o_which == R_DIGEST) eat = -eat
|
||||
}
|
||||
|
||||
nonzero := 0
|
||||
|
||||
for range trials {
|
||||
before := *g.Rng
|
||||
got := g.ringEat(hand)
|
||||
after := *g.Rng
|
||||
|
||||
// C: eat = (rnd(-eat) == 0), replayed from the same state.
|
||||
*g.Rng = before
|
||||
|
||||
want := 0
|
||||
if g.Rng.Rnd(-tc.uses) == 0 {
|
||||
want = 1
|
||||
}
|
||||
|
||||
want *= sign
|
||||
|
||||
if *g.Rng != after {
|
||||
t.Fatalf("ringEat for %s did not spend exactly one rnd(%d) call",
|
||||
tc.name, -tc.uses)
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("ringEat for %s = %d, want %d", tc.name, got, want)
|
||||
}
|
||||
|
||||
if want != 0 {
|
||||
nonzero++
|
||||
}
|
||||
}
|
||||
|
||||
assertOneInN(t, tc, nonzero, trials)
|
||||
}
|
||||
|
||||
// assertOneInN checks the observed rate against C's 1/n. The tolerance is
|
||||
// far tighter than the gap between the three denominators C uses (1/2,
|
||||
// 1/3, 1/5) and far wider than the sampling noise at this trial count.
|
||||
func assertOneInN(t *testing.T, tc cRingUse, nonzero, trials int) {
|
||||
t.Helper()
|
||||
|
||||
const tolerance = 0.03
|
||||
|
||||
rate := float64(nonzero) / float64(trials)
|
||||
want := 1 / float64(-tc.uses)
|
||||
|
||||
if math.Abs(rate-want) > tolerance {
|
||||
t.Errorf("%s fired %.3f of the time over %d trials, want ~%.3f "+
|
||||
"(C's one-in-%d)", tc.name, rate, trials, want, -tc.uses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingEatEmptyHandIsZero is C's "if ((ring = cur_ring[hand]) == NULL)
|
||||
// return 0" — the common case, since the hero usually wears nothing.
|
||||
func TestRingEatEmptyHandIsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
before := *g.Rng
|
||||
|
||||
for _, hand := range []int{Left, Right} {
|
||||
if got := g.ringEat(hand); got != 0 {
|
||||
t.Errorf("ringEat(%d) with an empty hand = %d, want 0", hand, got)
|
||||
}
|
||||
}
|
||||
|
||||
if *g.Rng != before {
|
||||
t.Error("ringEat on an empty hand consumed RNG")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingNum covers rings.c ring_num. Its switch ends in the `otherwise`
|
||||
// macro, which rogue.h 53 defines as `break;default` — so the four labels
|
||||
// R_PROTECT, R_ADDSTR, R_ADDDAM and R_ADDHIT fall through to a single
|
||||
// sprintf(" [%s]", num(o_arm, 0, RING)) and every other kind returns ""
|
||||
// from the default arm before the buffer is ever reached. Unknown rings
|
||||
// return "" earlier still, from the ISKNOW guard.
|
||||
//
|
||||
// The game pointer is C's implicit global state; ring_num reads none of
|
||||
// it, and the port's signature only carries one to satisfy nameit's
|
||||
// prfunc type, so nil is the honest argument here.
|
||||
func TestRingNum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kind RingKind
|
||||
bonus int
|
||||
known bool
|
||||
want string
|
||||
}{
|
||||
{"R_PROTECT known", RingProtection, 2, true, " [+2]"},
|
||||
{"R_ADDSTR known", RingAddStrength, 1, true, " [+1]"},
|
||||
{"R_ADDDAM known", RingIncreaseDamage, -1, true, " [-1]"},
|
||||
{"R_ADDHIT known", RingDexterity, 3, true, " [+3]"},
|
||||
{"R_PROTECT cursed", RingProtection, -1, true, " [-1]"},
|
||||
{"R_ADDSTR unknown", RingAddStrength, 2, false, ""},
|
||||
{"R_SEARCH known", RingSearching, 2, true, ""},
|
||||
{"R_DIGEST known", RingSlowDigestion, 2, true, ""},
|
||||
{"R_NOP known", RingAdornment, 0, true, ""},
|
||||
{"R_SUSTARM known", RingMaintainArmor, 2, true, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
obj := mkRing(tc.kind, tc.bonus)
|
||||
if tc.known {
|
||||
obj.Flags.Set(Known)
|
||||
}
|
||||
|
||||
if got := ringNum(nil, obj); got != tc.want {
|
||||
t.Errorf("ringNum() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage of the fourteen ring kinds, for the record:
|
||||
//
|
||||
// All fourteen are exercised by TestRingEatMatchesTheCUsesTable and ten of
|
||||
// them by TestRingNum. Beyond that, only three kinds have a ring_on effect
|
||||
// at all — R_ADDSTR, R_SEEINVIS and R_AGGR — and each has its own test
|
||||
// above, paired with the dropcheck arm that undoes it. The remaining
|
||||
// eleven are deliberately not given a wear/remove test: in C they are
|
||||
// inert at wear time, their powers being read from ISWEARING() elsewhere
|
||||
// (R_SEARCH and R_TELEPORT in the command.c per-turn tail, R_PROTECT and
|
||||
// R_ADDHIT/R_ADDDAM in fight.c, R_REGEN and R_DIGEST in daemons.c,
|
||||
// R_SUSTSTR and R_SUSTARM in the drain paths, R_STEALTH in chase.c), so a
|
||||
// wear/remove assertion for them would test nothing that rings.c does.
|
||||
// Those call sites belong to their own files' tests, not to this one.
|
||||
//
|
||||
// One branch is intentionally unreachable rather than untested: ring_off's
|
||||
// "obj == NULL -> not wearing such a ring" cannot fire, because every arm
|
||||
// that reaches it has already established that the chosen hand is worn.
|
||||
// The port keeps C's defensive check; there is no state from which to
|
||||
// provoke it.
|
||||
+23
-24
@@ -1,24 +1,22 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rip.c — the fun ends: death or a total win.
|
||||
//
|
||||
// The C functions here call exit(); the port panics with a gameEnd sentinel
|
||||
// that Run recovers, so the terminal is restored by normal unwinding.
|
||||
// The C functions here call exit(). One game run is one process, so the
|
||||
// port does the same: myExit restores the terminal and exits directly.
|
||||
|
||||
// gameEnd is the sentinel carried by the panic that replaces my_exit().
|
||||
type gameEnd struct{}
|
||||
|
||||
// myExit leaves the process properly (main.c my_exit): it unwinds to Run.
|
||||
// Every C caller exited with status 0; abnormal exits panic for real.
|
||||
// myExit leaves the process properly (main.c my_exit): it restores the
|
||||
// terminal and ends the process. Every C caller exited with status 0.
|
||||
func (g *RogueGame) myExit() {
|
||||
g.Playing = false
|
||||
|
||||
panic(gameEnd{})
|
||||
g.scr.Fini()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// death does something really fun when he dies (rip.c death).
|
||||
@@ -101,8 +99,10 @@ func (g *RogueGame) totalWinner() {
|
||||
}
|
||||
|
||||
g.standend()
|
||||
g.scr.Std.MvAddStr(10, 0, "You have joined the elite ranks of those who have escaped the")
|
||||
g.scr.Std.MvAddStr(11, 0, "Dungeons of Doom alive. You journey home and sell all your loot at")
|
||||
g.scr.Std.MvAddStr(10, 0,
|
||||
"You have joined the elite ranks of those who have escaped the")
|
||||
g.scr.Std.MvAddStr(11, 0,
|
||||
"Dungeons of Doom alive. You journey home and sell all your loot at")
|
||||
g.scr.Std.MvAddStr(12, 0, "a great profit and are admitted to the Fighters' Guild.")
|
||||
g.mvaddstr(NumLines-1, 0, "--Press space to continue--")
|
||||
g.refresh()
|
||||
@@ -132,9 +132,17 @@ func (g *RogueGame) totalWinner() {
|
||||
// objectWorth appraises one pack item on the way out, marking it known
|
||||
// (the switch of rip.c total_winner).
|
||||
func (g *RogueGame) objectWorth(obj *Object) int {
|
||||
// Same defense as inventoryName: most arms below appraise from a
|
||||
// per-kind table at obj.Which, so a malformed item is worth nothing
|
||||
// rather than a panic on the way to the scoreboard.
|
||||
if !obj.hasValidWhich() {
|
||||
return 0
|
||||
}
|
||||
|
||||
it := &g.Items
|
||||
worth := 0
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindFood:
|
||||
worth = 2 * obj.Count
|
||||
@@ -145,7 +153,7 @@ func (g *RogueGame) objectWorth(obj *Object) int {
|
||||
case KindArmor:
|
||||
worth = it.Armors[obj.Which].Worth
|
||||
worth += (9 - obj.ArmorClass) * 100
|
||||
worth += 10 * (g.data.aClass[obj.Which] - obj.ArmorClass)
|
||||
worth += 10 * (g.data.armorClass(obj.Which) - obj.ArmorClass)
|
||||
obj.Flags.Set(Known)
|
||||
case KindScroll:
|
||||
worth = loreWorth(&it.Scrolls[obj.Which], obj.Count)
|
||||
@@ -255,18 +263,9 @@ func (g *RogueGame) killname(monst byte, doart bool) string {
|
||||
}
|
||||
|
||||
// DeathDemo implements the -d command line option (main.c): burn some
|
||||
// random numbers to break patterns, then die a random death.
|
||||
// random numbers to break patterns, then die a random death. It does not
|
||||
// return — death exits the process.
|
||||
func (g *RogueGame) DeathDemo() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(gameEnd); ok {
|
||||
return
|
||||
}
|
||||
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
dnum := g.rnd(100)
|
||||
for dnum--; dnum > 0; dnum-- {
|
||||
g.rnd(100)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// Rng is the original Rogue linear congruential generator. The C RN macro is
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
@@ -6,6 +7,8 @@ import "testing"
|
||||
// (seed = seed*11109+13849; rnd(range) = abs(RN) % range) compiled and run
|
||||
// on this machine. They lock in seed compatibility with the C game.
|
||||
func TestRndMatchesCImplementation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
golden := map[int32][]int{
|
||||
1: {0, 30, 79, 7, 87, 1, 23, 7, 57, 98},
|
||||
12345: {92, 92, 45, 98, 24, 39, 92, 67, 3, 7},
|
||||
@@ -23,6 +26,8 @@ func TestRndMatchesCImplementation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRollMatchesCImplementation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
golden := map[int32][]int{
|
||||
1: {6, 8, 10},
|
||||
12345: {10, 10, 15},
|
||||
@@ -42,6 +47,8 @@ func TestRollMatchesCImplementation(t *testing.T) {
|
||||
// rnd(0) must return 0 without stepping the generator: the C macro
|
||||
// short-circuits before evaluating RN, and BEFORE/AFTER depend on it.
|
||||
func TestRndZeroDoesNotStep(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := &Rng{Seed: 42}
|
||||
if got := r.Rnd(0); got != 0 {
|
||||
t.Fatalf("rnd(0) = %d, want 0", got)
|
||||
@@ -55,6 +62,8 @@ func TestRndZeroDoesNotStep(t *testing.T) {
|
||||
// spread(1)==1 and spread(2)==2 deterministically; the C BEFORE/AFTER
|
||||
// constants rely on this.
|
||||
func TestSpreadSmallValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := &RogueGame{Rng: &Rng{Seed: 7}}
|
||||
if got := g.spread(1); got != 1 {
|
||||
t.Errorf("spread(1) = %d, want 1", got)
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// rooms.c — create the layout for the new level.
|
||||
@@ -330,7 +331,9 @@ func floorChar(rp *Room) byte {
|
||||
return Floor
|
||||
}
|
||||
|
||||
func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Coord, bool) {
|
||||
func (g *RogueGame) findFloorImpl(
|
||||
rp *Room, limit int, monst, pickroom bool,
|
||||
) (Coord, bool) {
|
||||
var compchar byte
|
||||
if !pickroom {
|
||||
compchar = floorChar(rp)
|
||||
|
||||
+168
-83
@@ -1,27 +1,79 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRunScriptedSession drives a complete game through Run(): a few
|
||||
// moves, a rest, an inventory, then Q-quit answered yes.
|
||||
func TestRunScriptedSession(t *testing.T) {
|
||||
tt := &testTerm{input: []byte("hjkl.i Qy")}
|
||||
// fortify makes the hero effectively immortal for a crash-sweep drive:
|
||||
// game-over now calls myExit and os.Exit(0) (step 8), which would kill the
|
||||
// test binary, so every death vector is neutralized. Re-applied each turn
|
||||
// because combat, digestion, freezing, and level drain chip away at these.
|
||||
func fortify(g *RogueGame) {
|
||||
p := &g.Player
|
||||
p.Stats.HP = 30000 // survive combat, arrow/dart traps, bolts
|
||||
p.Stats.MaxHP = 30000 // survive vampire max-hp drain
|
||||
p.Stats.Exp = 30000 // survive wraith level drain (death when exp hits 0)
|
||||
p.FoodLeft = 30000 // never starve
|
||||
g.NoCommand = 0 // never freeze to death (ice monster / sleep trap)
|
||||
g.NoMove = 0 // never stay stuck in a bear trap
|
||||
}
|
||||
|
||||
g := NewGame(Config{Seed: 99, Term: tt})
|
||||
// driveTurns runs the game's per-turn loop up to n times, doing the same
|
||||
// first-level and pre-play setup Run() does. Run() itself no longer
|
||||
// returns — game-over exits the process — so tests drive command()
|
||||
// directly, with short scripts that avoid quitting, saving, or playing
|
||||
// long enough to starve, any of which would exit the test binary.
|
||||
func driveTurns(t *testing.T, g *RogueGame, n int) {
|
||||
t.Helper()
|
||||
|
||||
err := g.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
for range n {
|
||||
g.command()
|
||||
}
|
||||
}
|
||||
|
||||
if g.Playing {
|
||||
t.Error("still playing after quit")
|
||||
// TestRunDownStairs stands the hero on the staircase and descends via the
|
||||
// '>' command through the real turn loop, then checks the level changed.
|
||||
func TestRunDownStairs(t *testing.T) {
|
||||
t.Parallel()
|
||||
// '>' is a free action (After=false), so it is followed by a paying
|
||||
// rest ('.') to end the command() call; without a paying action the
|
||||
// turn loop would spin forever on the auto-fed prompt input.
|
||||
tt := &testTerm{input: []byte(">.")}
|
||||
g := New(Params{Seed: 7, Term: tt})
|
||||
g.NewLevel()
|
||||
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
||||
g.restored = true // keep startLevel from regenerating
|
||||
g.Daemons = DaemonList{} // and give it a fresh daemon table
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
|
||||
driveTurns(t, g, 1)
|
||||
|
||||
if g.Depth != 2 {
|
||||
t.Errorf("depth = %d after descending, want 2", g.Depth)
|
||||
}
|
||||
// After quitting, the scoreboard is the last thing shown (in C it went
|
||||
// to stdout after endwin; here it is drawn on the screen).
|
||||
}
|
||||
|
||||
// TestScoreRendersList checks that the scoreboard is drawn on the screen
|
||||
// (in C it went to stdout after endwin; here it stays on the screen). The
|
||||
// quit and death paths that normally show it now exit the process, so the
|
||||
// display is exercised through score() directly.
|
||||
func TestScoreRendersList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 1, Term: &testTerm{}})
|
||||
g.Player.Purse = 100
|
||||
|
||||
g.score(g.Player.Purse, 1, 0) // flags 1 = quit; posts the top-ten list
|
||||
|
||||
found := false
|
||||
|
||||
for y := range NumLines {
|
||||
@@ -31,96 +83,129 @@ func TestRunScriptedSession(t *testing.T) {
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("score list not on screen after quit")
|
||||
t.Error("score list not on screen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunManyTurns mashes movement keys for a while as a crash sweep of
|
||||
// the whole turn loop (daemons, hunger, monsters, combat), ending with a
|
||||
// quit. The input alternates directions so the hero bumps around rooms.
|
||||
func TestRunManyTurns(t *testing.T) {
|
||||
// Spaces between commands double as answers to any --More-- prompts;
|
||||
// without them a single prompt would swallow the rest of the script
|
||||
// (wait_for eats everything that isn't a space).
|
||||
moves := []byte("h j k l y u b n s .")
|
||||
script := make([]byte, 0, len(moves)*200+7)
|
||||
// TestDeepPlaythrough drives a fortified hero through the real command loop:
|
||||
// quaff/read/zap on the first level, then descend through the staircase to
|
||||
// depth 8, saving and restoring mid-way. It is a crash sweep of the turn
|
||||
// engine, deep level generation, item effects, and mid-game save/restore.
|
||||
// The hero is fortified so no death exits the process (step 8), and the fixed
|
||||
// seed keeps it deterministic.
|
||||
func TestDeepPlaythrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 4242, Wizard: true, Term: &testTerm{}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
fortify(g)
|
||||
|
||||
// Stock and use one of each consumable through the command dispatch.
|
||||
pot := give(g, &Object{Kind: KindPotion, Which: int(PotionHealing)})
|
||||
scr := give(g, &Object{Kind: KindScroll, Which: int(ScrollMagicMapping)})
|
||||
|
||||
wand := newObject()
|
||||
wand.Kind = KindWand
|
||||
wand.Which = int(WandLight)
|
||||
wand.Charges = 5
|
||||
zap := give(g, wand)
|
||||
|
||||
setInput(t, g, 'q', pot) // quaff healing
|
||||
g.command()
|
||||
setInput(t, g, 'r', scr) // read magic mapping
|
||||
g.command()
|
||||
setInput(t, g, 'z', 'h', zap) // zap the light wand west
|
||||
g.command()
|
||||
fortify(g)
|
||||
|
||||
// Each consumable identifies itself on use, confirming the q/r/z
|
||||
// commands actually ran through dispatch (not aborted on a bad prompt).
|
||||
if !g.Items.Potions[PotionHealing].Know {
|
||||
t.Error("quaff command did not identify the healing potion")
|
||||
}
|
||||
|
||||
if !g.Items.Scrolls[ScrollMagicMapping].Know {
|
||||
t.Error("read command did not identify the magic-mapping scroll")
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandLight].Know {
|
||||
t.Error("zap command did not identify the light wand")
|
||||
}
|
||||
|
||||
const wantDepth = 8
|
||||
|
||||
for g.Depth < wantDepth {
|
||||
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
||||
setInput(t, g, '>', '.') // '>' descends (free), '.' pays the turn
|
||||
g.command()
|
||||
fortify(g)
|
||||
|
||||
if g.Depth == 4 {
|
||||
g = saveAndRestore(t, g)
|
||||
fortify(g)
|
||||
}
|
||||
}
|
||||
|
||||
if g.Depth != wantDepth {
|
||||
t.Errorf("depth = %d after descending, want %d", g.Depth, wantDepth)
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP <= 0 {
|
||||
t.Error("hero died during the playthrough")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnLoopCrashSweep mashes movement, search, and rest through the real
|
||||
// turn loop for many turns on several seeds, exercising combat, monster AI,
|
||||
// and traps. The hero is fortified each turn so nothing exits the process,
|
||||
// and the fixed seeds keep it deterministic; the point is to surface panics.
|
||||
func TestTurnLoopCrashSweep(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A generous mix of movement, search, and rest. The spaces between
|
||||
// commands double as answers to any --More-- prompt (wait_for eats
|
||||
// everything up to a space); without them one prompt would swallow the
|
||||
// rest of the script. The script is long enough that the bounded drive
|
||||
// never exhausts it (which would spin on the auto-fed prompt input).
|
||||
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
|
||||
|
||||
for _, seed := range []int32{1, 99, 2026, 31337} {
|
||||
g := New(Params{Seed: seed, Term: &testTerm{input: script}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
for range 200 {
|
||||
script = append(script, moves...)
|
||||
fortify(g)
|
||||
g.command()
|
||||
}
|
||||
|
||||
script = append(script, " Q y Qy"...)
|
||||
tt := &testTerm{input: script}
|
||||
|
||||
g := NewGame(Config{Seed: 31337, Term: tt})
|
||||
|
||||
err := g.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
if g.Player.Stats.HP <= 0 {
|
||||
t.Errorf("seed %d: hero died despite fortify", seed)
|
||||
}
|
||||
|
||||
if g.Playing {
|
||||
t.Error("session did not end")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDownStairs walks the hero onto the stairs by teleporting there in
|
||||
// wizard style, then descends and keeps playing.
|
||||
func TestRunDownStairs(t *testing.T) {
|
||||
tt := &testTerm{input: []byte(">..Qy")}
|
||||
g := NewGame(Config{Seed: 7, Term: tt})
|
||||
g.NewLevel()
|
||||
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
||||
g.restored = true // keep Run from regenerating the level
|
||||
g.Daemons = DaemonList{} // and give it a fresh daemon table
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
// saveAndRestore snapshots the game to a file, restores it, checks the key
|
||||
// state survived, and returns the restored game ready to keep playing.
|
||||
func saveAndRestore(t *testing.T, g *RogueGame) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
err := g.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
path := filepath.Join(t.TempDir(), "deep.save")
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr != nil {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
if g.Depth != 2 {
|
||||
t.Errorf("depth = %d after descending, want 2", g.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveCommandRoundTrip saves via the 'S' command (as a player would)
|
||||
// and restores the game.
|
||||
func TestSaveCommandRoundTrip(t *testing.T) {
|
||||
// The C get_str caps input at MAXINP=50 characters, so the save path
|
||||
// must be short: work from the temp directory.
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
path := "cmd.save"
|
||||
// 'S' with no default file name goes straight to the name prompt.
|
||||
script := "S" + path + "\n"
|
||||
tt := &testTerm{input: []byte(script)}
|
||||
g := NewGame(Config{Seed: 55, Term: tt})
|
||||
|
||||
g.FileName = "" // force the name prompt
|
||||
|
||||
runErr := g.Run()
|
||||
if runErr != nil {
|
||||
t.Fatalf("Run: %v", runErr)
|
||||
}
|
||||
|
||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
||||
h, err := Restore(path, Params{Wizard: true, Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
|
||||
t.Error("restored game does not match saved game")
|
||||
t.Errorf("restored game diverged: depth %d/%d purse %d/%d",
|
||||
h.Depth, g.Depth, h.Player.Purse, g.Player.Purse)
|
||||
}
|
||||
// The restored game must be playable.
|
||||
setInput(t, h, []byte("..Qy")...)
|
||||
|
||||
restoredErr := h.Run()
|
||||
if restoredErr != nil {
|
||||
t.Fatalf("restored Run: %v", restoredErr)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
+232
-21
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -5,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
|
||||
@@ -642,50 +645,252 @@ func (g *RogueGame) askOverwrite() saveAnswer {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// saveFile writes the saved game (save.c save_file).
|
||||
//
|
||||
// The snapshot goes to a temporary file in the target's own directory and
|
||||
// is renamed over the target, so there is no instant at which the player
|
||||
// has no save file: until the rename the old file is whole, and after it
|
||||
// the new one is. C wrote straight over the target, and this port did the
|
||||
// same with a remove in front of it (AutoSave), so a write that failed —
|
||||
// or a signal-time save cut short by the process dying — could leave the
|
||||
// player with neither the old save nor a usable new one (issue #24).
|
||||
//
|
||||
// The temporary file is fsynced before the rename so its contents reach
|
||||
// the disk ahead of the directory entry that will point at it. The
|
||||
// directory itself is not fsynced: that would only matter for a machine
|
||||
// that loses power in the same instant, and the old save survives that
|
||||
// case anyway. A process killed mid-encode leaves its temporary file
|
||||
// behind, which is litter next to a destroyed save file, and the dot
|
||||
// prefix keeps it out of the way.
|
||||
func (g *RogueGame) saveFile(path string) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encErr := gob.NewEncoder(f).Encode(g.snapshot())
|
||||
closeErr := f.Close()
|
||||
tmp := f.Name()
|
||||
|
||||
if encErr != nil || closeErr != nil {
|
||||
_ = os.Remove(path) // don't leave a corrupt save behind
|
||||
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()
|
||||
|
||||
if encErr != nil {
|
||||
return encErr
|
||||
}
|
||||
|
||||
return closeErr
|
||||
}
|
||||
|
||||
return os.Chmod(path, 0o400)
|
||||
}
|
||||
|
||||
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
|
||||
// (save.c auto_save). Best effort by design: it runs on the way out of a
|
||||
// dying process.
|
||||
func (g *RogueGame) AutoSave() {
|
||||
if g.FileName != "" {
|
||||
_ = os.Remove(g.FileName)
|
||||
_ = g.saveFile(g.FileName)
|
||||
// autoSaveRequest is one signal-triggered autosave in flight: the signal
|
||||
// goroutine posts it and waits, the game goroutine performs the save and
|
||||
// closes done. ok is written before done is closed and read only after,
|
||||
// so the close is the happens-before edge that publishes it.
|
||||
type autoSaveRequest struct {
|
||||
done chan struct{}
|
||||
ok bool
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal asks the game goroutine to autosave and waits up to
|
||||
// timeout for it to finish, reporting whether the save actually ran
|
||||
// (save.c auto_save, the SIGHUP/SIGTERM handler). It is the only entry
|
||||
// point the signal goroutine may use, and it deliberately touches no game
|
||||
// state: the gob encoder used to walk the live game tree from the signal
|
||||
// goroutine while the game goroutine was mid-turn mutating it (issue
|
||||
// #24).
|
||||
//
|
||||
// Blocked on input is the case that matters, since a dropped connection
|
||||
// is the whole reason the handler exists: the request is posted first and
|
||||
// the input read is then interrupted, so a game goroutine parked in
|
||||
// ReadChar wakes, saves in readchar, and reads again. A game goroutine
|
||||
// that is running turns instead picks the request up between turns, in
|
||||
// command; one parked in the `!` shell escape picks it up in
|
||||
// runShellEscape.
|
||||
//
|
||||
// The wait is bounded because the signal goroutine's job is to get the
|
||||
// process out. If the game goroutine is somewhere with no service point
|
||||
// at all, the deadline expires, this reports false, and the caller
|
||||
// restores the terminal and exits — leaving the player's previous save
|
||||
// file exactly as it was, which is the point of the rename in saveFile.
|
||||
func (g *RogueGame) AutoSaveOnSignal(timeout time.Duration) bool {
|
||||
req := &autoSaveRequest{done: make(chan struct{})}
|
||||
|
||||
select {
|
||||
case g.sigSave <- req:
|
||||
default:
|
||||
// A request is already queued and unserviced, or there is no
|
||||
// game loop to service one; either way this one would not be
|
||||
// answered either.
|
||||
return false
|
||||
}
|
||||
|
||||
g.scr.Interrupt()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-req.done:
|
||||
return req.ok
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// serviceAutoSaveRequest performs a pending signal-triggered autosave, if
|
||||
// one is waiting, and otherwise returns at once. It runs on the game
|
||||
// goroutine — that is the whole design — so it must only be called where
|
||||
// that goroutine is not itself inside the encode: between turns, or while
|
||||
// parked waiting for input or for the shell escape.
|
||||
//
|
||||
// What is guaranteed, exactly: the encode runs on the one goroutine that
|
||||
// owns the state, so the snapshot is internally consistent and always
|
||||
// restorable. It is *not* guaranteed to be a between-commands snapshot.
|
||||
// Only one of the three service points gives that: the check at the top
|
||||
// of command, which runs after the previous command returned and before
|
||||
// this turn's DoDaemons(Before)/DoFuses(Before). The other two are both
|
||||
// reached from inside a command call already under way, and both cost
|
||||
// the same on restore.
|
||||
//
|
||||
// readchar is reached from prompts raised part-way through a command —
|
||||
// --More-- on the second message of a turn, askOverwrite, getStr, the
|
||||
// direction and pack prompts — and by then the command has already
|
||||
// mutated state: fight sets g.Count and g.Quiet and runs runTo before
|
||||
// any message, revealXeroc writes tp.Disguise before emitting one. The
|
||||
// ordinary top-of-turn key read in readCommand is inside command too,
|
||||
// after that turn's BEFORE daemons and turnUpkeep.
|
||||
//
|
||||
// runShellEscape is no safer. shell is an ordinary command handler ('!'
|
||||
// in the tables.go dispatch table), reached through executeCommand, so a
|
||||
// goroutine parked in the shell escape has already run this turn's
|
||||
// DoDaemons(Before), DoFuses(Before), turnUpkeep and the last-command
|
||||
// bookkeeping, and has not yet run DoDaemons(After), DoFuses(After) or
|
||||
// ringTurnEffects.
|
||||
//
|
||||
// The cost, at both: restoring re-enters playit at the top of command,
|
||||
// so the rest of that command never runs — its AFTER daemons and fuses
|
||||
// and its ring effects are lost — and the restored game opens with a
|
||||
// fresh BEFORE pass on top of the one already in the snapshot. That
|
||||
// second BEFORE pass is not free: rollwand, a live Before daemon once
|
||||
// swander has fired, ticks again and draws from the RNG every fourth
|
||||
// tick, and any Before fuse is decremented again.
|
||||
//
|
||||
// Not every consequence of that pass is shared by both, though. visuals
|
||||
// returns immediately unless g.After, and After is part of the snapshot,
|
||||
// so DVisuals never re-ticks after a shell-escape save: shell sets
|
||||
// g.After = false as its first statement, before it parks. After a
|
||||
// readchar save it usually does re-tick, because turnUpkeep sets
|
||||
// g.After = true just before the top-of-turn read; the exception is a
|
||||
// handler that clears After before prompting, as identifyTrapCommand
|
||||
// does ahead of promptDirection.
|
||||
//
|
||||
// The result is still a coherent game state, one turn's worth of effects
|
||||
// off — strictly better than the torn encode this replaced, and the cost
|
||||
// of being able to save a player whose line dropped mid-prompt, or who
|
||||
// is away in a shell, at all.
|
||||
func (g *RogueGame) serviceAutoSaveRequest() {
|
||||
select {
|
||||
case req := <-g.sigSave:
|
||||
g.runAutoSaveRequest(req)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// runAutoSaveRequest answers one request: save, then release the waiter.
|
||||
func (g *RogueGame) runAutoSaveRequest(req *autoSaveRequest) {
|
||||
req.ok = g.autoSave()
|
||||
|
||||
close(req.done)
|
||||
}
|
||||
|
||||
// autoSave silently saves to the current file name (save.c auto_save),
|
||||
// reporting whether it wrote a save. Game-goroutine only — reach it
|
||||
// through AutoSaveOnSignal from anywhere else.
|
||||
//
|
||||
// The error is not surfaced: there is no player to tell, since the
|
||||
// terminal is on its way out, and nothing sensible to do about it. It is
|
||||
// reported to the waiting signal goroutine as a failed save rather than
|
||||
// discarded outright, which is what the old `_ =` here used to do.
|
||||
func (g *RogueGame) autoSave() bool {
|
||||
if g.FileName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return g.saveFile(g.FileName) == nil
|
||||
}
|
||||
|
||||
// ErrSaveOutOfDate reports a save file from an incompatible version.
|
||||
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
|
||||
|
||||
// ErrSaveCorrupt reports a save file whose contents are structurally
|
||||
// impossible for a game this code could have written.
|
||||
var ErrSaveCorrupt = errors.New("sorry, saved game is corrupt")
|
||||
|
||||
// validateSnapshotObjects rejects a snapshot carrying an item whose Which
|
||||
// would index past the end of its kind's tables. The file is written by
|
||||
// this program, so such a value can only come from corruption or
|
||||
// tampering; refusing it at the door is what keeps a malformed object
|
||||
// from reaching the effect tables in doZap, quaff, and readScroll, where
|
||||
// the wizard-create bug (issue #10) used to put one.
|
||||
func validateSnapshotObjects(st *SaveState) error {
|
||||
lists := make([][]Object, 0, 2+len(st.Monsters))
|
||||
lists = append(lists, st.Objects, st.Player.Body.Pack)
|
||||
|
||||
for i := range st.Monsters {
|
||||
lists = append(lists, st.Monsters[i].Pack)
|
||||
}
|
||||
|
||||
for _, list := range lists {
|
||||
for i := range list {
|
||||
obj := &list[i]
|
||||
if !obj.hasValidWhich() {
|
||||
return fmt.Errorf("%w: %s has out-of-range which %d",
|
||||
ErrSaveCorrupt, obj.Kind, obj.Which)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore restores a saved game from a file (save.c restore). The file is
|
||||
// deleted, as in C, to defeat restarting from the same save.
|
||||
func Restore(path string, cfg Config) (*RogueGame, error) {
|
||||
func Restore(path string, params Params) (*RogueGame, error) {
|
||||
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }() // read-only handle
|
||||
|
||||
var st SaveState
|
||||
@@ -700,16 +905,22 @@ func Restore(path string, cfg Config) (*RogueGame, error) {
|
||||
return nil, ErrSaveOutOfDate
|
||||
}
|
||||
|
||||
valErr := validateSnapshotObjects(&st)
|
||||
if valErr != nil {
|
||||
return nil, valErr
|
||||
}
|
||||
|
||||
g := &RogueGame{
|
||||
data: newGameData(),
|
||||
Rng: &Rng{},
|
||||
Playing: true,
|
||||
ScorePath: cfg.ScorePath,
|
||||
ScorePath: params.ScorePath,
|
||||
FileName: path,
|
||||
rogueOpts: cfg.RogueOpts,
|
||||
rogueOpts: params.RogueOpts,
|
||||
restored: true,
|
||||
sigSave: make(chan *autoSaveRequest, 1),
|
||||
}
|
||||
g.scr = NewScreen(cfg.Term)
|
||||
g.scr = NewScreen(params.Term)
|
||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||
g.applySnapshot(&st)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func TestSaveRestoreRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 4242)
|
||||
// Dirty up some state so the round trip is meaningful.
|
||||
g.Player.Purse = 123
|
||||
@@ -29,7 +32,7 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
||||
h, err := Restore(path, Params{Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
@@ -134,6 +137,8 @@ func checkEquipmentAliasing(t *testing.T, g, h *RogueGame) {
|
||||
}
|
||||
|
||||
func TestRestoreRejectsWrongVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 1)
|
||||
path := filepath.Join(t.TempDir(), "rogue.save")
|
||||
st := g.snapshot()
|
||||
@@ -154,7 +159,7 @@ func TestRestoreRejectsWrongVersion(t *testing.T) {
|
||||
t.Fatal(closeErr)
|
||||
}
|
||||
|
||||
_, restoreErr := Restore(path, Config{})
|
||||
_, restoreErr := Restore(path, Params{})
|
||||
if restoreErr == nil {
|
||||
t.Error("restore accepted an out-of-date save")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -36,6 +37,7 @@ func (g *RogueGame) rdScore() []ScoreEnt {
|
||||
if err != nil {
|
||||
return topTen
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }() // read-only handle
|
||||
|
||||
var onDisk []ScoreEnt
|
||||
|
||||
+46
-2
@@ -14,8 +14,27 @@ type Terminal interface {
|
||||
// Render blits the window to the device.
|
||||
Render(w *Window)
|
||||
// ReadChar blocks for the next key, translated to Rogue's input bytes
|
||||
// (arrows become hjkl, control keys their C0 codes).
|
||||
ReadChar() byte
|
||||
// (arrows become hjkl, control keys their C0 codes). ok is false when
|
||||
// the read was woken by Interrupt instead of by a key, which is how a
|
||||
// signal-triggered autosave reaches a game parked on input; the byte
|
||||
// is meaningless then.
|
||||
ReadChar() (ch byte, ok bool)
|
||||
// Interrupt wakes a ReadChar that is blocked waiting for a key. It is
|
||||
// the one Terminal method called from another goroutine, so an
|
||||
// implementation must be safe to call concurrently with ReadChar.
|
||||
Interrupt()
|
||||
// Repaint forces the device to redraw every cell it is showing, the
|
||||
// redraw command's whole point (curses clearok(curscr, TRUE) followed
|
||||
// by wrefresh(curscr)). Render cannot stand in for it: a device that
|
||||
// diffs against its own idea of what is on screen will do nothing at
|
||||
// all when the screen has been corrupted by something else's output,
|
||||
// which is the case the player types CTRL-R for. It repaints what was
|
||||
// last rendered — C repainted curscr, not stdscr — so it neither
|
||||
// needs nor takes a window.
|
||||
Repaint()
|
||||
// Fini restores the device to its pre-game state (curses endwin). The
|
||||
// game calls it on its way out, since one game run is one process.
|
||||
Fini()
|
||||
}
|
||||
|
||||
// cell is one screen position.
|
||||
@@ -213,6 +232,30 @@ func (s *Screen) Refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
// Repaint forces the device to redraw everything it is showing, if there
|
||||
// is a device (curses clearok(curscr, TRUE) + wrefresh(curscr)).
|
||||
func (s *Screen) Repaint() {
|
||||
if s.term != nil {
|
||||
s.term.Repaint()
|
||||
}
|
||||
}
|
||||
|
||||
// Fini restores the terminal device, if there is one (curses endwin).
|
||||
func (s *Screen) Fini() {
|
||||
if s.term != nil {
|
||||
s.term.Fini()
|
||||
}
|
||||
}
|
||||
|
||||
// Interrupt wakes a device read that is blocked waiting for a key, if
|
||||
// there is a device. Called from the signal goroutine; everything else on
|
||||
// Screen belongs to the game goroutine.
|
||||
func (s *Screen) Interrupt() {
|
||||
if s.term != nil {
|
||||
s.term.Interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
|
||||
func (s *Screen) RefreshWin(w *Window) {
|
||||
if s.term != nil {
|
||||
@@ -237,3 +280,4 @@ func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
|
||||
func (g *RogueGame) clear() { g.scr.Std.Clear() }
|
||||
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
|
||||
func (g *RogueGame) refresh() { g.scr.Refresh() }
|
||||
func (g *RogueGame) repaint() { g.scr.Repaint() }
|
||||
|
||||
+14
-4
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// scrolls.c — read a scroll and let it happen.
|
||||
@@ -28,14 +29,17 @@ func (g *RogueGame) readScroll() {
|
||||
// Get rid of the thing
|
||||
g.leavePack(obj, false, false)
|
||||
|
||||
if h := g.data.readHandlers[obj.ScrollKind()]; h != nil {
|
||||
if h := g.data.readHandler(obj); h != nil {
|
||||
h(g, obj)
|
||||
}
|
||||
|
||||
g.look(true) // put the result of the scroll on the screen
|
||||
g.status()
|
||||
|
||||
// A malformed scroll has no lore entry to name (see quaff).
|
||||
if obj.hasValidWhich() {
|
||||
g.callIt(&g.Items.Scrolls[obj.Which])
|
||||
}
|
||||
}
|
||||
|
||||
// The per-scroll effect handlers, dispatched through
|
||||
@@ -148,7 +152,8 @@ func (g *RogueGame) createMonsterSpot() (Coord, bool) {
|
||||
// Or anything else nasty
|
||||
if ch := g.Level.VisibleChar(y, x); stepOk(ch) {
|
||||
if ch == Scroll {
|
||||
if fo := g.Level.ObjectAt(y, x); fo != nil && fo.ScrollKind() == ScrollScareMonster {
|
||||
if fo := g.Level.ObjectAt(y, x); fo != nil &&
|
||||
fo.ScrollKind() == ScrollScareMonster {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -164,10 +169,15 @@ func (g *RogueGame) createMonsterSpot() (Coord, bool) {
|
||||
}
|
||||
|
||||
func (g *RogueGame) readIdentify(obj *Object) {
|
||||
// Identify, let him figure something out
|
||||
// Identify, let him figure something out. idType is shorter than the
|
||||
// scroll table keying it (it stops after the last identify scroll),
|
||||
// so the filter lookup carries its own bound. That bound is not
|
||||
// reachable today: readHandlers registers readIdentify only for the
|
||||
// identify scrolls, all of which sit inside idType. It is kept as
|
||||
// defense-in-depth against a future table resize.
|
||||
g.Items.Scrolls[obj.Which].Know = true
|
||||
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
|
||||
g.whatis(true, g.data.idType[obj.ScrollKind()])
|
||||
g.whatis(true, g.data.identifyType(obj.ScrollKind()))
|
||||
}
|
||||
|
||||
func (g *RogueGame) readMagicMapping(*Object) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dumpItemTables formats a game's per-seed item appearance tables in the
|
||||
// same layout the instrumented C reference prints: the potion colors,
|
||||
// scroll names, ring stones, and wand/staff materials, each generated by
|
||||
// consuming the RNG in a fixed order during New().
|
||||
func dumpItemTables(seed int32, g *RogueGame) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "SEED %d\n", seed)
|
||||
|
||||
fmt.Fprintln(&b, "POTIONS")
|
||||
|
||||
for _, c := range g.Items.PotColors {
|
||||
fmt.Fprintln(&b, c)
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b, "SCROLLS")
|
||||
|
||||
for _, s := range g.Items.ScrNames {
|
||||
fmt.Fprintln(&b, s)
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b, "RINGS")
|
||||
|
||||
for _, s := range g.Items.RingStones {
|
||||
fmt.Fprintln(&b, s)
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b, "STICKS")
|
||||
|
||||
for i := range g.Items.WandType {
|
||||
fmt.Fprintf(&b, "%s %s\n", g.Items.WandType[i], g.Items.WandMade[i])
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestSeedCompatItemTables proves the port's seed-compatibility claim: for
|
||||
// the same seed, the Go game generates the exact per-seed item appearance
|
||||
// tables as the C reference on modern-rogue. That requires the LCG and its
|
||||
// consumption order through the whole init sequence (init_probs →
|
||||
// init_player → init_names → init_colors → init_stones → init_materials) to
|
||||
// match C byte for byte. The golden is captured from an instrumented build
|
||||
// of the C game (testdata/README.md).
|
||||
func TestSeedCompatItemTables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
golden, err := os.ReadFile("testdata/item_tables.golden")
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
|
||||
// These must match the seeds the golden was generated from
|
||||
// (testdata/README.md).
|
||||
seeds := []int32{1, 42, 12345, 99999}
|
||||
|
||||
var got strings.Builder
|
||||
|
||||
for _, seed := range seeds {
|
||||
g := New(Params{Seed: seed, Wizard: true})
|
||||
got.WriteString(dumpItemTables(seed, g))
|
||||
}
|
||||
|
||||
if got.String() != string(golden) {
|
||||
t.Errorf("Go item tables diverge from the C reference at %s",
|
||||
firstDiff(string(golden), got.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// firstDiff returns a description of the first line where want and got
|
||||
// differ, for a readable failure.
|
||||
func firstDiff(want, got string) string {
|
||||
wl := strings.Split(want, "\n")
|
||||
gl := strings.Split(got, "\n")
|
||||
|
||||
for i := 0; i < len(wl) || i < len(gl); i++ {
|
||||
w, g := "", ""
|
||||
if i < len(wl) {
|
||||
w = wl[i]
|
||||
}
|
||||
|
||||
if i < len(gl) {
|
||||
g = gl[i]
|
||||
}
|
||||
|
||||
if w != g {
|
||||
return fmt.Sprintf("line %d: C=%q Go=%q", i+1, w, g)
|
||||
}
|
||||
}
|
||||
|
||||
return "no line difference (trailing content?)"
|
||||
}
|
||||
+32
-3
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
@@ -30,10 +31,30 @@ func (g *RogueGame) doZap() {
|
||||
return
|
||||
}
|
||||
|
||||
if h := g.data.zapHandlers[obj.WandKind()]; h != nil {
|
||||
// C's switch has a case for every one of the 14 WS_ kinds, so its
|
||||
// closing "otherwise: msg(...)" arm is reachable only for an o_which
|
||||
// outside the table — the malformed objects hasValidWhich screens
|
||||
// for, which is why no handler and a legal Which can only mean WS_NOP.
|
||||
//
|
||||
// The message is under #ifdef MASTER, not under a wizard test: C
|
||||
// printed it for every player of a MASTER build, which is the build
|
||||
// this port is (see the '+' command, issue #11). Do not gate it on
|
||||
// g.Wizard.
|
||||
//
|
||||
// WS_NOP is a case of its own ("when WS_NOP: break;"): the wand that
|
||||
// deliberately does nothing says nothing either. All three arms fall
|
||||
// out of the switch into o_charges--, so even the bizarre schtick
|
||||
// costs a charge.
|
||||
h := g.data.zapHandler(obj)
|
||||
|
||||
switch {
|
||||
case h != nil:
|
||||
if !h(g, obj) {
|
||||
return // the zap aborted; no charge is used
|
||||
}
|
||||
case obj.hasValidWhich(): // WS_NOP
|
||||
default:
|
||||
g.msg("what a bizarre schtick!")
|
||||
}
|
||||
|
||||
obj.Charges--
|
||||
@@ -272,6 +293,7 @@ func slowTarget(tp *Monster) {
|
||||
func (g *RogueGame) zapBolt(obj *Object) bool {
|
||||
var name string
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.WandKind() {
|
||||
case WandLightning:
|
||||
name = "bolt"
|
||||
@@ -434,7 +456,9 @@ func boltBounces(ch byte, heroPos, pos Coord) bool {
|
||||
// boltStrikesMonster resolves a bolt arriving on a monster's square (the
|
||||
// monster arm of the fire_bolt loop). It reports whether the bolt was
|
||||
// used up.
|
||||
func (g *RogueGame) boltStrikesMonster(tp *Monster, bolt *Object, pos Coord, ch byte, name string, fromHero bool) bool {
|
||||
func (g *RogueGame) boltStrikesMonster(
|
||||
tp *Monster, bolt *Object, pos Coord, ch byte, name string, fromHero bool,
|
||||
) bool {
|
||||
tp.OldCh = g.Level.Char(pos.Y, pos.X)
|
||||
if !g.saveThrow(VsMagic, &tp.Stats) {
|
||||
bolt.Pos = pos
|
||||
@@ -498,7 +522,11 @@ func (g *RogueGame) boltStrikesHero(start Coord, name string, fromHero bool) boo
|
||||
|
||||
// fixStick sets up a new wand or staff (sticks.c fix_stick).
|
||||
func (g *RogueGame) fixStick(cur *Object) {
|
||||
if g.Items.WandType[cur.Which] == staffName {
|
||||
// ws_type[] is indexed by Which; a malformed one is treated as a wand,
|
||||
// which is the branch the C string compare would take against any
|
||||
// value that is not literally "staff". The charge switch below already
|
||||
// funnels everything but WandLight into its default arm.
|
||||
if cur.hasValidWhich() && g.Items.WandType[cur.Which] == staffName {
|
||||
cur.Damage = dice("2x3")
|
||||
} else {
|
||||
cur.Damage = dice("1x1")
|
||||
@@ -506,6 +534,7 @@ func (g *RogueGame) fixStick(cur *Object) {
|
||||
|
||||
cur.HurlDmg = dice("1x1")
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch cur.WandKind() {
|
||||
case WandLight:
|
||||
cur.Charges = g.rnd(10) + 10
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The zap and bolt tests need a map they can reason about: real levels
|
||||
// put their rooms wherever rooms.c felt like, and sticks.c's geometry —
|
||||
// which square a bolt bounces off, which room a drain reaches — only
|
||||
// means anything against known walls, a known door, and a known passage
|
||||
// number. mkCarvedGame lays out two rooms joined by one corridor, using
|
||||
// the generator's own drawRoom so the wall characters (including the
|
||||
// '-' corners horiz() paints over vert()'s '|') are what a real level
|
||||
// would have:
|
||||
//
|
||||
// x: 1 20 40 59
|
||||
// y=1 -------------------- ------------------
|
||||
// |..................| |................|
|
||||
// y=4 |..................+########+................|
|
||||
// |..................| |................|
|
||||
// y=8 -------------------- ------------------
|
||||
const (
|
||||
carvedWidth = 20 // room width, both walls included
|
||||
carvedHeight = 8 // room height, both walls included
|
||||
roomAX = 1 // left wall of the west room
|
||||
roomBX = 40 // left wall of the east room
|
||||
carvedTopY = 1 // top wall of both rooms
|
||||
corridorY = 4 // row the corridor and doors run on
|
||||
doorAX = roomAX + carvedWidth - 1 // east wall of the west room
|
||||
carvedPass = 2 // passage number of the corridor
|
||||
)
|
||||
|
||||
// saveProofLvl makes save_throw(VS_MAGIC) succeed on every roll, so a
|
||||
// test can select the "it saved" arm without touching the RNG: C's
|
||||
// threshold is 14 + VS_MAGIC - lvl/2, which at level 40 is -3, and
|
||||
// roll(1,20) always clears that.
|
||||
const saveProofLvl = 40
|
||||
|
||||
// mkCarvedGame builds a game on the hand-carved level drawn above, with
|
||||
// the hero standing in the south-east corner of the west room — off
|
||||
// every row and column the bolt tests fire along.
|
||||
func mkCarvedGame(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := New(Params{Seed: seed, Term: &testTerm{}})
|
||||
for i := range g.Level.Places {
|
||||
g.Level.Places[i] = Place{Ch: ' ', Flags: FReal}
|
||||
}
|
||||
|
||||
for i, x := range [...]int{roomAX, roomBX} {
|
||||
rp := &g.Level.Rooms[i]
|
||||
*rp = Room{
|
||||
Pos: Coord{X: x, Y: carvedTopY},
|
||||
Max: Coord{X: carvedWidth, Y: carvedHeight},
|
||||
}
|
||||
g.drawRoom(rp)
|
||||
}
|
||||
|
||||
for i := 2; i < MaxRooms; i++ {
|
||||
g.Level.Rooms[i].Flags = Gone // rooms that are not there
|
||||
}
|
||||
|
||||
for x := doorAX + 1; x < roomBX; x++ {
|
||||
pp := g.Level.At(corridorY, x)
|
||||
pp.Ch = Passage
|
||||
pp.Flags = FReal | FPassage | carvedPass
|
||||
}
|
||||
// Doors carry the passage number in their low bits but not F_PASS,
|
||||
// exactly as passages.c numpass leaves them; roomin therefore reports
|
||||
// the room a door belongs to, and drain's corp lookup finds the
|
||||
// passage behind it.
|
||||
for _, x := range [...]int{doorAX, roomBX} {
|
||||
pp := g.Level.At(corridorY, x)
|
||||
pp.Ch = Door
|
||||
pp.Flags = FReal | carvedPass
|
||||
}
|
||||
|
||||
placeHero(g, Coord{X: roomAX + 17, Y: carvedTopY + 6})
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// placeHero moves the hero and keeps proom, oldpos and oldrp in step,
|
||||
// the way move.c and misc.c look do; a --More-- prompt redraws through
|
||||
// look, which reads all three.
|
||||
func placeHero(g *RogueGame, pos Coord) {
|
||||
g.Player.Pos = pos
|
||||
g.Player.Room = g.roomIn(pos)
|
||||
g.Oldpos = pos
|
||||
g.Oldrp = g.Player.Room
|
||||
}
|
||||
|
||||
// putMonster drops a monster of the given letter on a carved-level spot.
|
||||
func putMonster(g *RogueGame, typ byte, pos Coord) *Monster {
|
||||
tp := &Monster{}
|
||||
g.newMonster(tp, typ, pos)
|
||||
|
||||
return tp
|
||||
}
|
||||
|
||||
// pinRng rewinds the generator to a state whose next draw is exactly
|
||||
// want, so tests can choose a save-throw outcome or a polymorph letter
|
||||
// without assuming anything about the generator itself: the wanted
|
||||
// value is found by running the real Rng, not by predicting it.
|
||||
func pinRng(t *testing.T, g *RogueGame, draw func(*Rng) int, want int) {
|
||||
t.Helper()
|
||||
|
||||
for s := int32(1); s < 100000; s++ {
|
||||
probe := Rng{Seed: s}
|
||||
if draw(&probe) == want {
|
||||
g.Rng.Seed = s
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("no seed found whose next draw is %d", want)
|
||||
}
|
||||
|
||||
// d20 is the save_throw draw (monsters.c save_throw: roll(1, 20)).
|
||||
func d20(r *Rng) int { return r.Roll(1, 20) }
|
||||
|
||||
// zapWand builds a wand of the given kind with charges to spare.
|
||||
func zapWand(kind WandKind) *Object {
|
||||
obj := newObject()
|
||||
obj.Kind = KindWand
|
||||
obj.Which = int(kind)
|
||||
obj.Charges = 5
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// TestZapLightLightsTheRoom covers the WS_LIGHT arm: the room loses
|
||||
// ISDARK, the wand identifies itself, and the message is C's two-part
|
||||
// one (sticks.c 71-89).
|
||||
func TestZapLightLightsTheRoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 11)
|
||||
g.Player.Room.Flags.Set(Dark)
|
||||
|
||||
g.zapLight(zapWand(WandLight))
|
||||
|
||||
if g.Player.Room.Flags.Has(Dark) {
|
||||
t.Error("the room is still dark after a wand of light")
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandLight].Know {
|
||||
t.Error("the wand of light did not identify itself")
|
||||
}
|
||||
|
||||
const want = "the room is lit by a shimmering blue light"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapLightInPassageFades covers the ISGONE arm: a corridor is not a
|
||||
// room, so nothing is lit and the wand still becomes known.
|
||||
func TestZapLightInPassageFades(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 12)
|
||||
placeHero(g, Coord{X: doorAX + 3, Y: corridorY})
|
||||
g.Level.Rooms[0].Flags.Set(Dark)
|
||||
|
||||
g.zapLight(zapWand(WandLight))
|
||||
|
||||
if !g.Player.Room.Flags.Has(Gone) {
|
||||
t.Fatal("the hero is not in a passage; the test set-up is wrong")
|
||||
}
|
||||
|
||||
const want = "the corridor glows and then fades"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandLight].Know {
|
||||
t.Error("the wand of light did not identify itself in a corridor")
|
||||
}
|
||||
|
||||
if !g.Level.Rooms[0].Flags.Has(Dark) {
|
||||
t.Error("zapping in a corridor lit a room anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapDrainLifeTooWeakKeepsCharge covers C's early return: under two
|
||||
// hit points the zap is refused, and because C returns before the
|
||||
// switch falls out, o_charges-- never runs.
|
||||
func TestZapDrainLifeTooWeakKeepsCharge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 13)
|
||||
g.Player.Stats.HP = 1
|
||||
|
||||
wand := zapWand(WandDrainLife)
|
||||
ch := give(g, wand)
|
||||
setInput(t, g, ch)
|
||||
|
||||
g.doZap()
|
||||
|
||||
const want = "you are too weak to use it"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if wand.Charges != 5 {
|
||||
t.Errorf("charges = %d, want 5: the refused zap must not cost one",
|
||||
wand.Charges)
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP != 1 {
|
||||
t.Errorf("hit points = %d, want 1", g.Player.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainSplitsHitPoints covers sticks.c drain: the hero loses half
|
||||
// his hit points and the drainees each lose that half divided by their
|
||||
// number — monsters out of reach lose nothing.
|
||||
func TestDrainSplitsHitPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 14)
|
||||
placeHero(g, Coord{X: 5, Y: 3})
|
||||
g.Player.Stats.HP = 20
|
||||
|
||||
near := [2]*Monster{
|
||||
putMonster(g, 'Z', Coord{X: 7, Y: 3}),
|
||||
putMonster(g, 'Z', Coord{X: 9, Y: 5}),
|
||||
}
|
||||
far := putMonster(g, 'Z', Coord{X: roomBX + 5, Y: 3})
|
||||
|
||||
for _, tp := range []*Monster{near[0], near[1], far} {
|
||||
tp.Stats.HP = 100
|
||||
}
|
||||
|
||||
g.drain()
|
||||
|
||||
if g.Player.Stats.HP != 10 {
|
||||
t.Errorf("hero hit points = %d, want 10", g.Player.Stats.HP)
|
||||
}
|
||||
// 10 hit points spread over two drainees is 5 apiece.
|
||||
for i, tp := range near {
|
||||
if tp.Stats.HP != 95 {
|
||||
t.Errorf("drainee %d hit points = %d, want 95", i, tp.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
if far.Stats.HP != 100 {
|
||||
t.Errorf("the monster in the other room lost %d hit points",
|
||||
100-far.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainWithNoTargetsCostsNothing covers the cnt == 0 arm, which
|
||||
// returns before pstats.s_hpt is halved.
|
||||
func TestDrainWithNoTargetsCostsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 15)
|
||||
placeHero(g, Coord{X: 5, Y: 3})
|
||||
g.Player.Stats.HP = 20
|
||||
|
||||
g.drain()
|
||||
|
||||
const want = "you have a tingling feeling"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP != 20 {
|
||||
t.Errorf("hero hit points = %d, want 20: a drain that found nobody "+
|
||||
"returns before halving them", g.Player.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainKillsWeakMonster covers the other arm of drain's zot loop: a
|
||||
// drainee whose share of the hit points finishes it is killed outright.
|
||||
func TestDrainKillsWeakMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 29)
|
||||
placeHero(g, Coord{X: 5, Y: 3})
|
||||
g.Player.Stats.HP = 20
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 7, Y: 3})
|
||||
tp.Stats.HP = 3 // less than the ten points it is about to take
|
||||
|
||||
g.drain()
|
||||
|
||||
if len(g.Level.Monsters) != 0 {
|
||||
t.Error("the drained monster is still on the level")
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(7, 3) != nil {
|
||||
t.Error("the drained monster is still on the map")
|
||||
}
|
||||
|
||||
const want = "you have defeated the zombie"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapSpeedTogglesHasteAndSlow covers both WS_HASTE_M and WS_SLOW_M
|
||||
// in both directions: C cancels the opposite condition when it is
|
||||
// already on, and only otherwise applies its own.
|
||||
func TestZapSpeedTogglesHasteAndSlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind WandKind
|
||||
start CreatureFlags
|
||||
wantHasted bool
|
||||
wantSlowed bool
|
||||
wantTurn bool
|
||||
}{
|
||||
{name: "haste a monster", kind: WandHasteMonster, wantHasted: true},
|
||||
{
|
||||
name: "haste cancels a slow",
|
||||
kind: WandHasteMonster,
|
||||
start: Slowed,
|
||||
},
|
||||
{
|
||||
name: "slow a monster",
|
||||
kind: WandSlowMonster,
|
||||
wantSlowed: true,
|
||||
wantTurn: true,
|
||||
},
|
||||
{
|
||||
name: "slow cancels a haste",
|
||||
kind: WandSlowMonster,
|
||||
start: Hasted,
|
||||
wantTurn: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 30)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
tp.Flags.Clear(Hasted | Slowed)
|
||||
tp.Flags.Set(tt.start)
|
||||
tp.Turn = false // only the slow arm sets t_turn
|
||||
|
||||
g.zapSpeed(zapWand(tt.kind))
|
||||
|
||||
if tp.On(Hasted) != tt.wantHasted {
|
||||
t.Errorf("hasted = %v, want %v", tp.On(Hasted), tt.wantHasted)
|
||||
}
|
||||
|
||||
if tp.On(Slowed) != tt.wantSlowed {
|
||||
t.Errorf("slowed = %v, want %v", tp.On(Slowed), tt.wantSlowed)
|
||||
}
|
||||
|
||||
if tp.Turn != tt.wantTurn {
|
||||
t.Errorf("turn = %v, want %v", tp.Turn, tt.wantTurn)
|
||||
}
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the zapped monster was not set running")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainReaches pins the three-clause drainee test of sticks.c drain
|
||||
// one clause at a time: the hero's own room, the passage behind the door
|
||||
// he stands on (corp), and — only when he is in a passage — a door of
|
||||
// that same passage.
|
||||
func TestDrainReaches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
heroPos Coord
|
||||
monstPos Coord
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same room",
|
||||
heroPos: Coord{X: 5, Y: 3},
|
||||
monstPos: Coord{X: 9, Y: 6},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different room",
|
||||
heroPos: Coord{X: 5, Y: 3},
|
||||
monstPos: Coord{X: roomBX + 5, Y: 3},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "hero on a door reaches into that passage",
|
||||
heroPos: Coord{X: doorAX, Y: corridorY},
|
||||
monstPos: Coord{X: doorAX + 4, Y: corridorY},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "hero in the passage reaches its doors",
|
||||
heroPos: Coord{X: doorAX + 4, Y: corridorY},
|
||||
monstPos: Coord{X: roomBX, Y: corridorY},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "hero in the passage does not reach into a room",
|
||||
heroPos: Coord{X: doorAX + 4, Y: corridorY},
|
||||
monstPos: Coord{X: roomBX + 5, Y: 3},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 16)
|
||||
placeHero(g, tt.heroPos)
|
||||
tp := putMonster(g, 'Z', tt.monstPos)
|
||||
|
||||
var corp *Room
|
||||
if g.Level.Char(tt.heroPos.Y, tt.heroPos.X) == Door {
|
||||
corp = &g.Level.Passages[*g.Level.FlagsAt(
|
||||
tt.heroPos.Y, tt.heroPos.X)&FPassNum]
|
||||
}
|
||||
|
||||
inpass := g.Player.Room.Flags.Has(Gone)
|
||||
if got := g.drainReaches(tp, corp, inpass); got != tt.want {
|
||||
t.Errorf("drainReaches = %v, want %v (inpass=%v corp=%v)",
|
||||
got, tt.want, inpass, corp != nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapInvisibilityHidesMonster covers the WS_INVIS arm.
|
||||
func TestZapInvisibilityHidesMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 17)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
|
||||
g.zapInvisibility(zapWand(WandInvisibility))
|
||||
|
||||
if !tp.On(Invisible) {
|
||||
t.Error("the zapped monster is still visible")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapVictimReleasesFlytrap covers the shared preamble of C's
|
||||
// invisibility family: the flytrap holding the hero lets go the moment
|
||||
// the ray reaches it, whichever of those wands was zapped.
|
||||
func TestZapVictimReleasesFlytrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 18)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
g.Player.Flags.Set(Held)
|
||||
|
||||
tp := putMonster(g, 'F', Coord{X: 6, Y: corridorY})
|
||||
|
||||
g.zapInvisibility(zapWand(WandInvisibility))
|
||||
|
||||
if g.Player.On(Held) {
|
||||
t.Error("the flytrap still holds the hero after the zap")
|
||||
}
|
||||
|
||||
if !tp.On(Invisible) {
|
||||
t.Error("the flytrap was not made invisible")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapPolymorphReplacesMonster covers the WS_POLYMORPH arm and its
|
||||
// detach/re-attach dance: the creature keeps its identity (the same
|
||||
// THING, its pack, and the character it is standing on) but becomes a
|
||||
// different monster, listed once and standing where it stood.
|
||||
func TestZapPolymorphReplacesMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 19)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
pos := Coord{X: 8, Y: corridorY}
|
||||
tp := putMonster(g, 'K', pos)
|
||||
loot := newObject()
|
||||
loot.Kind = KindPotion
|
||||
tp.Pack = []*Object{loot}
|
||||
tp.OldCh = Stairs // it is standing on the staircase
|
||||
|
||||
const want = 'T'
|
||||
|
||||
pinRng(t, g, func(r *Rng) int { return r.Rnd(26) }, int(want-'A'))
|
||||
|
||||
g.zapPolymorph(zapWand(WandPolymorph))
|
||||
|
||||
if tp.Type != want || tp.Disguise != want {
|
||||
t.Errorf("monster is %q/%q after polymorph, want %q",
|
||||
tp.Type, tp.Disguise, want)
|
||||
}
|
||||
|
||||
if tp.Stats.Lvl != g.Monsters[want-'A'].Stats.Lvl {
|
||||
t.Errorf("level = %d, want the troll's %d: new_monster did not "+
|
||||
"re-roll the stats", tp.Stats.Lvl, g.Monsters[want-'A'].Stats.Lvl)
|
||||
}
|
||||
|
||||
if len(tp.Pack) != 1 || tp.Pack[0] != loot {
|
||||
t.Error("polymorph lost the monster's pack")
|
||||
}
|
||||
|
||||
if tp.OldCh != Stairs {
|
||||
t.Errorf("under-character = %q, want %q", tp.OldCh, Stairs)
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(pos.Y, pos.X) != tp || tp.Pos != pos {
|
||||
t.Error("the polymorphed monster is not where it stood")
|
||||
}
|
||||
|
||||
if n := len(g.Level.Monsters); n != 1 {
|
||||
t.Errorf("monster list holds %d entries, want 1: detach and "+
|
||||
"new_monster's attach must balance", n)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandPolymorph].Know {
|
||||
t.Error("a polymorph the hero watched did not identify the wand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapPolymorphClobbersDelta pins a C quirk the port keeps: do_zap
|
||||
// reuses the global delta as scratch for new_monster's coordinate, so
|
||||
// the zap direction is gone by the time the arm returns.
|
||||
func TestZapPolymorphClobbersDelta(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 20)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
pos := Coord{X: 8, Y: corridorY}
|
||||
putMonster(g, 'K', pos)
|
||||
|
||||
g.zapPolymorph(zapWand(WandPolymorph))
|
||||
|
||||
if g.Delta != pos {
|
||||
t.Errorf("delta = %v after polymorph, want the victim's %v",
|
||||
g.Delta, pos)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapCancellationClearsSpecials covers the WS_CANCEL arm. CANHUH is
|
||||
// set on the player and never on a monster in C (only scrolls.c sets
|
||||
// it), so the test puts it on by hand: the clear is written to take both
|
||||
// bits and the port must keep doing so.
|
||||
func TestZapCancellationClearsSpecials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 21)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
|
||||
tp.Flags.Set(Invisible | CanConfuse)
|
||||
|
||||
g.zapCancellation(zapWand(WandCancellation))
|
||||
|
||||
if !tp.On(Cancelled) {
|
||||
t.Error("the monster was not cancelled")
|
||||
}
|
||||
|
||||
if tp.On(Invisible) {
|
||||
t.Error("cancellation left the monster invisible")
|
||||
}
|
||||
|
||||
if tp.On(CanConfuse) {
|
||||
t.Error("cancellation left the monster able to confuse")
|
||||
}
|
||||
// t_disguise = t_type is an identity for every monster a zap ray can
|
||||
// actually stop on: the one disguised kind, the xeroc, looks like an
|
||||
// item, and step_ok is true for item characters, so the ray walks
|
||||
// straight past it. Pinned anyway, because C assigns it.
|
||||
if tp.Disguise != tp.Type {
|
||||
t.Errorf("disguise = %q, want %q", tp.Disguise, tp.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapTeleportToPullsMonsterIn covers WS_TELTO: the victim lands on
|
||||
// hero + delta, which is the square next to the hero along the ray.
|
||||
func TestZapTeleportToPullsMonsterIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 22)
|
||||
hero := Coord{X: 5, Y: corridorY}
|
||||
placeHero(g, hero)
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
from := Coord{X: 8, Y: corridorY}
|
||||
tp := putMonster(g, 'Z', from)
|
||||
|
||||
g.zapTeleport(zapWand(WandTeleportTo))
|
||||
|
||||
want := Coord{X: hero.X + 1, Y: hero.Y}
|
||||
if tp.Pos != want {
|
||||
t.Errorf("monster at %v after teleport-to, want %v", tp.Pos, want)
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(want.Y, want.X) != tp {
|
||||
t.Error("the map does not have the monster at its new spot")
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(from.Y, from.X) != nil {
|
||||
t.Error("the monster is still on the map where it came from")
|
||||
}
|
||||
|
||||
if tp.Dest != &g.Player.Pos {
|
||||
t.Error("the teleported monster is not chasing the hero")
|
||||
}
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the teleported monster was not woken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapTeleportAwayMovesMonsterOff covers WS_TELAWAY, whose C loop
|
||||
// re-draws until the spot is not the hero's own.
|
||||
func TestZapTeleportAwayMovesMonsterOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 23)
|
||||
hero := Coord{X: 5, Y: corridorY}
|
||||
placeHero(g, hero)
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
from := Coord{X: 8, Y: corridorY}
|
||||
tp := putMonster(g, 'Z', from)
|
||||
|
||||
g.zapTeleport(zapWand(WandTeleportAway))
|
||||
|
||||
if tp.Pos == from {
|
||||
t.Error("teleport away did not move the monster")
|
||||
}
|
||||
|
||||
if tp.Pos == hero {
|
||||
t.Error("teleport away dropped the monster onto the hero")
|
||||
}
|
||||
|
||||
if g.Level.Char(tp.Pos.Y, tp.Pos.X) != Floor {
|
||||
t.Errorf("monster landed on %q, want floor",
|
||||
g.Level.Char(tp.Pos.Y, tp.Pos.X))
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(from.Y, from.X) != nil {
|
||||
t.Error("the monster is still on the map where it came from")
|
||||
}
|
||||
}
|
||||
|
||||
// vanishMsg is what C says when the missile finds nobody to hit, with
|
||||
// the original spelling of "missile" intact (sticks.c 191).
|
||||
//
|
||||
//nolint:misspell // C's spelling, kept faithfully
|
||||
const vanishMsg = "the missle vanishes with a puff of smoke"
|
||||
|
||||
// TestZapMagicMissile covers WS_MISSILE both ways: a victim that saves
|
||||
// gets C's puff-of-smoke message and no damage, one that does not is
|
||||
// hit by a bolt whose o_hplus of 100 cannot miss.
|
||||
func TestZapMagicMissile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lvl int
|
||||
wantMsg bool
|
||||
}{
|
||||
{name: "victim saves", lvl: saveProofLvl, wantMsg: true},
|
||||
{name: "victim is hit", lvl: 1, wantMsg: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 24)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = tt.lvl
|
||||
tp.Stats.HP = 500
|
||||
|
||||
pinRng(t, g, d20, 1) // the lowest save throw there is
|
||||
|
||||
g.zapMagicMissile(zapWand(WandMagicMissile))
|
||||
|
||||
if got := g.Msgs.Huh == vanishMsg; got != tt.wantMsg {
|
||||
t.Errorf("message = %q, want vanish = %v", g.Msgs.Huh, tt.wantMsg)
|
||||
}
|
||||
|
||||
if hurt := tp.Stats.HP < 500; hurt == tt.wantMsg {
|
||||
t.Errorf("hit points = %d, want damage = %v",
|
||||
tp.Stats.HP, !tt.wantMsg)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandMagicMissile].Know {
|
||||
t.Error("the magic missile wand did not identify itself")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixStickDamage covers the strcmp against ws_type: a staff swings
|
||||
// for 2x3, everything else for 1x1, and both hurl for 1x1.
|
||||
func TestFixStickDamage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
material string
|
||||
want string
|
||||
}{
|
||||
{material: staffName, want: "2x3"},
|
||||
{material: wandName, want: "1x1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.material, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 25)
|
||||
g.Items.WandType[WandCold] = tt.material
|
||||
|
||||
cur := newObject()
|
||||
cur.Kind = KindWand
|
||||
cur.Which = int(WandCold)
|
||||
g.fixStick(cur)
|
||||
|
||||
if !slices.Equal(cur.Damage, dice(tt.want)) {
|
||||
t.Errorf("damage = %v, want %v", cur.Damage, tt.want)
|
||||
}
|
||||
|
||||
if !slices.Equal(cur.HurlDmg, dice("1x1")) {
|
||||
t.Errorf("hurl damage = %v, want 1x1", cur.HurlDmg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixStickCharges covers the charge switch. C is rnd(10)+10 for the
|
||||
// wand of light and rnd(5)+3 for everything else, so both ends of both
|
||||
// ranges must show up over enough draws and nothing outside them ever.
|
||||
func TestFixStickCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind WandKind
|
||||
lo, hi int
|
||||
}{
|
||||
{name: "light", kind: WandLight, lo: 10, hi: 19},
|
||||
{name: "other", kind: WandCold, lo: 3, hi: 7},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 26)
|
||||
lo, hi := 1<<30, -1
|
||||
|
||||
for range 500 {
|
||||
cur := newObject()
|
||||
cur.Kind = KindWand
|
||||
cur.Which = int(tt.kind)
|
||||
g.fixStick(cur)
|
||||
lo = min(lo, cur.Charges)
|
||||
hi = max(hi, cur.Charges)
|
||||
}
|
||||
|
||||
if lo != tt.lo || hi != tt.hi {
|
||||
t.Errorf("charges ranged over %d..%d, want %d..%d",
|
||||
lo, hi, tt.lo, tt.hi)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChargeStr covers sticks.c charge_str: nothing at all until the
|
||||
// stick is known, then the terse or verbose bracket.
|
||||
func TestChargeStr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
known bool
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{name: "unknown", known: false, terse: false, want: ""},
|
||||
{name: "unknown and terse", known: false, terse: true, want: ""},
|
||||
{name: "known", known: true, terse: false, want: " [7 charges]"},
|
||||
{name: "known and terse", known: true, terse: true, want: " [7]"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 27)
|
||||
g.Options.Terse = tt.terse
|
||||
|
||||
obj := zapWand(WandCold)
|
||||
obj.Charges = 7
|
||||
|
||||
if tt.known {
|
||||
obj.Flags.Set(Known)
|
||||
}
|
||||
|
||||
if got := chargeStr(g, obj); got != tt.want {
|
||||
t.Errorf("chargeStr = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapBoltNames covers the name each of the three bolt wands fires
|
||||
// under (sticks.c 225-231), read back out of the weapon table entry
|
||||
// fire_bolt overwrites and out of the bounce message.
|
||||
func TestZapBoltNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
kind WandKind
|
||||
want string
|
||||
}{
|
||||
{kind: WandLightning, want: boltName},
|
||||
{kind: WandFire, want: flameName},
|
||||
{kind: WandCold, want: iceName},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 28)
|
||||
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
|
||||
g.Player.Stats.Lvl = saveProofLvl // never hurt by the rebound
|
||||
g.Delta = Coord{X: -1, Y: 0} // straight at the west wall
|
||||
|
||||
g.zapBolt(zapWand(tt.kind))
|
||||
|
||||
if got := g.Items.Weapons[WeaponFlame].Name; got != tt.want {
|
||||
t.Errorf("weapon name = %q, want %q", got, tt.want)
|
||||
}
|
||||
|
||||
if !strings.Contains(g.Msgs.Huh, tt.want) {
|
||||
t.Errorf("message = %q, want it to name the %q",
|
||||
g.Msgs.Huh, tt.want)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[tt.kind].Know {
|
||||
t.Error("the bolt wand did not identify itself")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+78
-3
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// tables.go — the static data tables the C game kept as file-scope globals
|
||||
@@ -166,7 +167,10 @@ const ripWall = " | |"
|
||||
//nolint:funlen,maintidx // a single composite literal holding every C data table
|
||||
func newGameData() *gameData {
|
||||
return &gameData{
|
||||
initStats: Stats{Str: 16, Exp: 0, Lvl: 1, ArmorClass: 10, HP: 12, Dmg: dice("1x4"), MaxHP: 12},
|
||||
initStats: Stats{
|
||||
Str: 16, Exp: 0, Lvl: 1, ArmorClass: 10, HP: 12,
|
||||
Dmg: dice("1x4"), MaxHP: 12,
|
||||
},
|
||||
|
||||
aClass: [NumArmorTypes]int{
|
||||
8, // LEATHER
|
||||
@@ -205,7 +209,8 @@ func newGameData() *gameData {
|
||||
{"dragon", 100, Mean, Stats{10, 5000, 10, -1, 1, dice("1x8/1x8/3x10"), 0}},
|
||||
{"emu", 0, Mean, Stats{10, 2, 1, 7, 1, dice("1x2"), 0}},
|
||||
{"venus flytrap", 0, Mean, Stats{10, 80, 8, 3, 1, dice("%%%x0"), 0}},
|
||||
{"griffin", 20, Mean | Flying | Regenerates, Stats{10, 2000, 13, 2, 1, dice("4x3/3x5"), 0}},
|
||||
{"griffin", 20, Mean | Flying | Regenerates,
|
||||
Stats{10, 2000, 13, 2, 1, dice("4x3/3x5"), 0}},
|
||||
{"hobgoblin", 0, Mean, Stats{10, 3, 1, 5, 1, dice("1x8"), 0}},
|
||||
{"ice monster", 0, 0, Stats{10, 5, 1, 9, 1, dice("0x0"), 0}},
|
||||
{"jabberwock", 70, 0, Stats{10, 3000, 15, 6, 1, dice("2x12/2x4"), 0}},
|
||||
@@ -248,6 +253,7 @@ func newGameData() *gameData {
|
||||
{Name: "plate mail", Prob: 5, Worth: 150},
|
||||
},
|
||||
|
||||
//nolint:dupl // distinct C data tables that merely share their shape
|
||||
basePotInfo: [NumPotionTypes]ObjInfo{
|
||||
{Name: "confusion", Prob: 7, Worth: 5},
|
||||
{Name: "hallucination", Prob: 8, Worth: 5},
|
||||
@@ -265,6 +271,7 @@ func newGameData() *gameData {
|
||||
{Name: "levitation", Prob: 6, Worth: 75},
|
||||
},
|
||||
|
||||
//nolint:dupl // distinct C data tables that merely share their shape
|
||||
baseRingInfo: [NumRingTypes]ObjInfo{
|
||||
{Name: "protection", Prob: 9, Worth: 400},
|
||||
{Name: "add strength", Prob: 9, Worth: 400},
|
||||
@@ -316,6 +323,7 @@ func newGameData() *gameData {
|
||||
{}, // DO NOT REMOVE: fake entry for dragon's breath
|
||||
},
|
||||
|
||||
//nolint:dupl // distinct C data tables that merely share their shape
|
||||
baseWsInfo: [NumWandTypes]ObjInfo{
|
||||
{Name: "light", Prob: 12, Worth: 250},
|
||||
{Name: "invisibility", Prob: 6, Worth: 5},
|
||||
@@ -777,7 +785,10 @@ func newGameData() *gameData {
|
||||
},
|
||||
CTRL('R'): func(g *RogueGame) {
|
||||
g.After = false
|
||||
g.refresh()
|
||||
// C forces a full repaint here (clearok + wrefresh on
|
||||
// curscr), not the diffing refresh: the command exists
|
||||
// for screens the game's own record no longer matches.
|
||||
g.repaint()
|
||||
},
|
||||
'v': func(g *RogueGame) {
|
||||
g.After = false
|
||||
@@ -794,6 +805,7 @@ func newGameData() *gameData {
|
||||
g.After = false // "legal" illegal command
|
||||
},
|
||||
'^': (*RogueGame).identifyTrapCommand,
|
||||
'+': (*RogueGame).wizardToggleCommand,
|
||||
Escape: func(g *RogueGame) {
|
||||
g.DoorStop = false
|
||||
g.Count = 0
|
||||
@@ -851,6 +863,69 @@ func newGameData() *gameData {
|
||||
}
|
||||
}
|
||||
|
||||
// The three effect-table lookups below are the guarded form of a raw
|
||||
// index into quaffHandlers/readHandlers/zapHandlers. An object whose
|
||||
// Which is out of range for its kind reports no handler rather than
|
||||
// panicking, which lands on the same do-nothing behavior C's switches
|
||||
// had when no case matched.
|
||||
|
||||
// quaffHandler returns the potion effect for obj, or nil when obj is not
|
||||
// a potion the table knows about.
|
||||
func (d *gameData) quaffHandler(obj *Object) func(g *RogueGame, trip bool) {
|
||||
if !obj.hasValidWhich() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.quaffHandlers[obj.PotionKind()]
|
||||
}
|
||||
|
||||
// readHandler returns the scroll effect for obj, or nil when obj is not
|
||||
// a scroll the table knows about.
|
||||
func (d *gameData) readHandler(obj *Object) func(g *RogueGame, obj *Object) {
|
||||
if !obj.hasValidWhich() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.readHandlers[obj.ScrollKind()]
|
||||
}
|
||||
|
||||
// zapHandler returns the wand effect for obj, or nil when obj is not a
|
||||
// wand the table knows about.
|
||||
func (d *gameData) zapHandler(obj *Object) func(g *RogueGame, obj *Object) bool {
|
||||
if !obj.hasValidWhich() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.zapHandlers[obj.WandKind()]
|
||||
}
|
||||
|
||||
// identifyType reads extern.c's identify-scroll to item-kind map,
|
||||
// returning KindNone for any scroll past the last identify scroll (the
|
||||
// table is shorter than the scroll table it is keyed by). No scroll that
|
||||
// can reach readIdentify is past that end, so the guard is defensive
|
||||
// rather than a live bound; it exists so a future table resize cannot
|
||||
// turn the lookup into a panic.
|
||||
func (d *gameData) identifyType(kind ScrollKind) ObjectKind {
|
||||
if kind < 0 || int(kind) >= len(d.idType) {
|
||||
return KindNone
|
||||
}
|
||||
|
||||
return d.idType[kind]
|
||||
}
|
||||
|
||||
// armorClass reads extern.c a_class[] for an armor object's Which,
|
||||
// returning 0 when Which is out of range. Only a malformed object can
|
||||
// hit that arm — createObj and Restore both reject one — but a bounds
|
||||
// check here is what keeps a bad index from panicking the process
|
||||
// instead of merely naming a suit of armor oddly.
|
||||
func (d *gameData) armorClass(which int) int {
|
||||
if which < 0 || which >= int(NumArmorTypes) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return d.aClass[which]
|
||||
}
|
||||
|
||||
// Version strings (vers.c). The encstr/statlist XOR keys are not ported:
|
||||
// the Go save format does not use them.
|
||||
const (
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
|
||||
// badcheck from init.c: every probability table must sum to exactly 100.
|
||||
func TestProbabilitiesSumTo100(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sum := func(info []ObjInfo) int {
|
||||
s := 0
|
||||
for _, oi := range info {
|
||||
@@ -32,7 +35,9 @@ func TestProbabilitiesSumTo100(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInitProbsCumulative(t *testing.T) {
|
||||
g := NewGame(Config{Seed: 1})
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 1})
|
||||
|
||||
last := g.Items.Potions[NumPotionTypes-1].Prob
|
||||
if last != 100 {
|
||||
@@ -47,14 +52,16 @@ func TestInitProbsCumulative(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGameRandomizesAppearances(t *testing.T) {
|
||||
g := NewGame(Config{Seed: 12345})
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 12345})
|
||||
|
||||
checkPotionColors(t, g)
|
||||
checkScrollNames(t, g)
|
||||
checkWandMaterials(t, g)
|
||||
|
||||
// Determinism: same seed, same appearances.
|
||||
h := NewGame(Config{Seed: 12345})
|
||||
h := New(Params{Seed: 12345})
|
||||
if h.Items != g.Items {
|
||||
t.Error("two games with the same seed produced different item lore")
|
||||
}
|
||||
@@ -112,6 +119,8 @@ func checkWandMaterials(t *testing.T, g *RogueGame) {
|
||||
}
|
||||
|
||||
func TestMonsterTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := newGameData()
|
||||
if data.monsterTable[0].Name != "aquator" || data.monsterTable[25].Name != "zombie" {
|
||||
t.Error("monster table order broken")
|
||||
|
||||
+19
-4
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// testTerm is a headless Terminal for tests: rendering is a no-op and
|
||||
@@ -7,22 +8,36 @@ type testTerm struct {
|
||||
input []byte
|
||||
pos int
|
||||
tick int
|
||||
// repaints counts forced full redraws. Rendering is a no-op here, so
|
||||
// counting is the only way a headless test can tell that CTRL-R asked
|
||||
// for a repaint rather than an ordinary refresh — the two are
|
||||
// indistinguishable in the window contents, which is the whole reason
|
||||
// the bug this replaces went unnoticed.
|
||||
repaints int
|
||||
}
|
||||
|
||||
func (t *testTerm) Render(*Window) {}
|
||||
|
||||
func (t *testTerm) ReadChar() byte {
|
||||
func (t *testTerm) Repaint() { t.repaints++ }
|
||||
|
||||
func (t *testTerm) Fini() {}
|
||||
|
||||
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
|
||||
// The blocking case has its own fake, blockingTerm in autosave_test.go.
|
||||
func (t *testTerm) Interrupt() {}
|
||||
|
||||
func (t *testTerm) ReadChar() (byte, bool) {
|
||||
if t.pos < len(t.input) {
|
||||
c := t.input[t.pos]
|
||||
t.pos++
|
||||
|
||||
return c
|
||||
return c, true
|
||||
}
|
||||
|
||||
t.tick++
|
||||
if t.tick%2 == 0 {
|
||||
return '\n'
|
||||
return '\n', true
|
||||
}
|
||||
|
||||
return ' '
|
||||
return ' ', true
|
||||
}
|
||||
|
||||
Vendorováno
+27
@@ -0,0 +1,27 @@
|
||||
# Seed-compatibility golden
|
||||
|
||||
`item_tables.golden` is the per-seed item appearance tables (potion colors,
|
||||
scroll names, ring stones, wand/staff materials) captured from the **C
|
||||
reference** on the `modern-rogue` branch, for the seeds in the `seeds` list in
|
||||
`TestSeedCompatItemTables`. That test regenerates the same tables from the Go
|
||||
port and checks they match byte for byte — proving the LCG and its consumption
|
||||
order through the whole init sequence (`init_probs` → `init_player` →
|
||||
`init_names` → `init_colors` → `init_stones` → `init_materials`) agree with C.
|
||||
|
||||
## Regenerating the golden
|
||||
|
||||
`c_seedcompat.patch` adds a `DUMP` mode to the C `main.c`: with `DUMP` set it
|
||||
forces the RNG seed from `SEED`, runs the item-table init in the normal order,
|
||||
prints the tables, and exits before `initscr` (so no terminal is needed).
|
||||
|
||||
```sh
|
||||
# from a checkout of the C reference (modern-rogue branch):
|
||||
git archive modern-rogue | tar -x -C /tmp/rogue-c
|
||||
cd /tmp/rogue-c
|
||||
patch -p1 < .../game/testdata/c_seedcompat.patch
|
||||
./configure && make
|
||||
for s in 1 42 12345 99999; do DUMP=1 SEED=$s ./rogue; done \
|
||||
> .../game/testdata/item_tables.golden
|
||||
```
|
||||
|
||||
The seed list must match the `seeds` slice in `TestSeedCompatItemTables`.
|
||||
Vendorováno
+37
@@ -0,0 +1,37 @@
|
||||
--- a/main.c 2026-07-24 03:02:38
|
||||
+++ b/main.c 2026-07-24 02:48:31
|
||||
@@ -63,6 +63,34 @@
|
||||
#endif
|
||||
dnum = lowtime + md_getpid();
|
||||
seed = dnum;
|
||||
+
|
||||
+ /* SEEDCOMPAT: dump the per-game item appearance tables for a fixed
|
||||
+ * seed and exit, without initscr. The init sequence and everything
|
||||
+ * it consumes from rnd() mirror the normal startup (main.c), so the
|
||||
+ * tables are exactly what a real game with SEED would show. */
|
||||
+ if (getenv("DUMP") != NULL)
|
||||
+ {
|
||||
+ int di;
|
||||
+ char *sv = getenv("SEED");
|
||||
+ if (sv != NULL)
|
||||
+ seed = atoi(sv);
|
||||
+ printf("SEED %d\n", seed);
|
||||
+ init_probs();
|
||||
+ init_player();
|
||||
+ init_names();
|
||||
+ init_colors();
|
||||
+ init_stones();
|
||||
+ init_materials();
|
||||
+ printf("POTIONS\n");
|
||||
+ for (di = 0; di < MAXPOTIONS; di++) printf("%s\n", p_colors[di]);
|
||||
+ printf("SCROLLS\n");
|
||||
+ for (di = 0; di < MAXSCROLLS; di++) printf("%s\n", s_names[di]);
|
||||
+ printf("RINGS\n");
|
||||
+ for (di = 0; di < MAXRINGS; di++) printf("%s\n", r_stones[di]);
|
||||
+ printf("STICKS\n");
|
||||
+ for (di = 0; di < MAXSTICKS; di++) printf("%s %s\n", ws_type[di], ws_made[di]);
|
||||
+ exit(0);
|
||||
+ }
|
||||
|
||||
open_score();
|
||||
|
||||
Vendorováno
+260
@@ -0,0 +1,260 @@
|
||||
SEED 1
|
||||
POTIONS
|
||||
tangerine
|
||||
white
|
||||
ecru
|
||||
gold
|
||||
amber
|
||||
violet
|
||||
vermilion
|
||||
pink
|
||||
aquamarine
|
||||
plaid
|
||||
clear
|
||||
orange
|
||||
cyan
|
||||
tan
|
||||
SCROLLS
|
||||
miwhon garsnanih
|
||||
xomimi roke eshwedshu
|
||||
potwexrol ipbjorod turs evsnelg
|
||||
bekornan oxyfatox
|
||||
iv wexpo wun
|
||||
ha sefnelgtue whon pay
|
||||
alari wedit
|
||||
zantmon umzonski umwhonjo yot
|
||||
bluoxun rokkho yottrol sta
|
||||
vomarg microgcomp iteulkshu mung
|
||||
jo urokeep yuskiun
|
||||
ox xozantaks klisstaevs ag
|
||||
ipnih bek
|
||||
shu ami erk
|
||||
nejti zim
|
||||
iprol mic ishoxyvom fagan
|
||||
reacreti oodrol
|
||||
bytsri solsa tabu fri
|
||||
RINGS
|
||||
agate
|
||||
zircon
|
||||
jade
|
||||
tiger eye
|
||||
onyx
|
||||
germanium
|
||||
lapis lazuli
|
||||
emerald
|
||||
taaffeite
|
||||
kryptonite
|
||||
garnet
|
||||
ruby
|
||||
turquoise
|
||||
pearl
|
||||
STICKS
|
||||
wand steel
|
||||
wand platinum
|
||||
staff redwood
|
||||
staff pine
|
||||
wand silicon
|
||||
staff spruce
|
||||
staff pecan
|
||||
wand bone
|
||||
staff maple
|
||||
wand zinc
|
||||
wand iron
|
||||
wand pewter
|
||||
wand electrum
|
||||
staff dogwood
|
||||
SEED 42
|
||||
POTIONS
|
||||
blue
|
||||
green
|
||||
grey
|
||||
amber
|
||||
violet
|
||||
gold
|
||||
pink
|
||||
tan
|
||||
purple
|
||||
yellow
|
||||
plaid
|
||||
magenta
|
||||
turquoise
|
||||
cyan
|
||||
SCROLLS
|
||||
bek itod oxytaod oxy
|
||||
tarhovzant cre sname oxroy
|
||||
plemik ganod hyd wergerkpot
|
||||
hyd sol um bekzok
|
||||
esh eep ganmung
|
||||
anera ishsa ingala mon
|
||||
alasniklech viv
|
||||
yunejorn garro con nej
|
||||
dotrolther gopum eltitrol trolmonsri
|
||||
nes alazum
|
||||
itegopmung ti
|
||||
ere haeta wergla
|
||||
nejerecre poipi iprea
|
||||
ha falechrhov
|
||||
monskiwex sabitla frido
|
||||
rhovmar sno
|
||||
mar bekurzant satbuzum
|
||||
somon sri
|
||||
RINGS
|
||||
carnelian
|
||||
onyx
|
||||
jade
|
||||
granite
|
||||
stibotantalite
|
||||
kryptonite
|
||||
lapis lazuli
|
||||
germanium
|
||||
garnet
|
||||
tiger eye
|
||||
opal
|
||||
topaz
|
||||
agate
|
||||
peridot
|
||||
STICKS
|
||||
staff birch
|
||||
staff ebony
|
||||
staff redwood
|
||||
wand gold
|
||||
wand copper
|
||||
wand aluminum
|
||||
wand titanium
|
||||
wand mercury
|
||||
staff cypress
|
||||
staff bamboo
|
||||
staff dogwood
|
||||
wand silicon
|
||||
staff zebrawood
|
||||
wand beryllium
|
||||
SEED 12345
|
||||
POTIONS
|
||||
purple
|
||||
black
|
||||
grey
|
||||
brown
|
||||
plaid
|
||||
violet
|
||||
vermilion
|
||||
ecru
|
||||
orange
|
||||
turquoise
|
||||
tan
|
||||
magenta
|
||||
silver
|
||||
gold
|
||||
SCROLLS
|
||||
readalf shuplu ivnin
|
||||
plelaiv solel skibyt monha
|
||||
xo wun
|
||||
wedyfri o ewhonxo favompay
|
||||
eep zantreanelg
|
||||
plu buxo
|
||||
un zontabdan
|
||||
bie snik
|
||||
ulkitzant bluri
|
||||
apporg ash posnevly dennepwex
|
||||
u urval rol
|
||||
arzepotsno snovly pay snoropay
|
||||
pottox erewed faoxro
|
||||
ther sun ulkipo mik
|
||||
argzebfri elgrekli tuenepzon sehturssef
|
||||
isheep blumur
|
||||
wedash yuzimsun
|
||||
plupofri ski rejo fa
|
||||
RINGS
|
||||
onyx
|
||||
tiger eye
|
||||
alexandrite
|
||||
turquoise
|
||||
pearl
|
||||
emerald
|
||||
germanium
|
||||
sapphire
|
||||
zircon
|
||||
ruby
|
||||
granite
|
||||
stibotantalite
|
||||
opal
|
||||
diamond
|
||||
STICKS
|
||||
wand silicon
|
||||
staff ironwood
|
||||
staff holly
|
||||
wand gold
|
||||
staff mahogany
|
||||
wand iron
|
||||
wand brass
|
||||
wand pewter
|
||||
staff hemlock
|
||||
staff cherry
|
||||
staff elm
|
||||
wand mercury
|
||||
staff banyan
|
||||
staff dogwood
|
||||
SEED 99999
|
||||
POTIONS
|
||||
aquamarine
|
||||
plaid
|
||||
gold
|
||||
black
|
||||
vermilion
|
||||
red
|
||||
cyan
|
||||
tan
|
||||
orange
|
||||
violet
|
||||
brown
|
||||
clear
|
||||
silver
|
||||
green
|
||||
SCROLLS
|
||||
zant jocompan vomervly
|
||||
mur shusat prok
|
||||
prokmurklis oxysriklis
|
||||
ingcre prokbu whonengarg kli
|
||||
kli bot
|
||||
rokcoswerg ipsolsan klisvlypay
|
||||
glen yot whontox
|
||||
lechme markho fazim
|
||||
dalfsunbie micjosef cre comp
|
||||
vlyfumi bjorzantbot werg
|
||||
po argfidcos klipones
|
||||
ashtemarg ycrezim dalfiv whon
|
||||
turs unmisa zimpo therdo
|
||||
miccompuni uni neswex sef
|
||||
odwexing elwergmur mung
|
||||
itcon rhov nejmic lech
|
||||
garturs engseh ganish
|
||||
oodta whonorgsno monabmik vomyeng
|
||||
RINGS
|
||||
obsidian
|
||||
moonstone
|
||||
jade
|
||||
carnelian
|
||||
tiger eye
|
||||
taaffeite
|
||||
turquoise
|
||||
stibotantalite
|
||||
agate
|
||||
ruby
|
||||
onyx
|
||||
topaz
|
||||
germanium
|
||||
granite
|
||||
STICKS
|
||||
wand titanium
|
||||
wand brass
|
||||
wand silicon
|
||||
staff zebrawood
|
||||
wand mercury
|
||||
staff dogwood
|
||||
wand pewter
|
||||
staff cinnibar
|
||||
staff kukui wood
|
||||
staff banyan
|
||||
wand magnesium
|
||||
wand gold
|
||||
staff maple
|
||||
wand nickel
|
||||
+17
-2
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -12,16 +13,28 @@ import (
|
||||
func (g *RogueGame) inventoryName(obj *Object, drop bool) string {
|
||||
var pb strings.Builder
|
||||
|
||||
// Every arm below reaches into a per-kind name table at obj.Which:
|
||||
// the potion colors, ring stones, wand material/type, scroll titles,
|
||||
// and the weapon and armor name tables. An object with an out-of-range
|
||||
// Which cannot reach here (createObj and Restore both reject one), but
|
||||
// if one ever did, naming it must not take the process down — so it
|
||||
// falls back to the bare category name from C's type_name() vocabulary.
|
||||
if !obj.hasValidWhich() {
|
||||
return obj.Kind.String()
|
||||
}
|
||||
|
||||
which := obj.Which
|
||||
it := &g.Items
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindPotion:
|
||||
g.nameit(&pb, obj, potionName, it.PotColors[which], &it.Potions[which], nullstr)
|
||||
case KindRing:
|
||||
g.nameit(&pb, obj, ringName, it.RingStones[which], &it.Rings[which], ringNum)
|
||||
case KindWand:
|
||||
g.nameit(&pb, obj, it.WandType[which], it.WandMade[which], &it.Sticks[which], chargeStr)
|
||||
g.nameit(&pb, obj, it.WandType[which], it.WandMade[which],
|
||||
&it.Sticks[which], chargeStr)
|
||||
case KindScroll:
|
||||
g.nameScroll(&pb, obj)
|
||||
case KindFood:
|
||||
@@ -109,7 +122,7 @@ func (g *RogueGame) nameArmor(pb *strings.Builder, obj *Object) {
|
||||
sp := g.Items.Armors[obj.Which].Name
|
||||
if obj.Flags.Has(Known) {
|
||||
fmt.Fprintf(pb, "%s %s [",
|
||||
num(g.data.aClass[obj.Which]-obj.ArmorClass, 0, Armor), sp)
|
||||
num(g.data.armorClass(obj.Which)-obj.ArmorClass, 0, Armor), sp)
|
||||
|
||||
if !g.Options.Terse {
|
||||
pb.WriteString("protection ")
|
||||
@@ -250,6 +263,7 @@ func (g *RogueGame) dropRing(obj *Object) {
|
||||
|
||||
p.CurRing[hand] = nil
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingAddStrength:
|
||||
g.changeStrength(-obj.Bonus)
|
||||
@@ -346,6 +360,7 @@ func (g *RogueGame) newRingThing(cur *Object) {
|
||||
cur.Kind = KindRing
|
||||
|
||||
cur.Which = pickOne(g, g.Items.Rings[:])
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch cur.RingKind() {
|
||||
case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage:
|
||||
if cur.Bonus = g.rnd(3); cur.Bonus == 0 {
|
||||
|
||||
Rozdílový obsah nebyl zobrazen, protože je příliš veliký
Načíst rozdílové porovnání
+6
-6
@@ -118,7 +118,7 @@ const (
|
||||
Known // ISKNOW: player knows details about the object
|
||||
Missile // ISMISL: object is a missile type
|
||||
Stackable // ISMANY: object comes in groups
|
||||
WasFound // ISFOUND (objects): object has been seen (ISFOUND shares the bit with creatures)
|
||||
WasFound // ISFOUND (objects): seen; bit shared with creatures
|
||||
Protected // ISPROT: armor is permanently protected
|
||||
)
|
||||
|
||||
@@ -140,18 +140,18 @@ type CreatureFlags int32
|
||||
// Creature state bits (rogue.h).
|
||||
const (
|
||||
CanConfuse CreatureFlags = 0o000001 // CANHUH: creature can confuse
|
||||
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: creature can see invisible creatures
|
||||
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: can see invisible creatures
|
||||
Blind CreatureFlags = 0o000004 // ISBLIND: creature is blind
|
||||
Cancelled CreatureFlags = 0o000010 // ISCANC: creature has special qualities cancelled
|
||||
Cancelled CreatureFlags = 0o000010 // ISCANC: special qualities cancelled
|
||||
Levitating CreatureFlags = 0o000010 // ISLEVIT: hero is levitating
|
||||
Found CreatureFlags = 0o000020 // ISFOUND: creature has been seen
|
||||
Greedy CreatureFlags = 0o000040 // ISGREED: creature runs to protect gold
|
||||
Hasted CreatureFlags = 0o000100 // ISHASTE: creature has been hastened
|
||||
Targeted CreatureFlags = 0o000200 // ISTARGET: creature is the target of an 'f' command
|
||||
Targeted CreatureFlags = 0o000200 // ISTARGET: target of an 'f' command
|
||||
Held CreatureFlags = 0o000400 // ISHELD: creature has been held
|
||||
Confused CreatureFlags = 0o001000 // ISHUH: creature is confused
|
||||
Invisible CreatureFlags = 0o002000 // ISINVIS: creature is invisible
|
||||
Mean CreatureFlags = 0o004000 // ISMEAN: creature can wake when player enters room
|
||||
Mean CreatureFlags = 0o004000 // ISMEAN: wakes when player enters room
|
||||
Hallucinating CreatureFlags = 0o004000 // ISHALU: hero is on acid trip
|
||||
Regenerates CreatureFlags = 0o010000 // ISREGEN: creature can regenerate
|
||||
Awake CreatureFlags = 0o020000 // ISRUN: creature is running at the player
|
||||
@@ -392,7 +392,7 @@ type Stone struct {
|
||||
}
|
||||
|
||||
// CTRL maps a letter to its control character, as the C CTRL() macro.
|
||||
func CTRL(c byte) byte { return c & 0o37 }
|
||||
func CTRL(c byte) byte { return c & 0o37 } //nolint:mnd // the C CTRL() mask
|
||||
|
||||
// distance returns the squared distance between two points (chase.c dist).
|
||||
func distance(y1, x1, y2, x2 int) int {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
@@ -158,6 +159,15 @@ type weaponSetup struct {
|
||||
|
||||
// initWeapon sets up a new weapon (weapons.c init_weapon).
|
||||
func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) {
|
||||
// init_dam[] has a row only for the real weapons: WeaponFlame (dragon
|
||||
// breath) and anything past it have none. createObj rejects such a
|
||||
// choice before calling here, so this arm is unreachable in practice;
|
||||
// it exists so a malformed kind leaves the weapon untouched instead of
|
||||
// panicking on the table read.
|
||||
if which < 0 || int(which) >= int(NumWeaponTypes) {
|
||||
return
|
||||
}
|
||||
|
||||
iwp := &g.data.initWeaps[which]
|
||||
weap.Kind = KindWeapon
|
||||
weap.Which = int(which)
|
||||
|
||||
+22
-1
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// wizard.c — special wizard commands, some of which are also non-wizard
|
||||
@@ -25,6 +26,24 @@ func (g *RogueGame) createObj() {
|
||||
obj.Count = 1
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
// Deliberate divergence from 5.4.4: C stored this nibble unchecked, so
|
||||
// 'a'-'f' indexed straight past the ends of the per-kind static
|
||||
// tables. Input outside '0'-'9' and 'a'-'f' overshoots much further:
|
||||
// readchar returns a byte, so ch-'a' above is byte arithmetic and
|
||||
// wraps instead of going negative ('A' gives 234, '!' gives 202).
|
||||
// Reading past a static array was undefined behavior C happened to
|
||||
// survive by picking up adjacent memory; in Go it is a panic that
|
||||
// kills the process with the terminal still in raw mode. C had no
|
||||
// defined behavior here to be faithful to, so the choice is rejected
|
||||
// outright rather than emulating a garbage read. The check precedes
|
||||
// every rnd() call below, so the RNG sequence is untouched either way.
|
||||
if !obj.wizardCanCreate() {
|
||||
g.msg("there is no such %s", obj.Kind)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindWeapon, KindArmor:
|
||||
g.createWeaponArmor(obj)
|
||||
@@ -69,7 +88,7 @@ func (g *RogueGame) createWeaponArmor(obj *Object) {
|
||||
return
|
||||
}
|
||||
|
||||
obj.ArmorClass = g.data.aClass[obj.Which]
|
||||
obj.ArmorClass = g.data.armorClass(obj.Which)
|
||||
if bless == '-' {
|
||||
obj.ArmorClass += g.rnd(3) + 1
|
||||
}
|
||||
@@ -82,6 +101,7 @@ func (g *RogueGame) createWeaponArmor(obj *Object) {
|
||||
// createRing sets up a wizard-created ring, prompting for a bonus on
|
||||
// the bonus rings (the ring arm of wizard.c create_obj).
|
||||
func (g *RogueGame) createRing(obj *Object) {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage:
|
||||
g.msg("blessing? (+,-,n)")
|
||||
@@ -136,6 +156,7 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) {
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindScroll:
|
||||
setKnow(obj, g.Items.Scrolls[:])
|
||||
|
||||
Rozdílový obsah nebyl zobrazen, protože je příliš veliký
Načíst rozdílové porovnání
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# script/lint: lint in docker. golangci-lint is never installed on the host.
|
||||
#
|
||||
# Traps, each of which yields a green run over an unlinted or partly linted
|
||||
# tree:
|
||||
#
|
||||
# 1. --target and --no-cache-filter must both stay, and $stage must match
|
||||
# the stage name in Dockerfile.lint. BuildKit ignores --no-cache-filter
|
||||
# when no stage matches its argument, serving the lint layer from cache
|
||||
# without a word; --target rejects a name that is not in the file, which
|
||||
# is what makes the single $stage safe.
|
||||
#
|
||||
# 2. --target checks that the stage exists, not that it is the stage
|
||||
# running golangci-lint, and it halts the build there. Moving the lint
|
||||
# step to another stage, or adding a stage after it, is not caught.
|
||||
#
|
||||
# 3. .dockerignore decides what reaches the container, and only what
|
||||
# reaches it is linted. Excluding a self-contained Go file drops it from
|
||||
# the lint silently. Never exclude Go sources, go.mod/go.sum or
|
||||
# .golangci.yml.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Must match the stage name in Dockerfile.lint.
|
||||
stage=lint
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build \
|
||||
--target "$stage" \
|
||||
--no-cache-filter="$stage" \
|
||||
--output=type=cacheonly \
|
||||
-f Dockerfile.lint .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+39
-8
@@ -78,9 +78,20 @@ func (t *Tcell) Render(w *game.Window) {
|
||||
t.screen.Show()
|
||||
}
|
||||
|
||||
// Repaint redraws the whole physical screen from tcell's content buffer
|
||||
// — which holds what Render last blitted, so this is C's clearok(curscr,
|
||||
// TRUE) + wrefresh(curscr) (command.c, the CTRL('R') arm) rather than a
|
||||
// fresh draw of stdscr. Sync throws away tcell's record of what the
|
||||
// terminal is showing, so unlike Show it repaints cells it believes are
|
||||
// already correct, which is what makes it fix a corrupted screen.
|
||||
func (t *Tcell) Repaint() {
|
||||
t.screen.Sync()
|
||||
}
|
||||
|
||||
// ReadChar blocks for the next key, translated to the byte codes the C
|
||||
// game reads: arrows become hjkl, control keys their C0 codes.
|
||||
func (t *Tcell) ReadChar() byte {
|
||||
// game reads: arrows become hjkl, control keys their C0 codes. ok is
|
||||
// false when Interrupt woke the read instead of a key arriving.
|
||||
func (t *Tcell) ReadChar() (byte, bool) {
|
||||
for {
|
||||
ev := t.screen.PollEvent()
|
||||
switch ev := ev.(type) {
|
||||
@@ -88,14 +99,32 @@ func (t *Tcell) ReadChar() byte {
|
||||
if t.last != nil {
|
||||
t.Render(t.last)
|
||||
}
|
||||
case *tcell.EventInterrupt:
|
||||
// Interrupt posted this from the signal goroutine: hand
|
||||
// control back so the game goroutine can service a pending
|
||||
// autosave, then it reads again.
|
||||
return 0, false
|
||||
case *tcell.EventKey:
|
||||
if b, ok := translateKey(ev); ok {
|
||||
return b
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interrupt wakes a ReadChar parked in PollEvent by posting an interrupt
|
||||
// event onto tcell's own event queue — the mechanism tcell provides for
|
||||
// exactly this, and the only Tcell method called from another goroutine
|
||||
// (Screen.PostEvent is a channel send, safe to call concurrently).
|
||||
//
|
||||
// Best effort by design: PostEvent fails only when the event queue is
|
||||
// full, which means the game goroutine is not parked waiting for a key,
|
||||
// and a game goroutine that is running turns reaches the between-turns
|
||||
// check on its own.
|
||||
func (t *Tcell) Interrupt() {
|
||||
_ = t.screen.PostEvent(tcell.NewEventInterrupt(nil))
|
||||
}
|
||||
|
||||
// translateKey converts a key event to a game input byte; ok is false
|
||||
// for keys the C game does not understand.
|
||||
func translateKey(ev *tcell.EventKey) (byte, bool) {
|
||||
@@ -128,6 +157,7 @@ func namedKey(k tcell.Key) (byte, bool) {
|
||||
// motionKey translates the arrow and paging keys to Rogue's movement
|
||||
// letters (tcell.go ReadChar).
|
||||
func motionKey(k tcell.Key) (byte, bool) {
|
||||
//nolint:exhaustive // translation table: all other keys fall through
|
||||
switch k {
|
||||
case tcell.KeyUp:
|
||||
return 'k', true
|
||||
@@ -153,19 +183,20 @@ func motionKey(k tcell.Key) (byte, bool) {
|
||||
// editingKey translates the editing and control keys to their C0 codes
|
||||
// (tcell.go ReadChar).
|
||||
func editingKey(k tcell.Key) (byte, bool) {
|
||||
//nolint:exhaustive // translation table: all other keys fall through
|
||||
switch k {
|
||||
case tcell.KeyEnter:
|
||||
return '\n', true
|
||||
case tcell.KeyEscape:
|
||||
return game.Escape, true
|
||||
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
||||
return 8, true
|
||||
return '\b', true
|
||||
case tcell.KeyDelete:
|
||||
return 0x7f, true
|
||||
return '\x7f', true
|
||||
case tcell.KeyTab:
|
||||
return '\t', true
|
||||
case tcell.KeyCtrlC:
|
||||
return 3, true
|
||||
return '\x03', true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
@@ -188,8 +219,8 @@ func (t *Tcell) ShellEscape() {
|
||||
"[Entering shell; exit to return to the game]")
|
||||
|
||||
// The shell session has no deadline by design; Background context.
|
||||
cmd := exec.CommandContext(context.Background(), //nolint:gosec // G204: the user's own $SHELL
|
||||
shell)
|
||||
//nolint:gosec // G204: the user's own $SHELL
|
||||
cmd := exec.CommandContext(context.Background(), shell)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
Odkázat v novém úkolu
Zablokovat Uživatele