Compare commits
19 Commits
2e02e7d190
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ab4aba8b | |||
| 60442ce103 | |||
| 6f997b8d5c | |||
| 9f079ab594 | |||
| 3eb9f81fc4 | |||
| 20cfb47912 | |||
| 329c03f06e | |||
| 599286a88e | |||
| bde4eae450 | |||
|
|
3061931291 | ||
| 13caec4298 | |||
| df45f4cb24 | |||
| ba444a2002 | |||
| 6f409bda9e | |||
| c0741ad1ea | |||
| 29fbedb77d | |||
| bf820e3ec9 | |||
| c61e2827c5 | |||
| 2f7a0d980d |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
# Part of the lint gate: only what reaches the container is linted, so
|
||||
# excluding a self-contained Go source here drops it from the lint silently.
|
||||
# Never exclude Go sources, go.mod/go.sum or .golangci.yml.
|
||||
.git
|
||||
|
||||
# Generated artifacts only; `make build` puts a multi-megabyte binary here
|
||||
# and it would otherwise be shipped into the build context.
|
||||
/build/
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
*.log
|
||||
*.out
|
||||
*.test
|
||||
/build/
|
||||
/rogue
|
||||
|
||||
20
Dockerfile.lint
Normal file
20
Dockerfile.lint
Normal file
@@ -0,0 +1,20 @@
|
||||
# Lint image, built by script/lint: golangci-lint runs as a build step, so
|
||||
# a successful build is a clean lint.
|
||||
|
||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# This stage must stay the one that runs golangci-lint, and its name must
|
||||
# match $stage in script/lint. --target halts the build at this stage, so
|
||||
# moving the lint step to another stage, or adding a stage after this one,
|
||||
# is not caught.
|
||||
FROM deps AS lint
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
47
Makefile
47
Makefile
@@ -1,18 +1,47 @@
|
||||
# Development convenience targets. This repo is exempt from the standard
|
||||
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
|
||||
# is only a thin wrapper around the Go toolchain, golangci-lint, and
|
||||
# prettier so `make fmt` / `make check` behave the same as in sneak's
|
||||
# other repos.
|
||||
# policy scaffold (no CI config, no REPO_POLICIES.md, no application
|
||||
# Dockerfile) except for the lint container: per sneak's 2026-08-09
|
||||
# ruling, linting runs in docker only, so Dockerfile.lint and script/lint
|
||||
# are part of this repo. This Makefile is otherwise only a thin wrapper
|
||||
# around the Go toolchain and prettier so `make fmt` / `make check` behave
|
||||
# the same as in sneak's other repos.
|
||||
|
||||
GO_PKGS := ./...
|
||||
MD_FILES := $(shell git ls-files '*.md')
|
||||
PRETTIER := prettier --tab-width 4 --prose-wrap always
|
||||
|
||||
.PHONY: check fmt fmt-check lint test
|
||||
# Every generated artifact goes here, and the whole directory is
|
||||
# git-ignored. Targets that write outside it can commit their output.
|
||||
BUILD_DIR := build
|
||||
BIN := $(BUILD_DIR)/rogue
|
||||
COVERPROF := $(BUILD_DIR)/coverage.out
|
||||
COVERHTML := $(BUILD_DIR)/coverage.html
|
||||
|
||||
# Format, lint, and test — the full local pre-commit gate.
|
||||
.PHONY: build check cover cover-html fmt fmt-check lint test
|
||||
|
||||
# Format, lint, and test — the full local pre-commit gate. Keep this list
|
||||
# to targets that write nothing into the working tree.
|
||||
check: fmt-check lint test
|
||||
|
||||
# Build the executable into $(BUILD_DIR). `go build -o` does not create the
|
||||
# parent directory.
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build -o $(BIN) ./cmd/rogue
|
||||
|
||||
# Per-function coverage, for finding which functions are untested. The
|
||||
# percentage `make test` prints is a per-package total and cannot answer
|
||||
# that. Writes files, so it stays out of `check`.
|
||||
cover:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go test -timeout 30s -coverprofile=$(COVERPROF) $(GO_PKGS)
|
||||
go tool cover -func=$(COVERPROF)
|
||||
|
||||
# Render the same profile as annotated source.
|
||||
cover-html: cover
|
||||
go tool cover -html=$(COVERPROF) -o $(COVERHTML)
|
||||
@echo "wrote $(COVERHTML)"
|
||||
|
||||
# Format Go and Markdown in place.
|
||||
fmt:
|
||||
gofmt -w .
|
||||
@@ -26,9 +55,11 @@ fmt-check:
|
||||
fi
|
||||
$(PRETTIER) --check $(MD_FILES)
|
||||
|
||||
# Run the house linter (config in .golangci.yml).
|
||||
# Run the house linter. golangci-lint is never installed on the host: the
|
||||
# work happens inside the pinned container built by Dockerfile.lint, and
|
||||
# this target is a thin shim over the script that builds it.
|
||||
lint:
|
||||
golangci-lint run $(GO_PKGS)
|
||||
./script/lint
|
||||
|
||||
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
|
||||
# full output and still fail the target (the first run already proved the
|
||||
|
||||
24
README.md
24
README.md
@@ -21,19 +21,19 @@ original program structure and the design of this port.
|
||||
Requires Go 1.25 or later and a terminal at least 80x24.
|
||||
|
||||
```bash
|
||||
go build ./cmd/rogue
|
||||
./rogue
|
||||
make build
|
||||
./build/rogue
|
||||
```
|
||||
|
||||
```bash
|
||||
# Restore a saved game
|
||||
./rogue ~/rogue.save
|
||||
./build/rogue ~/rogue.save
|
||||
|
||||
# View high scores
|
||||
./rogue -s
|
||||
./build/rogue -s
|
||||
|
||||
# Test the death screen (demo mode)
|
||||
./rogue -d
|
||||
./build/rogue -d
|
||||
```
|
||||
|
||||
## In-game commands
|
||||
@@ -57,7 +57,7 @@ Press `?` in game for the full list.
|
||||
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
||||
|
||||
# Wizard (debug) mode, with a reproducible dungeon
|
||||
ROGUE_WIZARD=1 SEED=12345 ./rogue
|
||||
ROGUE_WIZARD=1 SEED=12345 ./build/rogue
|
||||
```
|
||||
|
||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
||||
@@ -77,10 +77,14 @@ sequences, dungeon-generation golden checks, and an RNG compatibility test
|
||||
against the original C generator.
|
||||
|
||||
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
||||
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race
|
||||
detector with coverage and a timeout), and `make check` (all three). Use the
|
||||
targets rather than invoking `go test` directly — they carry the flags the
|
||||
project relies on.
|
||||
prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
|
||||
pinned container built from `Dockerfile.lint` — it is never installed on the
|
||||
host, so docker is required), `make test` (the suite, under the race detector
|
||||
with coverage and a timeout), `make check` (all three), `make build` (the
|
||||
executable), and `make cover` / `make cover-html` (per-function coverage, and
|
||||
the same profile as annotated source at `build/coverage.html`). Everything they
|
||||
generate lands in the git-ignored `build/`. Use the targets rather than the
|
||||
toolchain directly — they carry the flags the project relies on.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
390
TODO.md
390
TODO.md
@@ -29,11 +29,373 @@ Refactor ground rules:
|
||||
|
||||
# Next Step
|
||||
|
||||
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
|
||||
wizard commands).
|
||||
Tag a release once a full game (Amulet retrieval and score entry) completes
|
||||
without defects. Promoted from Future Steps now that the coverage step above it
|
||||
is finished.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-10 `make cover` added (https://git.eeqj.de/sneak/rgoue/issues/17).
|
||||
`make cover` writes `build/coverage.out` and prints the per-function report;
|
||||
`make cover-html` renders the same profile to `build/coverage.html`. The
|
||||
per-package percentage `make test` prints cannot say _which_ function is
|
||||
untested, which is how the coverage gaps closed so far had to be found — by
|
||||
grepping test files for identifiers.
|
||||
|
||||
Neither target is in `check`, and neither may be added to it: both write
|
||||
files, and `make check` must not modify the working tree.
|
||||
|
||||
- 2026-08-10 `make build` added (https://git.eeqj.de/sneak/rgoue/issues/19). The
|
||||
executable is built to `build/rogue`; `README.md` no longer contains a raw
|
||||
`go` invocation anywhere. `build` is in neither `check` nor `test` —
|
||||
`make check` stays `fmt-check lint test` and still writes nothing into the
|
||||
working tree.
|
||||
|
||||
Generated artifacts now all live under `build/`, which `.gitignore` covers
|
||||
as a whole. Anything written outside it is committable, so a target that
|
||||
puts its output elsewhere reintroduces the stray-artifact problem.
|
||||
|
||||
- 2026-08-10 Linting moved into a container
|
||||
(https://git.eeqj.de/sneak/rgoue/issues/41). `golangci-lint` is no longer
|
||||
invoked on the host anywhere in the repo: `Dockerfile.lint` pins
|
||||
`golangci/golangci-lint:v2.12.2` by digest and runs the linter as a build
|
||||
step, so a successful build is a clean lint, and `make lint` is now a shim
|
||||
over `script/lint`. This is what killed the false green seen earlier, where a
|
||||
branch that was genuinely red with a `goconst` finding reported `0 issues` off
|
||||
the shared host cache; a container per run has its own cache and lock.
|
||||
|
||||
`script/lint` builds with `--target "$stage"`, `--no-cache-filter="$stage"`
|
||||
and `--output=type=cacheonly`. The durable property to check when touching
|
||||
any of this: the lint stage executes on every run and is never served from
|
||||
cache. Three things no tooling checks, left to whoever edits the gate —
|
||||
`$stage` must match the stage name in `Dockerfile.lint`; that stage must
|
||||
stay the one running `golangci-lint`, since `--target` halts the build
|
||||
there; and `.dockerignore` governs what reaches the container, so excluding
|
||||
a self-contained Go source drops it from the lint silently.
|
||||
|
||||
Verified rather than assumed, since a green docker build is the classic
|
||||
false green: two consecutive runs on an unchanged tree each showed the
|
||||
`golangci-lint run` layer executing and reporting `0 issues.` while the
|
||||
`deps` layers reported `CACHED`; the same build with `--no-cache-filter`
|
||||
removed reported that layer `CACHED`, so the re-execution is attributable to
|
||||
the flag rather than to a changed context; deliberate violations failed the
|
||||
build naming the specific finding and reverted clean; a stage-name typo
|
||||
failed loudly at exit 1; and a Go file excluded via `.dockerignore` reported
|
||||
`0 issues.` at exit 0 with the violation still in the tree. Wall-clock
|
||||
durations vary per host and per run, so they are not recorded here.
|
||||
|
||||
- 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause
|
||||
(`fix/autosave-turn-budget-36`, closes #36). The failure text was captured
|
||||
before anything was changed and it is **not** a data race: the assertion was
|
||||
`driveUntilDone`'s
|
||||
`t.Fatal("the turn loop ran out of turns before the saves were taken")`, with
|
||||
no `WARNING: DATA RACE` anywhere in the log. The handoff fixed in #24 was
|
||||
working; the test's own drive loop was running out of its fixed 1000-turn
|
||||
budget first.
|
||||
|
||||
Confirmed rather than taken on trust. Instrumenting the loop to report the
|
||||
turns it actually used showed the count tracking scheduling pressure and
|
||||
nothing else: about 60-120 turns at host load ~57 with the whole machine to
|
||||
spread over, 418 at `GOMAXPROCS=4`, 539 and 655 at 2 and 1, and past 1000 —
|
||||
the recorded failure — under the doubled load of the verbose rerun that the
|
||||
test target performs after a failure. The turns between one save being
|
||||
answered and the next request arriving are not work; they are the saving
|
||||
goroutine's wake-up latency, so a fixed turn count is a wall-clock
|
||||
assumption in disguise, which is why raising it would have hidden the flake
|
||||
rather than fixed it.
|
||||
|
||||
So the budget is gone rather than larger. `driveUntilDone` now drives until
|
||||
the saving goroutine finishes and nothing else. Termination is not lost, it
|
||||
just belongs to the code under test instead of to the test: every
|
||||
`AutoSaveOnSignal` returns within the timeout it is handed, so the saving
|
||||
goroutine always finishes. A handoff that has stopped answering costs one
|
||||
`autoSaveWait` in total — `g.sigSave` is one deep, so an unserviced request
|
||||
stays in the channel and every later call finds it full and fails at once —
|
||||
and the failure is then the real assertion (`saves taken = 0, want 25`)
|
||||
instead of "out of turns". The worst case is not that one: a handoff that
|
||||
drains each request but slower than `autoSaveWait` costs one timeout per
|
||||
save, `wantSaves × autoSaveWait` = 250s, which would run past the 30s
|
||||
package timeout instead of reaching the assertion. It takes ~10s of
|
||||
scheduler starvation per save against a measured 0.12s per 1000 turns, so it
|
||||
is remote, and the turn cap did not bound it either. The comment in the test
|
||||
states that bound rather than the optimistic one.
|
||||
|
||||
Removing the cap exposed a second assumption underneath it, which is the
|
||||
reason this is not a one-line diff. `testTerm` answers space and newline for
|
||||
ever once its script is exhausted, and neither key takes a turn, so
|
||||
`command()` — which loops until the player consumes one — never returns; the
|
||||
old cap was silently sized to the script (4000 characters, two per turn,
|
||||
against 1000 turns). An uncapped drive wedged inside a single `command()`
|
||||
call. The two drive tests therefore use a new `driveTerm`, a headless
|
||||
terminal whose script repeats. Repeating is necessary but not sufficient,
|
||||
and the test says so: `' '` clears `After` outright and all eight movement
|
||||
keys clear it on a refused step, so a script of only those keys wedges just
|
||||
as `testTerm`'s tail did. What makes the wedge impossible is that the cycle
|
||||
always holds an _unconditional_ turn-taker, and these scripts hold two —
|
||||
`'.'` (empty handler) and `'s'` (`search`, which writes `After` on no path),
|
||||
neither refusable by blocked-in-all-directions, `Held`, a bear trap, or
|
||||
`NoCommand > 0`. Removing both would bring the wedge back.
|
||||
|
||||
Both halves of the definition of done were demonstrated by mutation, with
|
||||
the deliberately-broken tree reverted afterwards and `.golangci.yml` left
|
||||
byte-identical (sha256 `021cc83f...46bcb`). Reverting #24 —
|
||||
`AutoSaveOnSignal` replaced by a direct `g.autoSave()`, encoding on the
|
||||
calling goroutine — still fails the test with 139 `WARNING: DATA RACE`
|
||||
reports naming `snapshotHeader` reading what `executeCommand` writes, so the
|
||||
guard is undiminished. Removing the `serviceAutoSaveRequest` call from
|
||||
`command()` still fails it too, now in 10s with `saves taken = 0, want 25`
|
||||
rather than by hanging.
|
||||
|
||||
Under load, an A/B at `GOMAXPROCS=2` on a 48-core host at load ~150, with an
|
||||
unrelated deliberate failure in the tree so that every run took the verbose
|
||||
rerun: the old code failed 8 of 8 runs with "ran out of turns"; the new code
|
||||
failed 0 of 8, the only failure being the planted one. Also green across 24
|
||||
concurrent unconstrained runs at load ~120, 10 runs alongside a spinner
|
||||
load, and 5 runs each at `GOMAXPROCS` 1, 2 and 4. `make check` green, lint 0
|
||||
issues.
|
||||
|
||||
- 2026-08-09 Wizard commands under test (`test/wizard-coverage`, closes #7): the
|
||||
last of the three thin spots, so the coverage step is now closed rather than
|
||||
narrowed. `game/wizard.go`'s eight functions had no tests of their own, and
|
||||
the file is not purely a debug surface — `set_know` writes the per-game
|
||||
discovered tables that name items in ordinary play, and `teleport` is what the
|
||||
teleport ring calls every fiftieth turn. Package coverage 60.6% -> 62.4%.
|
||||
Everything expected was transcribed from `wizard.c`, `command.c` (the
|
||||
`CTRL('I')` kit), `extern.c` (`a_class[]`), `weapons.c` (`init_dam[]`) and
|
||||
`rogue.h`; 30 mutations were tried and all 30 were caught.
|
||||
|
||||
Two findings came out of the reading. (1) **A wizard-created cursed weapon
|
||||
is not cursed, in C or here.** `create_obj` sets `ISCURSED` and then calls
|
||||
`init_weapon`, which _assigns_ `weap->o_flags = iwp->iw_flags` and so
|
||||
overwrites the bit it just set; only the `o_hplus` penalty survives, and the
|
||||
"cursed" weapon can still be dropped and unwielded. The port reproduces this
|
||||
exactly. The test asserts the whole flag word comes back as the `init_dam[]`
|
||||
row's value whatever blessing was answered, and deleting the `ISCURSED` line
|
||||
from the port leaves every weapon test green — which is the evidence that
|
||||
the line is dead for weapons. The armor arm has no such clobber and does
|
||||
keep the curse. (2) **`show_map`'s standout is asymmetric in C and symmetric
|
||||
here.** C tests `!(real & F_REAL)` before drawing and `!real` — the whole
|
||||
flag word — after. `new_level` seeds every square with `p_flags = F_REAL`,
|
||||
and exactly three sites clear that bit. `passages.c putpass` sets `F_PASS`
|
||||
first, so its secret passage is left at `0x80`. `passages.c door`'s
|
||||
secret-door arm clears it on a room-wall exit whose flags are still exactly
|
||||
`F_REAL` (`rooms.c` writes no `p_flags` at all), leaving `p_flags == 0`; its
|
||||
per-square gate is `rnd(5) == 0` against `putpass`'s `rnd(40) == 0`, and
|
||||
`game/passages.go`'s `door` reproduces it. `new_level`'s trap loop then ORs
|
||||
in `rnd(NTRAPS)`, which is `abs((int) RN) % 8` and so yields `0..7`, and
|
||||
`T_DOOR` is `00` — an unsprung trapdoor square is also exactly zero
|
||||
(`be_trapped` is what later ORs `F_SEEN` into it). So C _does_ turn standout
|
||||
off again, at secret doors and unsprung trapdoors; what it gets wrong is
|
||||
leaking the attribute forward from a secret passage or a non-trapdoor trap
|
||||
until it reaches one of those. Intermittent bands of reverse video, not a
|
||||
permanently reversed map. `game/wizard.go` tests `isReal` both times and
|
||||
highlights the one square. That is a display-only difference in a
|
||||
wizard-only command and was reported on the issue rather than changed here;
|
||||
the test asserts the map characters unconditionally but the standout
|
||||
attribute only up to the first secret square, so it pins nothing that C
|
||||
contradicts.
|
||||
|
||||
Two things the tests had to be built around. The `insist` arm of `whatis` is
|
||||
a loop whose only exits are picking a matching item and `n_objs == 0`, so a
|
||||
script that runs dry hangs instead of failing — every sequence that can
|
||||
re-prompt ends in an abort tail, the `n_objs == 0` exit is reached the way a
|
||||
player reaches it (`*` for a list with nothing appropriate in the pack)
|
||||
rather than by poking the counter, and the one mutation that deletes that
|
||||
exit is the only one of the 30 that fails by timeout instead of fast,
|
||||
necessarily so. And `show_map` does **not** mark squares seen — it writes
|
||||
into `hw` and touches no `PLACE` at all — so the issue's wording for it
|
||||
could not be tested as written; the loop bounds are asserted instead by
|
||||
planting a marker in the rows C's loop excludes, since those rows are blank
|
||||
on a real level and copying blanks over blanks would have made the bound
|
||||
unfalsifiable.
|
||||
|
||||
- 2026-08-09 Wands and staffs under test (`test/sticks-coverage`, closes #6):
|
||||
the second of the three thin spots the Next Step names. `game/sticks.go` was
|
||||
the largest under-tested file in the repo — 534 lines, 23 functions, one test
|
||||
— and now has `game/sticks_test.go` (the zap handlers, `drain`, `fix_stick`,
|
||||
`charge_str`) and `game/bolt_test.go` (the `fire_bolt` geometry). Every
|
||||
expectation was read out of `sticks.c` rather than off the Go code; **no
|
||||
divergence from C was found**, and three things worth knowing came out of the
|
||||
reading. (1) **The bolt trail is the test instrument.** `fire_bolt` paints
|
||||
each square with `dirch` and then paints `chat()` back over every square it
|
||||
recorded, so on a screen nothing else has drawn on, the non-blank cells
|
||||
afterwards are exactly the squares the bolt occupied — and the walls it
|
||||
bounced off are absent, because C undoes the record with `c1--` and `break`s
|
||||
before the `mvaddch`. That gives an exact assertion of the path and the
|
||||
resting place without touching game code, and it is why the tests fire from a
|
||||
square that is not the hero's (which is what `chase.c` does for dragon
|
||||
breath): with the hero off the ray the run produces one message and the screen
|
||||
stays readable. (2) **A bounce reverses both components of the direction, not
|
||||
one.** A bolt entering a wall at 45 degrees goes back the way it came instead
|
||||
of reflecting off the surface, so the diagonal-into-a-vertical-wall case is
|
||||
the one that separates C's rule from the plausible wrong one, and it is
|
||||
tested. (3) **The `ch != 'M'` guard on the miss message is a tautology.** `ch`
|
||||
comes from `winat`, and `winat` _is_ `t_disguise` when a monster stands there
|
||||
(`rogue.h` 57), so `ch == 'M'` implies `t_disguise == 'M'` and the arm can
|
||||
never go quiet; it is vestigial from when 'M' was the mimic, and the test pins
|
||||
the port to speaking, so nobody "tidies" it into a real silence. The
|
||||
door-under-hero exception has no assertion of its own because it cannot have
|
||||
one: without it the bolt bounces on the hero's own square forever, recording
|
||||
nothing, and `fire_bolt` never returns — the test for it hangs rather than
|
||||
fails, which the comment on it says. Determinism comes from a `pinRng` helper
|
||||
that searches for a seed whose next draw is the wanted value (running the real
|
||||
`Rng`, never predicting it) and from a level the tests carve themselves
|
||||
through `drawRoom`, since bounce geometry and `drain`'s room/passage/door
|
||||
reach only mean something against known walls and a known passage number. All
|
||||
27 mutations tried against the new tests were caught.
|
||||
|
||||
- 2026-08-09 Trap unit-test coverage (`test/traps-coverage`, closes #14):
|
||||
`trapHandlers` had eight entries and **zero** direct tests, on the one
|
||||
subsystem besides combat that can kill the hero outright. New
|
||||
`game/traps_test.go` (19 tests, 15 subtests) covers all eight arms of
|
||||
`move.c be_trapped`, the prologue every trap runs through, and the
|
||||
`rust_armor` tail `T_RUST` calls. Package coverage 56.2% -> 57.9% measured on
|
||||
`main` at `bf820e3`, the branch point, before the sticks tests landed. Every
|
||||
expected value is transcribed from `origin/c-master` and quoted in the file.
|
||||
**No divergence from C was found.**
|
||||
|
||||
The issue body's trap list was wrong and the correction is the first thing
|
||||
worth recording: there is no separate "poison dart" trap — `T_DART` **is**
|
||||
the poisoned dart, its death message being "a poisoned dart killed you" —
|
||||
and the list omitted `T_MYST`, the mystery trap, whose arm is an eleven-way
|
||||
`rnd(11)` message switch. `rogue.h` 192-200 is the authority
|
||||
(`T_DOOR`/`T_ARROW`/`T_SLEEP`/`T_BEAR`/`T_TELEP`/`T_DART`/`T_RUST`/`T_MYST`,
|
||||
`NTRAPS` 8) and the Go `TrapKind` iota matches it index-for-index.
|
||||
|
||||
Three C details the tests are built around. (1) `BEARTIME` and `SLEEPTIME`
|
||||
are `spread(3)` and `spread(5)` (`rogue.h` 108-109), and `spread` is
|
||||
`nm - nm/20 + rnd(nm/10)`; for both, `nm/10` is 0 and C's `rnd` short
|
||||
circuits a zero range without touching the generator, so each is an exact
|
||||
constant that costs **no** random number — and the tests assert the no-draw
|
||||
half as well as the value, because a stray draw desynchronises the
|
||||
seed-compatible stream. (2) `T_ARROW` swings at `s_lvl - 1` and `T_DART` at
|
||||
`s_lvl + 1`: opposite signs, which is exactly the kind of detail a
|
||||
transliterating port drops. (3) The strength loss is gated on
|
||||
`!ISWEARING(R_SUSTSTR) && !save(VS_POISON)`, and the `&&` is load-bearing —
|
||||
with the ring on, C never rolls the save, so the arm must spend two random
|
||||
numbers and not three.
|
||||
|
||||
Two shapes worth keeping, both forced by mutation results rather than
|
||||
foresight. Damage dice are checked by a **sweep**, not one shot: `rnd(n)` is
|
||||
"raw value % n", so a single draw agrees between a d6 and a d5 five times in
|
||||
six and leaves the generator identical either way — the first draft's
|
||||
single-trial arrow test passed with `roll(1,6)` mutated to `roll(1,5)`.
|
||||
Likewise the swing arguments are pinned by a 200-trial boundary sweep at a
|
||||
mid-range to-hit target: a forced hit and a forced miss cannot see a wrong
|
||||
`at_lvl` or a dropped `op_arm`, because both arms are reachable at any level
|
||||
and swing spends one `rnd(20)` regardless.
|
||||
|
||||
Mutation-proved, 33 mutations, each reverted, and every one of them is now
|
||||
caught. Three were **not** caught on the first pass and the tests were
|
||||
strengthened until they were, which is the useful part of the record. (a)
|
||||
Deleting `new_level()` from `T_DOOR` left the suite green: `be_trapped`'s
|
||||
own prologue stamps the trap glyph into the cell the hero fell through, so
|
||||
"the map changed" is true even with no new level dug. The test now counts
|
||||
differing cells — exactly one can change that way — and also requires the
|
||||
staircase to move and the hero to be re-placed. (b) The `roll(1,6)` case
|
||||
above.
|
||||
|
||||
(c) **`be_trapped` takes a coordinate, and which coordinate decides whether
|
||||
`T_TELEP`'s `mvaddch(tc, TRAP)` does anything.** Deleting that line first
|
||||
left the suite green, and the first draft wrote that off as an unavoidable
|
||||
redundancy — wrongly, because the test only exercised one of the two call
|
||||
sites. `move.go` 105-108 (`case Floor`) springs a trap under the hero and
|
||||
passes `p.Pos`; there `tc` **is** the hero's square, the prologue has
|
||||
already set its `p_ch` to `TRAP`, and `teleport()` opens by drawing
|
||||
`floor_at()` — which returns `chat(hero)` — over it, so the glyph is on
|
||||
screen before the line runs. But `move.go` 94-98 (`case Trap`), the ordinary
|
||||
walk onto a hidden trap, passes `nh`, the square being stepped **onto**,
|
||||
with the hero still on the previous square: `teleport()`'s opening `mvaddch`
|
||||
paints the old square, `leave_room` writes blanks and never `TRAP`, and
|
||||
nothing calls `look()` afterwards because the `case Trap` arm returns before
|
||||
`finishMove` for a teleporter. There `mvaddch(tc, TRAP)` is the only writer,
|
||||
exactly as C's comment says.
|
||||
`TestTrapTeleportDrawsTheTrapOnTheSquareSteppedOnto` springs the trap at a
|
||||
floor square next to the hero and pins it: unmutated the screen at `tc`
|
||||
reads `^`, with the line deleted it reads `.`.
|
||||
|
||||
The other 30 each failed their own test and only their own; two also moved
|
||||
`TestAutoSaveOnSignalRacesTurnLoop`, which drives real turns and is
|
||||
legitimately sensitive to `BEARTIME` and to armor rusting.
|
||||
|
||||
Deliberately uncovered: the two death messages, "an arrow killed you" and "a
|
||||
poisoned dart killed you". Each is printed immediately before `death()`,
|
||||
which reaches `myExit` and `os.Exit`, so provoking either would take the
|
||||
test binary with it; the hero is pinned with `fortify()` and the damage
|
||||
rolls are checked by replaying C's arithmetic instead of by letting HP reach
|
||||
zero. They are the only two: `rust_armor`'s `|| ISWEARING(R_SUSTARM)`
|
||||
operand and its `if (!to_death)` suppression of the rust-vanishes message,
|
||||
the last predicates that had no assertion, are pinned by
|
||||
`TestTrapRustHonoursTheRingAndTheToDeathFlag`. This entry does **not**
|
||||
rotate `Next Step`: #14 was an out-of-band gap found while surveying, not
|
||||
part of the rings/sticks/wizard step.
|
||||
|
||||
- 2026-08-09 Ring unit-test coverage (`test/rings-coverage`, closes #5): the
|
||||
first third of the standing coverage step. `game/rings.go` had **zero** tests
|
||||
— not one of the 32 in the suite touched wear, removal, hand choice, or the
|
||||
ring contribution to the hunger clock. New `game/rings_test.go` (17 tests, 44
|
||||
subtests) covers `ringOn`, `pickRingHand`, `ringOff`, `gethand`, `ringEat` and
|
||||
`ringNum`, plus the ring arm of `things.c dropcheck` (`dropRing`), which is
|
||||
what actually takes a ring off. Package coverage 53.7% -> 56.2%. `Next Step`
|
||||
narrowed rather than rotated: #6 and #7 are the other two thirds.
|
||||
|
||||
Every expected value is transcribed from `origin/c-master` (`rings.c`,
|
||||
`rogue.h`, `things.c`), never from what the port returns, and the C is
|
||||
quoted in the file. **No divergence from C was found**, which is the result
|
||||
and is worth recording as a negative: `ringEat` is the one function here
|
||||
whose being wrong would be invisible — it feeds `daemons.c`'s hunger clock,
|
||||
so a bad entry is a slow drift in when the hero starves rather than anything
|
||||
a playtest would notice — and it now has all fourteen ring kinds pinned to
|
||||
C's table.
|
||||
|
||||
Three C details the tests were written around. (1) `ring_eat`'s `uses[]`
|
||||
holds negatives, and a negative is **not** a cost: C computes
|
||||
`eat = (rnd(-eat) == 0)`, a one-in-n chance of a single unit. (2) `R_DIGEST`
|
||||
then flips the sign, so slow digestion returns 0 or **-1** and is the only
|
||||
ring that gives food back. (3) `ring_num`'s switch closes with the
|
||||
`otherwise` macro, which `rogue.h` 53 defines as `break;default` — so its
|
||||
four labels fall through to one `sprintf` and every other kind returns `""`
|
||||
from a default arm, not by falling off the end. The `RingKind` iota matches
|
||||
C's `R_` numbering index-for-index, so a `uses[]` index and a `RingKind` are
|
||||
the same number; `R_ADDHIT` is `RingDexterity` and `R_ADDDAM` is
|
||||
`RingIncreaseDamage`.
|
||||
|
||||
The chance rings are checked two ways at once. Each call snapshots the
|
||||
generator, runs `ringEat`, and replays C's own expression from the identical
|
||||
state — which pins the one-in-n denominator, the sign flip, and the fact
|
||||
that exactly one `rnd` call is spent — and a frequency check over 4000
|
||||
trials backs it with a number a human can read. The non-negative entries
|
||||
assert the reverse: the generator must be **untouched**, because C never
|
||||
reaches `rnd` on that path and a stray call there would desynchronise the
|
||||
whole game's RNG stream from C's and cost seed compatibility. That assertion
|
||||
is what caught the one real bug in this work, which was in the test and not
|
||||
the game: `g.Rng` is a pointer, so the first draft's snapshots aliased
|
||||
instead of copying.
|
||||
|
||||
Two shapes worth keeping. Scripted hand answers carry an abort tail (a space
|
||||
for the reprompt's `--More--`, then ESCAPE): without it a port that stopped
|
||||
accepting a key would loop forever on the headless terminal's filler input
|
||||
and the test would die of the 30s timeout instead of failing on its
|
||||
assertion — which is exactly what the first draft did, and it was only
|
||||
visible because the mutation run was inspected rather than trusted. And the
|
||||
"only one hand free" case scripts the _wrong_ hand key deliberately: a port
|
||||
that asked anyway consumes it and lands the ring on the wrong side, so the
|
||||
test fails on a hand rather than on a hang.
|
||||
|
||||
Mutation-proved, 23 mutations, each reverted: breaking `pickRingHand`'s
|
||||
ask/auto/reject arms, `ring_on`'s type guard, `is_current` guard and all
|
||||
three effect arms, `ring_off`'s no-rings message, hand selection and ESCAPE
|
||||
abort, `gethand`'s uppercase keys, ESCAPE and reprompt, `dropRing`'s hand
|
||||
clearing and both effect arms, `dropcheck`'s cursed gate, three `ringUses`
|
||||
entries, the `R_DIGEST` sign flip, the one-in-n roll, the empty-hand zero,
|
||||
and `ring_num`'s `ISKNOW` guard, label set and `RING`-vs-`WEAPON`
|
||||
formatting. Each failed its own test and only its own; stripping all three
|
||||
`ring_on` effect arms failed 3 of 3. All fourteen ring kinds are exercised;
|
||||
the eleven with no wear-time effect in C are documented at the foot of the
|
||||
file as deliberately not given a wear/remove test, with the files their
|
||||
powers actually live in, and `ring_off`'s unreachable "not wearing such a
|
||||
ring" arm is documented as unreachable rather than left looking untested.
|
||||
|
||||
- 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes
|
||||
#31): checked every case label in C's `command.c` against this port's
|
||||
dispatch, and left the audit behind as a standing test
|
||||
@@ -392,7 +754,11 @@ wizard commands).
|
||||
24 long lines wrapped or their comments tightened, control bytes in
|
||||
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
|
||||
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
|
||||
`make lint` runs whatever `golangci-lint` is on the host).
|
||||
`make lint` runs whatever `golangci-lint` is on the host). Superseded
|
||||
2026-08-10: there is a pin now, and no host lint path — `Dockerfile.lint` pins
|
||||
the linter image by digest and `script/lint` runs it in a container. See the
|
||||
2026-08-10 entry at the top of this section
|
||||
(https://git.eeqj.de/sneak/rgoue/issues/41).
|
||||
|
||||
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
|
||||
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
|
||||
@@ -536,13 +902,17 @@ wizard commands).
|
||||
|
||||
# Future Steps
|
||||
|
||||
1. Tag a release once a full game (Amulet retrieval and score entry) completes
|
||||
without defects.
|
||||
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
||||
1. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
||||
per-game dungeon dimensions instead of the 80x24 constants; open design
|
||||
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
||||
80x24 mode.
|
||||
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
||||
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
||||
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
||||
REPO_POLICIES.md.
|
||||
2. Note: this repo is exempt from the standard policy scaffold, but the
|
||||
exemption is narrower than it was. A minimal dev Makefile
|
||||
(fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07
|
||||
request. `Dockerfile.lint` and `script/lint` are now also permitted, and
|
||||
required, along with the `.dockerignore` that scopes their build context:
|
||||
sneak's 2026-08-09 ruling (https://git.eeqj.de/sneak/rgoue/issues/41) is that
|
||||
every repo lints in a container invoked through `script/lint`, and being
|
||||
later and explicit it overrides the 2026-07-07 exemption for those three
|
||||
files only. Still do not add: CI config, `REPO_POLICIES.md`, an application
|
||||
`Dockerfile`, or any other `script/` entrypoint.
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -17,7 +16,9 @@ import (
|
||||
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
|
||||
// expect the save to be taken. It is long enough that a loaded machine
|
||||
// cannot turn a working handoff into a spurious failure, and it is never
|
||||
// actually waited out on a passing run.
|
||||
// actually waited out on a passing run. It is also what bounds
|
||||
// driveUntilDone, by way of the saving goroutine it waits for — see
|
||||
// there for what that bound comes to.
|
||||
const autoSaveWait = 10 * time.Second
|
||||
|
||||
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
|
||||
@@ -34,12 +35,14 @@ const autoSaveWait = 10 * time.Second
|
||||
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Same mix as TestTurnLoopCrashSweep: the spaces answer any --More--
|
||||
// prompt, and the script is long enough that the drive never runs it
|
||||
// out.
|
||||
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
|
||||
// Same mix as TestTurnLoopCrashSweep — the spaces answer any --More--
|
||||
// prompt — on a driveTerm, so the drive can run for as long as the
|
||||
// saves take rather than for as long as a script lasts. The '.' and
|
||||
// the 's' are what make an unbounded drive safe, and at least one of
|
||||
// the two has to stay in the cycle: see driveTerm.
|
||||
term := &driveTerm{script: []byte("h j k l y u b n s . ")}
|
||||
|
||||
g := New(Params{Seed: 20260809, Term: &testTerm{input: script}})
|
||||
g := New(Params{Seed: 20260809, Term: term})
|
||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
@@ -74,14 +77,50 @@ func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
||||
|
||||
// driveUntilDone runs turns until the saving goroutine is finished,
|
||||
// fortifying the hero each turn so no death exits the test binary. The
|
||||
// turn cap keeps a broken handoff from hanging the suite instead of
|
||||
// failing it.
|
||||
// condition it waits on is that goroutine finishing — nothing else.
|
||||
//
|
||||
// It used to stop after a fixed 1000 turns and fail, and that cap was a
|
||||
// load-sensitive assumption wearing a counter's clothes (issue #36). The
|
||||
// turns this loop spends between one save request being answered and the
|
||||
// next arriving are not work; they are the saving goroutine's scheduling
|
||||
// latency, so the turn count 25 saves costs is a function of how
|
||||
// contended the machine is rather than of anything the code under test
|
||||
// does. Measured here on a 48-core host at load ~57: about 60-120 turns
|
||||
// with a whole machine to spread over, 418 to 655 as GOMAXPROCS was cut
|
||||
// from 4 to 1, and past 1000 under the doubled load of the verbose
|
||||
// rerun, which is the flake this replaces. A budget that has to be
|
||||
// guessed cannot be guessed right, so there is no budget.
|
||||
//
|
||||
// Dropping it costs no termination guarantee, because the bound belongs
|
||||
// to the code under test and not to this loop: each AutoSaveOnSignal
|
||||
// call returns within the timeout the caller hands it, so the saving
|
||||
// goroutine always finishes and done always closes. That bound is worth
|
||||
// stating exactly, because it is not one autoSaveWait.
|
||||
//
|
||||
// A handoff that has stopped answering altogether costs one, in total,
|
||||
// however many saves were asked for. g.sigSave
|
||||
// is one deep, so the unserviced request stays in the channel and every
|
||||
// later call finds it full and reports failure immediately — measured
|
||||
// at 10.0s for 25 saves with the service point deleted from command().
|
||||
// What fails is then the caller's own assertion, the count of saves
|
||||
// actually taken, which says far more than "out of turns" ever did.
|
||||
//
|
||||
// A handoff that still drains every request but takes longer than
|
||||
// autoSaveWait to do it is the worst case, and costs one timeout per
|
||||
// save: wantSaves * autoSaveWait, 250s at these constants, which would
|
||||
// run past the package timeout rather than reach the assertion. It
|
||||
// takes about ten seconds of scheduler starvation per save to get
|
||||
// there, against a regime measured at 0.12s per 1000 turns, so it is
|
||||
// remote — and the 1000-turn cap did not bound it either, a turn count
|
||||
// being no kind of time bound. `go test -timeout 30s` is the backstop
|
||||
// under all of it.
|
||||
//
|
||||
// The one thing the caller does have to supply is a terminal that can
|
||||
// feed an unbounded drive: see driveTerm.
|
||||
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
const maxTurns = 1000
|
||||
|
||||
for range maxTurns {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
@@ -91,8 +130,6 @@ func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
|
||||
fortify(g)
|
||||
g.command()
|
||||
}
|
||||
|
||||
t.Fatal("the turn loop ran out of turns before the saves were taken")
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
|
||||
@@ -266,9 +303,7 @@ func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
|
||||
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 5, Term: &testTerm{
|
||||
input: []byte(strings.Repeat("s . ", 200)),
|
||||
}})
|
||||
g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}})
|
||||
g.FileName = ""
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
@@ -437,6 +472,66 @@ func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
|
||||
return g
|
||||
}
|
||||
|
||||
// driveTerm is a headless Terminal whose script repeats instead of
|
||||
// running out, for the tests that drive the turn loop until something
|
||||
// else finishes rather than for a set number of turns.
|
||||
//
|
||||
// testTerm cannot do that job. Once its script is exhausted it answers
|
||||
// space and newline for ever, and neither takes a turn, so command() —
|
||||
// which loops until the player does something that consumes one, the
|
||||
// `if !g.After { ntimes++ }` in command.c — never returns. A drive with
|
||||
// a turn cap sized to its script never notices; a drive that runs until
|
||||
// the saves are taken wedges inside a single command() call, which is
|
||||
// what a first attempt at issue #36 did.
|
||||
//
|
||||
// Repeating the script is necessary but nowhere near sufficient, and
|
||||
// the difference is what anyone editing one of these scripts has to
|
||||
// know. Most keys take a turn only conditionally. ' ' is the "legal
|
||||
// illegal command" and clears After outright (tables.go). All eight
|
||||
// movement keys clear it whenever the step is refused: a wall or the
|
||||
// map edge (move.go moveResolve), an illegal diagonal (moveTarget), or
|
||||
// a confused step that lands back in place (moveHero). A script of
|
||||
// nothing but those keys wedges exactly the way testTerm's tail does,
|
||||
// repetition or no repetition — with the script set to just " " this
|
||||
// drive hits the 30s package timeout inside command().
|
||||
//
|
||||
// What actually makes the wedge impossible is that the cycle always
|
||||
// contains at least one *unconditional* turn-taker, and the scripts
|
||||
// here carry two: '.', the rest command, whose handler is empty, and
|
||||
// 's', search, which writes After on no path. Nothing refuses either
|
||||
// one — not being blocked in all eight directions, not Held, not stuck
|
||||
// in a bear trap, and not NoCommand > 0, where playTurn skips
|
||||
// executeCommand altogether and After is simply left true. Trim both
|
||||
// out and the wedge this test exists to remove comes straight back.
|
||||
//
|
||||
// One further precondition, from what this fake does not supply:
|
||||
// testTerm's tail answered a newline every other read and this does
|
||||
// not. Nothing reachable from these scripts asks for one — waitFor('\n')
|
||||
// sits on the death and score paths (rip.go, score.go), which fortify
|
||||
// prevents from ever being reached — but a script that could reach them
|
||||
// would park in waitFor for ever.
|
||||
type driveTerm struct {
|
||||
script []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (t *driveTerm) Render(*Window) {}
|
||||
|
||||
func (t *driveTerm) Repaint() {}
|
||||
|
||||
func (t *driveTerm) Fini() {}
|
||||
|
||||
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
|
||||
func (t *driveTerm) Interrupt() {}
|
||||
|
||||
// ReadChar hands out the next scripted key, wrapping at the end.
|
||||
func (t *driveTerm) ReadChar() (byte, bool) {
|
||||
ch := t.script[t.pos]
|
||||
t.pos = (t.pos + 1) % len(t.script)
|
||||
|
||||
return ch, true
|
||||
}
|
||||
|
||||
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
|
||||
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
|
||||
// block, cannot reproduce.
|
||||
|
||||
497
game/bolt_test.go
Normal file
497
game/bolt_test.go
Normal file
@@ -0,0 +1,497 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bolt geometry of sticks.c fire_bolt, tested on the hand-carved
|
||||
// level built by mkCarvedGame in sticks_test.go.
|
||||
//
|
||||
// Most of these fire from a square that is not the hero's, which is what
|
||||
// chase.c does when a dragon breathes (fire_bolt(&th->t_pos, ...)). That
|
||||
// keeps the hero off the ray, so the run produces exactly one message
|
||||
// and the screen stays readable as a record of where the bolt went — see
|
||||
// litCells.
|
||||
|
||||
// The three names sticks.c fires a bolt under (do_zap's WS_ELECT,
|
||||
// WS_FIRE and WS_COLD arms); fire_bolt prints them and hangs them on the
|
||||
// FLAME weapon-table entry.
|
||||
const (
|
||||
boltName = "bolt"
|
||||
flameName = "flame"
|
||||
iceName = "ice"
|
||||
)
|
||||
|
||||
// litCells reports every non-blank cell of the map area of the screen.
|
||||
//
|
||||
// fire_bolt paints its trail with dirch and then erases it by writing
|
||||
// back chat() for each square it recorded, so on a screen nothing else
|
||||
// has drawn on, the squares left non-blank are exactly the ones the bolt
|
||||
// occupied. Squares it bounced off are absent by construction: C undoes
|
||||
// the record with c1-- and breaks before the mvaddch, so a wall is
|
||||
// neither painted nor erased.
|
||||
func litCells(g *RogueGame) []Coord {
|
||||
var out []Coord
|
||||
// Row 0 is the message line, not the map.
|
||||
for y := 1; y < NumLines; y++ {
|
||||
line := g.scr.Std.Line(y)
|
||||
for x := range len(line) {
|
||||
if line[x] != ' ' {
|
||||
out = append(out, Coord{X: x, Y: y})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// assertErased checks that every square the bolt flew over is showing
|
||||
// the map character underneath it again: fire_bolt's closing loop paints
|
||||
// chat() back over the whole trail, so a bolt leaves no '/' or '\'
|
||||
// behind.
|
||||
func assertErased(t *testing.T, g *RogueGame, cells []Coord) {
|
||||
t.Helper()
|
||||
|
||||
for _, c := range cells {
|
||||
got := g.scr.Std.Line(c.Y)[c.X]
|
||||
if want := g.Level.Char(c.Y, c.X); got != want {
|
||||
t.Errorf("square %v shows %q, want the map's %q: the trail "+
|
||||
"was not erased", c, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoltDirChar covers the dirch switch for all eight directions. C
|
||||
// keys it on dir->y + dir->x: the two sums of zero are the '/' pair, the
|
||||
// two of magnitude two are the '\' pair, and the four axis directions
|
||||
// split on whether y is zero.
|
||||
func TestBoltDirChar(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dir Coord
|
||||
want byte
|
||||
}{
|
||||
{name: "north", dir: Coord{X: 0, Y: -1}, want: '|'},
|
||||
{name: "south", dir: Coord{X: 0, Y: 1}, want: '|'},
|
||||
{name: "east", dir: Coord{X: 1, Y: 0}, want: '-'},
|
||||
{name: "west", dir: Coord{X: -1, Y: 0}, want: '-'},
|
||||
{name: "north east", dir: Coord{X: 1, Y: -1}, want: '/'},
|
||||
{name: "south west", dir: Coord{X: -1, Y: 1}, want: '/'},
|
||||
{name: "north west", dir: Coord{X: -1, Y: -1}, want: '\\'},
|
||||
{name: "south east", dir: Coord{X: 1, Y: 1}, want: '\\'},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := boltDirChar(tt.dir); got != tt.want {
|
||||
t.Errorf("boltDirChar(%v) = %q, want %q", tt.dir, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoltBounces covers the case labels a bolt reflects off, and the
|
||||
// door exception: C jumps to the default arm when the hero is standing
|
||||
// on the door, "otherwise it would loop infinitely".
|
||||
func TestBoltBounces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const heroX, heroY = 5, 5
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ch byte
|
||||
pos Coord
|
||||
want bool
|
||||
}{
|
||||
{name: "vertical wall", ch: '|', pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{name: "horizontal wall", ch: '-', pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{name: "solid rock", ch: ' ', pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{name: "door", ch: Door, pos: Coord{X: 6, Y: 5}, want: true},
|
||||
{
|
||||
name: "the door under the hero",
|
||||
ch: Door,
|
||||
pos: Coord{X: heroX, Y: heroY},
|
||||
want: false,
|
||||
},
|
||||
{name: "floor", ch: Floor, pos: Coord{X: 6, Y: 5}, want: false},
|
||||
{name: "passage", ch: Passage, pos: Coord{X: 6, Y: 5}, want: false},
|
||||
{name: "staircase", ch: Stairs, pos: Coord{X: 6, Y: 5}, want: false},
|
||||
{name: "a monster", ch: 'Z', pos: Coord{X: 6, Y: 5}, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hero := Coord{X: heroX, Y: heroY}
|
||||
if got := boltBounces(tt.ch, hero, tt.pos); got != tt.want {
|
||||
t.Errorf("boltBounces(%q) = %v, want %v", tt.ch, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltFliesStraight is the end-to-end run with nothing in the
|
||||
// way: six squares, BOLT_LENGTH of them, and the last one is where the
|
||||
// bolt stops.
|
||||
func TestFireBoltFliesStraight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 41)
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
|
||||
g.fireBolt(Coord{X: 2, Y: 2}, &dir, flameName)
|
||||
|
||||
want := []Coord{
|
||||
{X: 3, Y: 2}, {X: 4, Y: 2}, {X: 5, Y: 2},
|
||||
{X: 6, Y: 2}, {X: 7, Y: 2}, {X: 8, Y: 2},
|
||||
}
|
||||
|
||||
got := litCells(g)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("bolt path = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
assertErased(t, g, got)
|
||||
|
||||
if g.Msgs.Huh != "" {
|
||||
t.Errorf("a bolt that hit nothing said %q", g.Msgs.Huh)
|
||||
}
|
||||
|
||||
if (dir != Coord{X: 1, Y: 0}) {
|
||||
t.Errorf("direction = %v, want it unchanged", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltBounces covers the reflection rule in both wall
|
||||
// orientations, off a corner, and — the case that separates C's rule
|
||||
// from a plausible wrong one — diagonally off a vertical wall. C negates
|
||||
// *both* components, so a bolt that came in at 45 degrees goes back the
|
||||
// way it came instead of reflecting off the surface.
|
||||
// boltBounceCase is one wall-bounce run: where the bolt sets off, which
|
||||
// way it goes, the wall it must reflect off, and the squares it must end
|
||||
// up having occupied.
|
||||
type boltBounceCase struct {
|
||||
name string
|
||||
start Coord
|
||||
dir Coord
|
||||
wall Coord
|
||||
want []Coord
|
||||
}
|
||||
|
||||
// run fires the case's bolt and checks its whole flight.
|
||||
func (tt boltBounceCase) run(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
g := mkCarvedGame(t, 42)
|
||||
dir := tt.dir
|
||||
|
||||
g.fireBolt(tt.start, &dir, flameName)
|
||||
|
||||
got := litCells(g)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("bolt path = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
assertErased(t, g, got)
|
||||
|
||||
if slices.Contains(got, tt.wall) {
|
||||
t.Errorf("the wall at %v was drawn on; C drops the bounce "+
|
||||
"square from spotpos before the mvaddch", tt.wall)
|
||||
}
|
||||
|
||||
if want := (Coord{X: -tt.dir.X, Y: -tt.dir.Y}); dir != want {
|
||||
t.Errorf("direction = %v after one bounce, want %v", dir, want)
|
||||
}
|
||||
|
||||
if g.Msgs.Huh != "the flame bounces" {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, "the flame bounces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireBoltBounces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []boltBounceCase{
|
||||
{
|
||||
name: "off a vertical wall",
|
||||
start: Coord{X: 3, Y: corridorY},
|
||||
dir: Coord{X: -1, Y: 0},
|
||||
wall: Coord{X: 1, Y: corridorY},
|
||||
// Five squares, not six: the square in front of the wall is
|
||||
// flown over twice, and C charges spotpos for both.
|
||||
want: []Coord{
|
||||
{X: 2, Y: 4}, {X: 3, Y: 4}, {X: 4, Y: 4},
|
||||
{X: 5, Y: 4}, {X: 6, Y: 4},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off a horizontal wall",
|
||||
start: Coord{X: 5, Y: 2},
|
||||
dir: Coord{X: 0, Y: -1},
|
||||
wall: Coord{X: 5, Y: 1},
|
||||
want: []Coord{
|
||||
{X: 5, Y: 2}, {X: 5, Y: 3}, {X: 5, Y: 4},
|
||||
{X: 5, Y: 5}, {X: 5, Y: 6}, {X: 5, Y: 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off a corner",
|
||||
start: Coord{X: 3, Y: 3},
|
||||
dir: Coord{X: -1, Y: -1},
|
||||
wall: Coord{X: 1, Y: 1},
|
||||
want: []Coord{
|
||||
{X: 2, Y: 2}, {X: 3, Y: 3}, {X: 4, Y: 4},
|
||||
{X: 5, Y: 5}, {X: 6, Y: 6},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diagonally off a vertical wall",
|
||||
start: Coord{X: 3, Y: corridorY},
|
||||
dir: Coord{X: -1, Y: -1},
|
||||
wall: Coord{X: 1, Y: 2},
|
||||
want: []Coord{
|
||||
{X: 2, Y: 3}, {X: 3, Y: 4}, {X: 4, Y: 5},
|
||||
{X: 5, Y: 6}, {X: 6, Y: 7},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tt.run(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltReboundsIntoHero covers the hit_hero/changed pair: a bolt
|
||||
// the hero fires starts unable to hit him, and the first bounce flips
|
||||
// that, so a wall one square away throws his own bolt back at him.
|
||||
func TestFireBoltReboundsIntoHero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lvl int
|
||||
wantMsg string
|
||||
wantHurt bool
|
||||
}{
|
||||
{
|
||||
name: "the hero saves",
|
||||
lvl: saveProofLvl,
|
||||
wantMsg: "the flame whizzes by you",
|
||||
wantHurt: false,
|
||||
},
|
||||
{
|
||||
name: "the hero is hit",
|
||||
lvl: 1,
|
||||
wantMsg: "you are hit by the flame",
|
||||
wantHurt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 43)
|
||||
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
|
||||
fortify(g) // a bolt to the face must not exit the test binary
|
||||
g.Player.Stats.Lvl = tt.lvl
|
||||
|
||||
pinRng(t, g, d20, 1) // the lowest save throw there is
|
||||
|
||||
hp := g.Player.Stats.HP
|
||||
dir := Coord{X: -1, Y: 0}
|
||||
|
||||
g.fireBolt(g.Player.Pos, &dir, flameName)
|
||||
|
||||
if g.Msgs.Huh != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
|
||||
}
|
||||
|
||||
lost := hp - g.Player.Stats.HP
|
||||
if hurt := lost > 0; hurt != tt.wantHurt {
|
||||
t.Errorf("hero lost %d hit points, want hurt = %v",
|
||||
lost, tt.wantHurt)
|
||||
}
|
||||
// roll(6, 6) is six to thirty-six.
|
||||
if tt.wantHurt && (lost < 6 || lost > 36) {
|
||||
t.Errorf("hero lost %d hit points, want 6..36", lost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltFromDoorUnderHeroTerminates covers the guard C wrote the
|
||||
// door case for: the hero standing on a door and firing into the wall
|
||||
// that door sits in. Without the ce(hero, pos) exception the bolt
|
||||
// bounces on his own square forever, never recording a spot and never
|
||||
// filling spotpos, and fire_bolt does not return — this test hangs
|
||||
// rather than fails if the exception is lost.
|
||||
func TestFireBoltFromDoorUnderHeroTerminates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 44)
|
||||
placeHero(g, Coord{X: doorAX, Y: corridorY})
|
||||
fortify(g)
|
||||
|
||||
pinRng(t, g, d20, 1) // no save: the strike ends the flight
|
||||
|
||||
hp := g.Player.Stats.HP
|
||||
dir := Coord{X: 0, Y: -1} // north, into the wall the door is in
|
||||
|
||||
g.fireBolt(g.Player.Pos, &dir, boltName)
|
||||
|
||||
if g.Msgs.Huh != "you are hit by the bolt" {
|
||||
t.Errorf("message = %q, want the hero to be hit", g.Msgs.Huh)
|
||||
}
|
||||
|
||||
if lost := hp - g.Player.Stats.HP; lost < 6 || lost > 36 {
|
||||
t.Errorf("hero lost %d hit points, want 6..36", lost)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltStrikesMonster covers the monster arm both ways, and the
|
||||
// dragon's immunity to flame that C spells out in the same breath.
|
||||
func TestFireBoltStrikesMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
typ byte
|
||||
lvl int
|
||||
bolt string
|
||||
wantMsg string
|
||||
wantHurt bool
|
||||
}{
|
||||
{
|
||||
name: "it fails its save",
|
||||
typ: 'Z',
|
||||
lvl: 1,
|
||||
bolt: boltName,
|
||||
wantMsg: "the bolt hits the zombie",
|
||||
wantHurt: true,
|
||||
},
|
||||
{
|
||||
name: "it saves",
|
||||
typ: 'Z',
|
||||
lvl: saveProofLvl,
|
||||
bolt: boltName,
|
||||
wantMsg: "the bolt whizzes past the zombie",
|
||||
wantHurt: false,
|
||||
},
|
||||
{
|
||||
name: "a dragon shrugs off a flame",
|
||||
typ: 'D',
|
||||
lvl: 1,
|
||||
bolt: flameName,
|
||||
wantMsg: "the flame bounces off the dragon",
|
||||
wantHurt: false,
|
||||
},
|
||||
{
|
||||
name: "but not a lightning bolt",
|
||||
typ: 'D',
|
||||
lvl: 1,
|
||||
bolt: boltName,
|
||||
wantMsg: "the bolt hits the dragon",
|
||||
wantHurt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 45)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
|
||||
tp := putMonster(g, tt.typ, Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = tt.lvl
|
||||
tp.Stats.HP = 500 // enough to survive 6x6 and stay assertable
|
||||
|
||||
pinRng(t, g, d20, 1) // the lowest save throw there is
|
||||
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
g.fireBolt(g.Player.Pos, &dir, tt.bolt)
|
||||
|
||||
if g.Msgs.Huh != tt.wantMsg {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tt.wantMsg)
|
||||
}
|
||||
|
||||
if hurt := tp.Stats.HP < 500; hurt != tt.wantHurt {
|
||||
t.Errorf("monster hit points = %d, want hurt = %v",
|
||||
tp.Stats.HP, tt.wantHurt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltMissedMonsterWakesUp covers the rest of the miss arm: a
|
||||
// bolt the hero fired sets the monster running (runto) before it says
|
||||
// what it whizzed past, and the bolt flies on for its full length.
|
||||
func TestFireBoltMissedMonsterWakesUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 46)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = saveProofLvl
|
||||
tp.Flags.Clear(Awake)
|
||||
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
g.fireBolt(g.Player.Pos, &dir, boltName)
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the monster the bolt missed is still asleep")
|
||||
}
|
||||
|
||||
if tp.Dest != &g.Player.Pos {
|
||||
t.Error("the woken monster is not chasing the hero")
|
||||
}
|
||||
|
||||
if tp.OldCh != Floor {
|
||||
t.Errorf("under-character = %q, want %q: fire_bolt records chat() "+
|
||||
"before it resolves the save", tp.OldCh, Floor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireBoltMissSpeaksEvenForAnM pins the "ch != 'M' ||
|
||||
// tp->t_disguise == 'M'" guard on C's miss message, which reads as
|
||||
// though something looking like an 'M' can be missed silently. It
|
||||
// cannot: ch comes from winat, and winat *is* t_disguise whenever a
|
||||
// monster stands there (rogue.h 57), so ch == 'M' implies
|
||||
// t_disguise == 'M' and the condition is always true. The guard is
|
||||
// vestigial — 'M' was the mimic in earlier Rogues — and a port that
|
||||
// "tidied" it into a real silence would go quiet where C speaks.
|
||||
func TestFireBoltMissSpeaksEvenForAnM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 47)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
|
||||
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = saveProofLvl
|
||||
tp.Flags.Clear(Awake)
|
||||
|
||||
dir := Coord{X: 1, Y: 0}
|
||||
g.fireBolt(g.Player.Pos, &dir, boltName)
|
||||
|
||||
const want = "the bolt whizzes past the medusa"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the missed medusa was not set running")
|
||||
}
|
||||
}
|
||||
751
game/rings_test.go
Normal file
751
game/rings_test.go
Normal file
@@ -0,0 +1,751 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rings_test.go covers rings.c — ring_on, gethand, ring_off, ring_eat and
|
||||
// ring_num — plus the ring arm of things.c dropcheck (dropRing), which is
|
||||
// what actually takes a worn ring off.
|
||||
//
|
||||
// Every expected value below is transcribed from the C reference on the
|
||||
// origin/c-master branch (rings.c, rogue.h, things.c), not from what this
|
||||
// port happens to return. A test that asserts the current Go behaviour
|
||||
// cannot catch the port drifting away from C, which is the only thing
|
||||
// these tests exist to do.
|
||||
//
|
||||
// The C constants in play (rogue.h 122-123 and 275-289):
|
||||
//
|
||||
// #define LEFT 0 #define RIGHT 1
|
||||
// R_PROTECT 0 R_ADDSTR 1 R_SUSTSTR 2 R_SEARCH 3
|
||||
// R_SEEINVIS 4 R_NOP 5 R_AGGR 6 R_ADDHIT 7
|
||||
// R_ADDDAM 8 R_REGEN 9 R_DIGEST 10 R_TELEPORT 11
|
||||
// R_STEALTH 12 R_SUSTARM 13 MAXRINGS 14
|
||||
//
|
||||
// The Go RingKind iota (types.go 303-316) runs in that same order, so a C
|
||||
// uses[] index and a Go RingKind are the same number. R_ADDHIT is the
|
||||
// dexterity ring (RingDexterity) and R_ADDDAM is RingIncreaseDamage.
|
||||
//
|
||||
// Nothing here can kill the hero — no ring path in rings.c touches HP,
|
||||
// food, or experience — so these tests need no fortify() pinning.
|
||||
|
||||
// C message text, verbatim, in the terse/verbose pairs C picks between.
|
||||
const (
|
||||
cWearingTwo = "you already have a ring on each hand"
|
||||
cWearingTwoTerse = "wearing two"
|
||||
cNotARing = "it would be difficult to wrap that around a finger"
|
||||
cNotARingTerse = "not a ring"
|
||||
cNoRings = "you aren't wearing any rings"
|
||||
cNoRingsTerse = "no rings"
|
||||
cInUse = "That's already in use"
|
||||
cCursed = "you can't. It appears to be cursed"
|
||||
)
|
||||
|
||||
// ringSeed is the fixed seed every test here runs on: nothing in rings.c
|
||||
// depends on the layout, but the RNG stream must be reproducible for the
|
||||
// ring_eat chance rolls.
|
||||
const ringSeed = 5
|
||||
|
||||
// mkRingGame builds a headless game with a clear message line, so that a
|
||||
// leftover mpos cannot turn the next msg() into a --More-- that eats the
|
||||
// scripted keystrokes.
|
||||
func mkRingGame(t *testing.T) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := mkGame(t, ringSeed)
|
||||
g.Msgs.Mpos = 0
|
||||
g.Msgs.Huh = ""
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// handKeys scripts an answer to gethand and appends an abort tail. Without
|
||||
// it, a port that stopped accepting the key under test would reprompt
|
||||
// forever against the headless terminal's filler input, and the test would
|
||||
// die of the 30s timeout instead of failing on its own assertion. The
|
||||
// space acknowledges the reprompt's --More-- and the ESCAPE makes gethand
|
||||
// give up, so the assertion gets to run and say what actually went wrong.
|
||||
// For the same reason, a call that must not prompt at all is scripted with
|
||||
// a lone ESCAPE rather than an empty script.
|
||||
func handKeys(keys ...byte) []byte {
|
||||
return append(keys, ' ', Escape)
|
||||
}
|
||||
|
||||
// mkRing builds a ring of the given kind; bonus is C's o_arm.
|
||||
func mkRing(kind RingKind, bonus int) *Object {
|
||||
obj := newObject()
|
||||
obj.Kind = KindRing
|
||||
obj.Which = int(kind)
|
||||
obj.Bonus = bonus
|
||||
obj.Count = 1
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// wear puts a ring straight onto a hand the way a restored save would,
|
||||
// bypassing ring_on's prompting and effects.
|
||||
func wear(g *RogueGame, hand int, obj *Object) *Object {
|
||||
give(g, obj)
|
||||
g.Player.CurRing[hand] = obj
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func handDesc(obj *Object) string {
|
||||
if obj == nil {
|
||||
return "empty"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ring kind %d", obj.RingKind())
|
||||
}
|
||||
|
||||
// assertHands pins both hands at once, which is what "no state change"
|
||||
// means for every rejection path in ring_on.
|
||||
func assertHands(t *testing.T, g *RogueGame, left, right *Object) {
|
||||
t.Helper()
|
||||
|
||||
if g.Player.CurRing[Left] != left {
|
||||
t.Errorf("left hand = %s, want %s",
|
||||
handDesc(g.Player.CurRing[Left]), handDesc(left))
|
||||
}
|
||||
|
||||
if g.Player.CurRing[Right] != right {
|
||||
t.Errorf("right hand = %s, want %s",
|
||||
handDesc(g.Player.CurRing[Right]), handDesc(right))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnUsesTheHandTheHeroPicks covers the first arm of C's ring_on
|
||||
// hand choice: "if (cur_ring[LEFT] == NULL && cur_ring[RIGHT] == NULL)
|
||||
// { if ((ring = gethand()) < 0) return; }".
|
||||
func TestRingOnUsesTheHandTheHeroPicks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key byte
|
||||
hand int
|
||||
}{
|
||||
{"lower l", 'l', Left},
|
||||
{"upper L", 'L', Left},
|
||||
{"lower r", 'r', Right},
|
||||
{"upper R", 'R', Right},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAdornment, 0)
|
||||
ch := give(g, ring)
|
||||
setInput(t, g, handKeys(ch, tc.key)...)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if tc.hand == Left {
|
||||
assertHands(t, g, ring, nil)
|
||||
} else {
|
||||
assertHands(t, g, nil, ring)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnEscapeFromGethandWearsNothing is the "< 0" half of that arm.
|
||||
func TestRingOnEscapeFromGethandWearsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAdornment, 0)
|
||||
ch := give(g, ring)
|
||||
setInput(t, g, ch, Escape)
|
||||
|
||||
g.ringOn()
|
||||
assertHands(t, g, nil, nil)
|
||||
}
|
||||
|
||||
// TestRingOnTakesTheOnlyFreeHandWithoutAsking covers C's second and third
|
||||
// arms — "else if (cur_ring[LEFT] == NULL) ring = LEFT" and the RIGHT
|
||||
// mirror — which must not prompt. The scripted hand key is deliberately
|
||||
// the wrong hand: a port that asked anyway would consume it and put the
|
||||
// ring on the occupied side's opposite, failing here instead of hanging.
|
||||
func TestRingOnTakesTheOnlyFreeHandWithoutAsking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
worn int
|
||||
free int
|
||||
badKey byte
|
||||
}{
|
||||
{"left already worn", Left, Right, 'l'},
|
||||
{"right already worn", Right, Left, 'r'},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
old := wear(g, tc.worn, mkRing(RingStealth, 0))
|
||||
ring := mkRing(RingAdornment, 0)
|
||||
ch := give(g, ring)
|
||||
setInput(t, g, handKeys(ch, tc.badKey)...)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Player.CurRing[tc.free] != ring {
|
||||
t.Errorf("free hand = %s, want the new ring",
|
||||
handDesc(g.Player.CurRing[tc.free]))
|
||||
}
|
||||
|
||||
if g.Player.CurRing[tc.worn] != old {
|
||||
t.Error("ring_on disturbed the hand that was already worn")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnWithBothHandsFullIsRejected covers C's final else arm. The
|
||||
// trailing ESCAPE is scripted so that a port which wrongly fell through
|
||||
// to gethand() aborts instead of looping on the exhausted script.
|
||||
func TestRingOnWithBothHandsFullIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{"wearing two, verbose", false, cWearingTwo},
|
||||
{"wearing two, terse", true, cWearingTwoTerse},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
g.Options.Terse = tc.terse
|
||||
left := wear(g, Left, mkRing(RingStealth, 0))
|
||||
right := wear(g, Right, mkRing(RingRegeneration, 0))
|
||||
ch := give(g, mkRing(RingAdornment, 0))
|
||||
setInput(t, g, ch, Escape)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Msgs.Huh != tc.want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
|
||||
}
|
||||
|
||||
assertHands(t, g, left, right)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnRejectsANonRing covers C's "if (obj->o_type != RING)" guard.
|
||||
func TestRingOnRejectsANonRing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{"not a ring, verbose", false, cNotARing},
|
||||
{"not a ring, terse", true, cNotARingTerse},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
g.Options.Terse = tc.terse
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
pot.Which = int(PotionHealing)
|
||||
ch := give(g, pot)
|
||||
setInput(t, g, ch, Escape)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Msgs.Huh != tc.want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
|
||||
}
|
||||
|
||||
assertHands(t, g, nil, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnRejectsARingAlreadyWorn covers C's "if (is_current(obj))
|
||||
// return", which sits between the type check and the hand choice.
|
||||
func TestRingOnRejectsARingAlreadyWorn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
worn := wear(g, Left, mkRing(RingStealth, 0))
|
||||
setInput(t, g, worn.PackCh, Escape)
|
||||
|
||||
g.ringOn()
|
||||
|
||||
if g.Msgs.Huh != cInUse {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, cInUse)
|
||||
}
|
||||
|
||||
assertHands(t, g, worn, nil)
|
||||
}
|
||||
|
||||
// TestRingOnAddStrengthAndRingOffReverseEachOther pins the R_ADDSTR arm
|
||||
// of ring_on ("case R_ADDSTR: chg_str(obj->o_arm)") against the R_ADDSTR
|
||||
// arm of things.c dropcheck ("chg_str(-obj->o_arm)").
|
||||
func TestRingOnAddStrengthAndRingOffReverseEachOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAddStrength, 2)
|
||||
ch := give(g, ring)
|
||||
base := g.Player.Stats.Str
|
||||
|
||||
setInput(t, g, handKeys(ch, 'l')...)
|
||||
g.ringOn()
|
||||
|
||||
if g.Player.Stats.Str != base+2 {
|
||||
t.Errorf("strength after wearing = %d, want %d",
|
||||
g.Player.Stats.Str, base+2)
|
||||
}
|
||||
|
||||
assertHands(t, g, ring, nil)
|
||||
|
||||
// Only the left hand is worn, so ring_off's "else if (cur_ring[RIGHT]
|
||||
// == NULL) ring = LEFT" arm picks it with no prompt.
|
||||
setInput(t, g, Escape)
|
||||
g.ringOff()
|
||||
|
||||
if g.Player.Stats.Str != base {
|
||||
t.Errorf("strength after removal = %d, want %d",
|
||||
g.Player.Stats.Str, base)
|
||||
}
|
||||
|
||||
assertHands(t, g, nil, nil)
|
||||
}
|
||||
|
||||
// TestRingOnSeeInvisibleAndRingOffUndoIt pins the R_SEEINVIS arms:
|
||||
// invis_on() on the way in, unsee() plus extinguish(unsee) on the way
|
||||
// out. The pending fuse stands in for a potion of see invisible still
|
||||
// running, which is the only way the extinguish is observable.
|
||||
func TestRingOnSeeInvisibleAndRingOffUndoIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingSeeInvisible, 0)
|
||||
ch := give(g, ring)
|
||||
|
||||
setInput(t, g, handKeys(ch, 'r')...)
|
||||
g.ringOn()
|
||||
|
||||
if !g.Player.On(CanSeeInvisible) {
|
||||
t.Error("ring of see invisible did not set CanSeeInvisible")
|
||||
}
|
||||
|
||||
g.Fuse(DUnsee, 0, 100, After)
|
||||
|
||||
setInput(t, g, Escape)
|
||||
g.ringOff()
|
||||
|
||||
if g.Player.On(CanSeeInvisible) {
|
||||
t.Error("taking the ring off left CanSeeInvisible set")
|
||||
}
|
||||
|
||||
if g.findSlot(DUnsee) != nil {
|
||||
t.Error("taking the ring off did not extinguish the unsee fuse")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOnAggravateMonstersWakesThem pins the R_AGGR arm, which calls
|
||||
// aggravate() — misc.c walks every monster through runTo, setting ISRUN.
|
||||
func TestRingOnAggravateMonstersWakesThem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
tp := spawnAdjacent(g, 'Z')
|
||||
tp.Flags.Clear(Awake)
|
||||
|
||||
ring := mkRing(RingAggravateMonsters, 0)
|
||||
ch := give(g, ring)
|
||||
|
||||
setInput(t, g, handKeys(ch, 'l')...)
|
||||
g.ringOn()
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("ring of aggravate monsters did not wake the monster")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGethand covers rings.c gethand end to end. The bad-key case needs
|
||||
// the extra space: the reprompt happens with mpos still set from "please
|
||||
// type L or R", so endmsg puts up a --More-- that wait_for absorbs.
|
||||
func TestGethand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input []byte
|
||||
want int
|
||||
}{
|
||||
{"l", []byte{'l'}, Left},
|
||||
{"L", []byte{'L'}, Left},
|
||||
{"r", []byte{'r'}, Right},
|
||||
{"R", []byte{'R'}, Right},
|
||||
{"escape aborts", []byte{Escape}, -1},
|
||||
{"bad key reprompts", []byte{'x', ' ', 'r'}, Right},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
setInput(t, g, handKeys(tc.input...)...)
|
||||
|
||||
if got := g.gethand(); got != tc.want {
|
||||
t.Errorf("gethand() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOffWithNoRingsSaysSo covers ring_off's first arm.
|
||||
func TestRingOffWithNoRingsSaysSo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{"no rings, verbose", false, cNoRings},
|
||||
{"no rings, terse", true, cNoRingsTerse},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
g.Options.Terse = tc.terse
|
||||
setInput(t, g, Escape)
|
||||
|
||||
g.ringOff()
|
||||
|
||||
if g.Msgs.Huh != tc.want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOffWithBothHandsWornAsksWhich covers ring_off's else arm, both
|
||||
// the answer and the "(ring = gethand()) < 0" abort.
|
||||
func TestRingOffWithBothHandsWornAsksWhich(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key byte
|
||||
gone int
|
||||
stays int
|
||||
}{
|
||||
{"takes off the left", 'l', Left, Right},
|
||||
{"takes off the right", 'r', Right, Left},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
rings := [2]*Object{
|
||||
Left: wear(g, Left, mkRing(RingStealth, 0)),
|
||||
Right: wear(g, Right, mkRing(RingRegeneration, 0)),
|
||||
}
|
||||
setInput(t, g, handKeys(tc.key)...)
|
||||
|
||||
g.ringOff()
|
||||
|
||||
if g.Player.CurRing[tc.gone] != nil {
|
||||
t.Errorf("chosen hand still holds %s",
|
||||
handDesc(g.Player.CurRing[tc.gone]))
|
||||
}
|
||||
|
||||
if g.Player.CurRing[tc.stays] != rings[tc.stays] {
|
||||
t.Error("ring_off cleared the hand that was not chosen")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingOffEscapeKeepsBothRings is the abort half of that arm.
|
||||
func TestRingOffEscapeKeepsBothRings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
left := wear(g, Left, mkRing(RingStealth, 0))
|
||||
right := wear(g, Right, mkRing(RingRegeneration, 0))
|
||||
setInput(t, g, Escape)
|
||||
|
||||
g.ringOff()
|
||||
assertHands(t, g, left, right)
|
||||
}
|
||||
|
||||
// TestRingOffCursedRingStaysOn covers the dropcheck gate ring_off runs
|
||||
// its removal through: things.c returns FALSE for an ISCURSED item after
|
||||
// printing this message, and the hand is left alone.
|
||||
func TestRingOffCursedRingStaysOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
ring := mkRing(RingAddStrength, -1)
|
||||
ring.Flags.Set(Cursed)
|
||||
wear(g, Left, ring)
|
||||
setInput(t, g, Escape)
|
||||
|
||||
g.ringOff()
|
||||
|
||||
if g.Msgs.Huh != cCursed {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, cCursed)
|
||||
}
|
||||
|
||||
assertHands(t, g, ring, nil)
|
||||
}
|
||||
|
||||
// cRingUse is one entry of the rings.c ring_eat uses[] table.
|
||||
type cRingUse struct {
|
||||
kind RingKind
|
||||
name string // the C R_ name, for failure messages
|
||||
uses int
|
||||
}
|
||||
|
||||
// cRingUses transcribes ring_eat's static uses[] verbatim:
|
||||
//
|
||||
// static int uses[] = {
|
||||
// 1, /* R_PROTECT */ 1, /* R_ADDSTR */
|
||||
// 1, /* R_SUSTSTR */ -3, /* R_SEARCH */
|
||||
// -5, /* R_SEEINVIS */ 0, /* R_NOP */
|
||||
// 0, /* R_AGGR */ -3, /* R_ADDHIT */
|
||||
// -3, /* R_ADDDAM */ 2, /* R_REGEN */
|
||||
// -2, /* R_DIGEST */ 0, /* R_TELEPORT */
|
||||
// 1, /* R_STEALTH */ 1 /* R_SUSTARM */
|
||||
// };
|
||||
//
|
||||
// A negative entry is not a cost: C computes eat = (rnd(-eat) == 0), a
|
||||
// one-in-n chance of a single unit. R_DIGEST then flips the sign, so slow
|
||||
// digestion returns 0 or -1 and is the only ring that gives food back.
|
||||
func cRingUses() []cRingUse {
|
||||
return []cRingUse{
|
||||
{RingProtection, "R_PROTECT", 1},
|
||||
{RingAddStrength, "R_ADDSTR", 1},
|
||||
{RingSustainStrength, "R_SUSTSTR", 1},
|
||||
{RingSearching, "R_SEARCH", -3},
|
||||
{RingSeeInvisible, "R_SEEINVIS", -5},
|
||||
{RingAdornment, "R_NOP", 0},
|
||||
{RingAggravateMonsters, "R_AGGR", 0},
|
||||
{RingDexterity, "R_ADDHIT", -3},
|
||||
{RingIncreaseDamage, "R_ADDDAM", -3},
|
||||
{RingRegeneration, "R_REGEN", 2},
|
||||
{RingSlowDigestion, "R_DIGEST", -2},
|
||||
{RingTeleportation, "R_TELEPORT", 0},
|
||||
{RingStealth, "R_STEALTH", 1},
|
||||
{RingMaintainArmor, "R_SUSTARM", 1},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingEatMatchesTheCUsesTable exercises all fourteen ring kinds, both
|
||||
// hands, against the C table above. This is the highest-value assertion in
|
||||
// the file: ring_eat feeds the hunger clock through daemons.c, so a wrong
|
||||
// entry is a silent, slow divergence from C that no other test would see.
|
||||
func TestRingEatMatchesTheCUsesTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range cRingUses() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, hand := range []int{Left, Right} {
|
||||
g := mkRingGame(t)
|
||||
g.Player.CurRing[hand] = mkRing(tc.kind, 0)
|
||||
|
||||
if tc.uses >= 0 {
|
||||
assertFixedRingEat(t, g, hand, tc)
|
||||
} else {
|
||||
assertChanceRingEat(t, g, hand, tc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertFixedRingEat checks a non-negative uses[] entry. C returns it
|
||||
// unchanged and, just as importantly, never reaches rnd() on that path —
|
||||
// so the generator must be untouched, or the whole game's RNG stream
|
||||
// desynchronises from C's and seed compatibility is gone.
|
||||
func assertFixedRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
|
||||
t.Helper()
|
||||
|
||||
for range 4 {
|
||||
before := *g.Rng
|
||||
|
||||
if got := g.ringEat(hand); got != tc.uses {
|
||||
t.Fatalf("ringEat(%d) for %s = %d, want C uses[] entry %d",
|
||||
hand, tc.name, got, tc.uses)
|
||||
}
|
||||
|
||||
if *g.Rng != before {
|
||||
t.Fatalf("ringEat for %s called rnd(); C only does that for a "+
|
||||
"negative uses[] entry", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertChanceRingEat checks a negative uses[] entry. Each call is replayed
|
||||
// against C's own expression from the identical generator state, which pins
|
||||
// the one-in-n denominator, the sign flip R_DIGEST gets, and the fact that
|
||||
// exactly one rnd() call is spent. The frequency check on top of that
|
||||
// fails loudly on a wrong denominator even if the replay were ever
|
||||
// weakened to agree with the code by construction.
|
||||
func assertChanceRingEat(t *testing.T, g *RogueGame, hand int, tc cRingUse) {
|
||||
t.Helper()
|
||||
|
||||
const trials = 4000
|
||||
|
||||
sign := 1
|
||||
if tc.kind == RingSlowDigestion {
|
||||
sign = -1 // rings.c: if (ring->o_which == R_DIGEST) eat = -eat
|
||||
}
|
||||
|
||||
nonzero := 0
|
||||
|
||||
for range trials {
|
||||
before := *g.Rng
|
||||
got := g.ringEat(hand)
|
||||
after := *g.Rng
|
||||
|
||||
// C: eat = (rnd(-eat) == 0), replayed from the same state.
|
||||
*g.Rng = before
|
||||
|
||||
want := 0
|
||||
if g.Rng.Rnd(-tc.uses) == 0 {
|
||||
want = 1
|
||||
}
|
||||
|
||||
want *= sign
|
||||
|
||||
if *g.Rng != after {
|
||||
t.Fatalf("ringEat for %s did not spend exactly one rnd(%d) call",
|
||||
tc.name, -tc.uses)
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("ringEat for %s = %d, want %d", tc.name, got, want)
|
||||
}
|
||||
|
||||
if want != 0 {
|
||||
nonzero++
|
||||
}
|
||||
}
|
||||
|
||||
assertOneInN(t, tc, nonzero, trials)
|
||||
}
|
||||
|
||||
// assertOneInN checks the observed rate against C's 1/n. The tolerance is
|
||||
// far tighter than the gap between the three denominators C uses (1/2,
|
||||
// 1/3, 1/5) and far wider than the sampling noise at this trial count.
|
||||
func assertOneInN(t *testing.T, tc cRingUse, nonzero, trials int) {
|
||||
t.Helper()
|
||||
|
||||
const tolerance = 0.03
|
||||
|
||||
rate := float64(nonzero) / float64(trials)
|
||||
want := 1 / float64(-tc.uses)
|
||||
|
||||
if math.Abs(rate-want) > tolerance {
|
||||
t.Errorf("%s fired %.3f of the time over %d trials, want ~%.3f "+
|
||||
"(C's one-in-%d)", tc.name, rate, trials, want, -tc.uses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingEatEmptyHandIsZero is C's "if ((ring = cur_ring[hand]) == NULL)
|
||||
// return 0" — the common case, since the hero usually wears nothing.
|
||||
func TestRingEatEmptyHandIsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkRingGame(t)
|
||||
before := *g.Rng
|
||||
|
||||
for _, hand := range []int{Left, Right} {
|
||||
if got := g.ringEat(hand); got != 0 {
|
||||
t.Errorf("ringEat(%d) with an empty hand = %d, want 0", hand, got)
|
||||
}
|
||||
}
|
||||
|
||||
if *g.Rng != before {
|
||||
t.Error("ringEat on an empty hand consumed RNG")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingNum covers rings.c ring_num. Its switch ends in the `otherwise`
|
||||
// macro, which rogue.h 53 defines as `break;default` — so the four labels
|
||||
// R_PROTECT, R_ADDSTR, R_ADDDAM and R_ADDHIT fall through to a single
|
||||
// sprintf(" [%s]", num(o_arm, 0, RING)) and every other kind returns ""
|
||||
// from the default arm before the buffer is ever reached. Unknown rings
|
||||
// return "" earlier still, from the ISKNOW guard.
|
||||
//
|
||||
// The game pointer is C's implicit global state; ring_num reads none of
|
||||
// it, and the port's signature only carries one to satisfy nameit's
|
||||
// prfunc type, so nil is the honest argument here.
|
||||
func TestRingNum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kind RingKind
|
||||
bonus int
|
||||
known bool
|
||||
want string
|
||||
}{
|
||||
{"R_PROTECT known", RingProtection, 2, true, " [+2]"},
|
||||
{"R_ADDSTR known", RingAddStrength, 1, true, " [+1]"},
|
||||
{"R_ADDDAM known", RingIncreaseDamage, -1, true, " [-1]"},
|
||||
{"R_ADDHIT known", RingDexterity, 3, true, " [+3]"},
|
||||
{"R_PROTECT cursed", RingProtection, -1, true, " [-1]"},
|
||||
{"R_ADDSTR unknown", RingAddStrength, 2, false, ""},
|
||||
{"R_SEARCH known", RingSearching, 2, true, ""},
|
||||
{"R_DIGEST known", RingSlowDigestion, 2, true, ""},
|
||||
{"R_NOP known", RingAdornment, 0, true, ""},
|
||||
{"R_SUSTARM known", RingMaintainArmor, 2, true, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
obj := mkRing(tc.kind, tc.bonus)
|
||||
if tc.known {
|
||||
obj.Flags.Set(Known)
|
||||
}
|
||||
|
||||
if got := ringNum(nil, obj); got != tc.want {
|
||||
t.Errorf("ringNum() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage of the fourteen ring kinds, for the record:
|
||||
//
|
||||
// All fourteen are exercised by TestRingEatMatchesTheCUsesTable and ten of
|
||||
// them by TestRingNum. Beyond that, only three kinds have a ring_on effect
|
||||
// at all — R_ADDSTR, R_SEEINVIS and R_AGGR — and each has its own test
|
||||
// above, paired with the dropcheck arm that undoes it. The remaining
|
||||
// eleven are deliberately not given a wear/remove test: in C they are
|
||||
// inert at wear time, their powers being read from ISWEARING() elsewhere
|
||||
// (R_SEARCH and R_TELEPORT in the command.c per-turn tail, R_PROTECT and
|
||||
// R_ADDHIT/R_ADDDAM in fight.c, R_REGEN and R_DIGEST in daemons.c,
|
||||
// R_SUSTSTR and R_SUSTARM in the drain paths, R_STEALTH in chase.c), so a
|
||||
// wear/remove assertion for them would test nothing that rings.c does.
|
||||
// Those call sites belong to their own files' tests, not to this one.
|
||||
//
|
||||
// One branch is intentionally unreachable rather than untested: ring_off's
|
||||
// "obj == NULL -> not wearing such a ring" cannot fire, because every arm
|
||||
// that reaches it has already established that the chosen hand is worn.
|
||||
// The port keeps C's defensive check; there is no state from which to
|
||||
// provoke it.
|
||||
874
game/sticks_test.go
Normal file
874
game/sticks_test.go
Normal file
@@ -0,0 +1,874 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The zap and bolt tests need a map they can reason about: real levels
|
||||
// put their rooms wherever rooms.c felt like, and sticks.c's geometry —
|
||||
// which square a bolt bounces off, which room a drain reaches — only
|
||||
// means anything against known walls, a known door, and a known passage
|
||||
// number. mkCarvedGame lays out two rooms joined by one corridor, using
|
||||
// the generator's own drawRoom so the wall characters (including the
|
||||
// '-' corners horiz() paints over vert()'s '|') are what a real level
|
||||
// would have:
|
||||
//
|
||||
// x: 1 20 40 59
|
||||
// y=1 -------------------- ------------------
|
||||
// |..................| |................|
|
||||
// y=4 |..................+########+................|
|
||||
// |..................| |................|
|
||||
// y=8 -------------------- ------------------
|
||||
const (
|
||||
carvedWidth = 20 // room width, both walls included
|
||||
carvedHeight = 8 // room height, both walls included
|
||||
roomAX = 1 // left wall of the west room
|
||||
roomBX = 40 // left wall of the east room
|
||||
carvedTopY = 1 // top wall of both rooms
|
||||
corridorY = 4 // row the corridor and doors run on
|
||||
doorAX = roomAX + carvedWidth - 1 // east wall of the west room
|
||||
carvedPass = 2 // passage number of the corridor
|
||||
)
|
||||
|
||||
// saveProofLvl makes save_throw(VS_MAGIC) succeed on every roll, so a
|
||||
// test can select the "it saved" arm without touching the RNG: C's
|
||||
// threshold is 14 + VS_MAGIC - lvl/2, which at level 40 is -3, and
|
||||
// roll(1,20) always clears that.
|
||||
const saveProofLvl = 40
|
||||
|
||||
// mkCarvedGame builds a game on the hand-carved level drawn above, with
|
||||
// the hero standing in the south-east corner of the west room — off
|
||||
// every row and column the bolt tests fire along.
|
||||
func mkCarvedGame(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := New(Params{Seed: seed, Term: &testTerm{}})
|
||||
for i := range g.Level.Places {
|
||||
g.Level.Places[i] = Place{Ch: ' ', Flags: FReal}
|
||||
}
|
||||
|
||||
for i, x := range [...]int{roomAX, roomBX} {
|
||||
rp := &g.Level.Rooms[i]
|
||||
*rp = Room{
|
||||
Pos: Coord{X: x, Y: carvedTopY},
|
||||
Max: Coord{X: carvedWidth, Y: carvedHeight},
|
||||
}
|
||||
g.drawRoom(rp)
|
||||
}
|
||||
|
||||
for i := 2; i < MaxRooms; i++ {
|
||||
g.Level.Rooms[i].Flags = Gone // rooms that are not there
|
||||
}
|
||||
|
||||
for x := doorAX + 1; x < roomBX; x++ {
|
||||
pp := g.Level.At(corridorY, x)
|
||||
pp.Ch = Passage
|
||||
pp.Flags = FReal | FPassage | carvedPass
|
||||
}
|
||||
// Doors carry the passage number in their low bits but not F_PASS,
|
||||
// exactly as passages.c numpass leaves them; roomin therefore reports
|
||||
// the room a door belongs to, and drain's corp lookup finds the
|
||||
// passage behind it.
|
||||
for _, x := range [...]int{doorAX, roomBX} {
|
||||
pp := g.Level.At(corridorY, x)
|
||||
pp.Ch = Door
|
||||
pp.Flags = FReal | carvedPass
|
||||
}
|
||||
|
||||
placeHero(g, Coord{X: roomAX + 17, Y: carvedTopY + 6})
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// placeHero moves the hero and keeps proom, oldpos and oldrp in step,
|
||||
// the way move.c and misc.c look do; a --More-- prompt redraws through
|
||||
// look, which reads all three.
|
||||
func placeHero(g *RogueGame, pos Coord) {
|
||||
g.Player.Pos = pos
|
||||
g.Player.Room = g.roomIn(pos)
|
||||
g.Oldpos = pos
|
||||
g.Oldrp = g.Player.Room
|
||||
}
|
||||
|
||||
// putMonster drops a monster of the given letter on a carved-level spot.
|
||||
func putMonster(g *RogueGame, typ byte, pos Coord) *Monster {
|
||||
tp := &Monster{}
|
||||
g.newMonster(tp, typ, pos)
|
||||
|
||||
return tp
|
||||
}
|
||||
|
||||
// pinRng rewinds the generator to a state whose next draw is exactly
|
||||
// want, so tests can choose a save-throw outcome or a polymorph letter
|
||||
// without assuming anything about the generator itself: the wanted
|
||||
// value is found by running the real Rng, not by predicting it.
|
||||
func pinRng(t *testing.T, g *RogueGame, draw func(*Rng) int, want int) {
|
||||
t.Helper()
|
||||
|
||||
for s := int32(1); s < 100000; s++ {
|
||||
probe := Rng{Seed: s}
|
||||
if draw(&probe) == want {
|
||||
g.Rng.Seed = s
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("no seed found whose next draw is %d", want)
|
||||
}
|
||||
|
||||
// d20 is the save_throw draw (monsters.c save_throw: roll(1, 20)).
|
||||
func d20(r *Rng) int { return r.Roll(1, 20) }
|
||||
|
||||
// zapWand builds a wand of the given kind with charges to spare.
|
||||
func zapWand(kind WandKind) *Object {
|
||||
obj := newObject()
|
||||
obj.Kind = KindWand
|
||||
obj.Which = int(kind)
|
||||
obj.Charges = 5
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// TestZapLightLightsTheRoom covers the WS_LIGHT arm: the room loses
|
||||
// ISDARK, the wand identifies itself, and the message is C's two-part
|
||||
// one (sticks.c 71-89).
|
||||
func TestZapLightLightsTheRoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 11)
|
||||
g.Player.Room.Flags.Set(Dark)
|
||||
|
||||
g.zapLight(zapWand(WandLight))
|
||||
|
||||
if g.Player.Room.Flags.Has(Dark) {
|
||||
t.Error("the room is still dark after a wand of light")
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandLight].Know {
|
||||
t.Error("the wand of light did not identify itself")
|
||||
}
|
||||
|
||||
const want = "the room is lit by a shimmering blue light"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapLightInPassageFades covers the ISGONE arm: a corridor is not a
|
||||
// room, so nothing is lit and the wand still becomes known.
|
||||
func TestZapLightInPassageFades(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 12)
|
||||
placeHero(g, Coord{X: doorAX + 3, Y: corridorY})
|
||||
g.Level.Rooms[0].Flags.Set(Dark)
|
||||
|
||||
g.zapLight(zapWand(WandLight))
|
||||
|
||||
if !g.Player.Room.Flags.Has(Gone) {
|
||||
t.Fatal("the hero is not in a passage; the test set-up is wrong")
|
||||
}
|
||||
|
||||
const want = "the corridor glows and then fades"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandLight].Know {
|
||||
t.Error("the wand of light did not identify itself in a corridor")
|
||||
}
|
||||
|
||||
if !g.Level.Rooms[0].Flags.Has(Dark) {
|
||||
t.Error("zapping in a corridor lit a room anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapDrainLifeTooWeakKeepsCharge covers C's early return: under two
|
||||
// hit points the zap is refused, and because C returns before the
|
||||
// switch falls out, o_charges-- never runs.
|
||||
func TestZapDrainLifeTooWeakKeepsCharge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 13)
|
||||
g.Player.Stats.HP = 1
|
||||
|
||||
wand := zapWand(WandDrainLife)
|
||||
ch := give(g, wand)
|
||||
setInput(t, g, ch)
|
||||
|
||||
g.doZap()
|
||||
|
||||
const want = "you are too weak to use it"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if wand.Charges != 5 {
|
||||
t.Errorf("charges = %d, want 5: the refused zap must not cost one",
|
||||
wand.Charges)
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP != 1 {
|
||||
t.Errorf("hit points = %d, want 1", g.Player.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainSplitsHitPoints covers sticks.c drain: the hero loses half
|
||||
// his hit points and the drainees each lose that half divided by their
|
||||
// number — monsters out of reach lose nothing.
|
||||
func TestDrainSplitsHitPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 14)
|
||||
placeHero(g, Coord{X: 5, Y: 3})
|
||||
g.Player.Stats.HP = 20
|
||||
|
||||
near := [2]*Monster{
|
||||
putMonster(g, 'Z', Coord{X: 7, Y: 3}),
|
||||
putMonster(g, 'Z', Coord{X: 9, Y: 5}),
|
||||
}
|
||||
far := putMonster(g, 'Z', Coord{X: roomBX + 5, Y: 3})
|
||||
|
||||
for _, tp := range []*Monster{near[0], near[1], far} {
|
||||
tp.Stats.HP = 100
|
||||
}
|
||||
|
||||
g.drain()
|
||||
|
||||
if g.Player.Stats.HP != 10 {
|
||||
t.Errorf("hero hit points = %d, want 10", g.Player.Stats.HP)
|
||||
}
|
||||
// 10 hit points spread over two drainees is 5 apiece.
|
||||
for i, tp := range near {
|
||||
if tp.Stats.HP != 95 {
|
||||
t.Errorf("drainee %d hit points = %d, want 95", i, tp.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
if far.Stats.HP != 100 {
|
||||
t.Errorf("the monster in the other room lost %d hit points",
|
||||
100-far.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainWithNoTargetsCostsNothing covers the cnt == 0 arm, which
|
||||
// returns before pstats.s_hpt is halved.
|
||||
func TestDrainWithNoTargetsCostsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 15)
|
||||
placeHero(g, Coord{X: 5, Y: 3})
|
||||
g.Player.Stats.HP = 20
|
||||
|
||||
g.drain()
|
||||
|
||||
const want = "you have a tingling feeling"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP != 20 {
|
||||
t.Errorf("hero hit points = %d, want 20: a drain that found nobody "+
|
||||
"returns before halving them", g.Player.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainKillsWeakMonster covers the other arm of drain's zot loop: a
|
||||
// drainee whose share of the hit points finishes it is killed outright.
|
||||
func TestDrainKillsWeakMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 29)
|
||||
placeHero(g, Coord{X: 5, Y: 3})
|
||||
g.Player.Stats.HP = 20
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 7, Y: 3})
|
||||
tp.Stats.HP = 3 // less than the ten points it is about to take
|
||||
|
||||
g.drain()
|
||||
|
||||
if len(g.Level.Monsters) != 0 {
|
||||
t.Error("the drained monster is still on the level")
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(7, 3) != nil {
|
||||
t.Error("the drained monster is still on the map")
|
||||
}
|
||||
|
||||
const want = "you have defeated the zombie"
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapSpeedTogglesHasteAndSlow covers both WS_HASTE_M and WS_SLOW_M
|
||||
// in both directions: C cancels the opposite condition when it is
|
||||
// already on, and only otherwise applies its own.
|
||||
func TestZapSpeedTogglesHasteAndSlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind WandKind
|
||||
start CreatureFlags
|
||||
wantHasted bool
|
||||
wantSlowed bool
|
||||
wantTurn bool
|
||||
}{
|
||||
{name: "haste a monster", kind: WandHasteMonster, wantHasted: true},
|
||||
{
|
||||
name: "haste cancels a slow",
|
||||
kind: WandHasteMonster,
|
||||
start: Slowed,
|
||||
},
|
||||
{
|
||||
name: "slow a monster",
|
||||
kind: WandSlowMonster,
|
||||
wantSlowed: true,
|
||||
wantTurn: true,
|
||||
},
|
||||
{
|
||||
name: "slow cancels a haste",
|
||||
kind: WandSlowMonster,
|
||||
start: Hasted,
|
||||
wantTurn: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 30)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
tp.Flags.Clear(Hasted | Slowed)
|
||||
tp.Flags.Set(tt.start)
|
||||
tp.Turn = false // only the slow arm sets t_turn
|
||||
|
||||
g.zapSpeed(zapWand(tt.kind))
|
||||
|
||||
if tp.On(Hasted) != tt.wantHasted {
|
||||
t.Errorf("hasted = %v, want %v", tp.On(Hasted), tt.wantHasted)
|
||||
}
|
||||
|
||||
if tp.On(Slowed) != tt.wantSlowed {
|
||||
t.Errorf("slowed = %v, want %v", tp.On(Slowed), tt.wantSlowed)
|
||||
}
|
||||
|
||||
if tp.Turn != tt.wantTurn {
|
||||
t.Errorf("turn = %v, want %v", tp.Turn, tt.wantTurn)
|
||||
}
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the zapped monster was not set running")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrainReaches pins the three-clause drainee test of sticks.c drain
|
||||
// one clause at a time: the hero's own room, the passage behind the door
|
||||
// he stands on (corp), and — only when he is in a passage — a door of
|
||||
// that same passage.
|
||||
func TestDrainReaches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
heroPos Coord
|
||||
monstPos Coord
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same room",
|
||||
heroPos: Coord{X: 5, Y: 3},
|
||||
monstPos: Coord{X: 9, Y: 6},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different room",
|
||||
heroPos: Coord{X: 5, Y: 3},
|
||||
monstPos: Coord{X: roomBX + 5, Y: 3},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "hero on a door reaches into that passage",
|
||||
heroPos: Coord{X: doorAX, Y: corridorY},
|
||||
monstPos: Coord{X: doorAX + 4, Y: corridorY},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "hero in the passage reaches its doors",
|
||||
heroPos: Coord{X: doorAX + 4, Y: corridorY},
|
||||
monstPos: Coord{X: roomBX, Y: corridorY},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "hero in the passage does not reach into a room",
|
||||
heroPos: Coord{X: doorAX + 4, Y: corridorY},
|
||||
monstPos: Coord{X: roomBX + 5, Y: 3},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 16)
|
||||
placeHero(g, tt.heroPos)
|
||||
tp := putMonster(g, 'Z', tt.monstPos)
|
||||
|
||||
var corp *Room
|
||||
if g.Level.Char(tt.heroPos.Y, tt.heroPos.X) == Door {
|
||||
corp = &g.Level.Passages[*g.Level.FlagsAt(
|
||||
tt.heroPos.Y, tt.heroPos.X)&FPassNum]
|
||||
}
|
||||
|
||||
inpass := g.Player.Room.Flags.Has(Gone)
|
||||
if got := g.drainReaches(tp, corp, inpass); got != tt.want {
|
||||
t.Errorf("drainReaches = %v, want %v (inpass=%v corp=%v)",
|
||||
got, tt.want, inpass, corp != nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapInvisibilityHidesMonster covers the WS_INVIS arm.
|
||||
func TestZapInvisibilityHidesMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 17)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
|
||||
g.zapInvisibility(zapWand(WandInvisibility))
|
||||
|
||||
if !tp.On(Invisible) {
|
||||
t.Error("the zapped monster is still visible")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapVictimReleasesFlytrap covers the shared preamble of C's
|
||||
// invisibility family: the flytrap holding the hero lets go the moment
|
||||
// the ray reaches it, whichever of those wands was zapped.
|
||||
func TestZapVictimReleasesFlytrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 18)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
g.Player.Flags.Set(Held)
|
||||
|
||||
tp := putMonster(g, 'F', Coord{X: 6, Y: corridorY})
|
||||
|
||||
g.zapInvisibility(zapWand(WandInvisibility))
|
||||
|
||||
if g.Player.On(Held) {
|
||||
t.Error("the flytrap still holds the hero after the zap")
|
||||
}
|
||||
|
||||
if !tp.On(Invisible) {
|
||||
t.Error("the flytrap was not made invisible")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapPolymorphReplacesMonster covers the WS_POLYMORPH arm and its
|
||||
// detach/re-attach dance: the creature keeps its identity (the same
|
||||
// THING, its pack, and the character it is standing on) but becomes a
|
||||
// different monster, listed once and standing where it stood.
|
||||
func TestZapPolymorphReplacesMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 19)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
pos := Coord{X: 8, Y: corridorY}
|
||||
tp := putMonster(g, 'K', pos)
|
||||
loot := newObject()
|
||||
loot.Kind = KindPotion
|
||||
tp.Pack = []*Object{loot}
|
||||
tp.OldCh = Stairs // it is standing on the staircase
|
||||
|
||||
const want = 'T'
|
||||
|
||||
pinRng(t, g, func(r *Rng) int { return r.Rnd(26) }, int(want-'A'))
|
||||
|
||||
g.zapPolymorph(zapWand(WandPolymorph))
|
||||
|
||||
if tp.Type != want || tp.Disguise != want {
|
||||
t.Errorf("monster is %q/%q after polymorph, want %q",
|
||||
tp.Type, tp.Disguise, want)
|
||||
}
|
||||
|
||||
if tp.Stats.Lvl != g.Monsters[want-'A'].Stats.Lvl {
|
||||
t.Errorf("level = %d, want the troll's %d: new_monster did not "+
|
||||
"re-roll the stats", tp.Stats.Lvl, g.Monsters[want-'A'].Stats.Lvl)
|
||||
}
|
||||
|
||||
if len(tp.Pack) != 1 || tp.Pack[0] != loot {
|
||||
t.Error("polymorph lost the monster's pack")
|
||||
}
|
||||
|
||||
if tp.OldCh != Stairs {
|
||||
t.Errorf("under-character = %q, want %q", tp.OldCh, Stairs)
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(pos.Y, pos.X) != tp || tp.Pos != pos {
|
||||
t.Error("the polymorphed monster is not where it stood")
|
||||
}
|
||||
|
||||
if n := len(g.Level.Monsters); n != 1 {
|
||||
t.Errorf("monster list holds %d entries, want 1: detach and "+
|
||||
"new_monster's attach must balance", n)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandPolymorph].Know {
|
||||
t.Error("a polymorph the hero watched did not identify the wand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapPolymorphClobbersDelta pins a C quirk the port keeps: do_zap
|
||||
// reuses the global delta as scratch for new_monster's coordinate, so
|
||||
// the zap direction is gone by the time the arm returns.
|
||||
func TestZapPolymorphClobbersDelta(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 20)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
pos := Coord{X: 8, Y: corridorY}
|
||||
putMonster(g, 'K', pos)
|
||||
|
||||
g.zapPolymorph(zapWand(WandPolymorph))
|
||||
|
||||
if g.Delta != pos {
|
||||
t.Errorf("delta = %v after polymorph, want the victim's %v",
|
||||
g.Delta, pos)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapCancellationClearsSpecials covers the WS_CANCEL arm. CANHUH is
|
||||
// set on the player and never on a monster in C (only scrolls.c sets
|
||||
// it), so the test puts it on by hand: the clear is written to take both
|
||||
// bits and the port must keep doing so.
|
||||
func TestZapCancellationClearsSpecials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 21)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'M', Coord{X: 8, Y: corridorY})
|
||||
tp.Flags.Set(Invisible | CanConfuse)
|
||||
|
||||
g.zapCancellation(zapWand(WandCancellation))
|
||||
|
||||
if !tp.On(Cancelled) {
|
||||
t.Error("the monster was not cancelled")
|
||||
}
|
||||
|
||||
if tp.On(Invisible) {
|
||||
t.Error("cancellation left the monster invisible")
|
||||
}
|
||||
|
||||
if tp.On(CanConfuse) {
|
||||
t.Error("cancellation left the monster able to confuse")
|
||||
}
|
||||
// t_disguise = t_type is an identity for every monster a zap ray can
|
||||
// actually stop on: the one disguised kind, the xeroc, looks like an
|
||||
// item, and step_ok is true for item characters, so the ray walks
|
||||
// straight past it. Pinned anyway, because C assigns it.
|
||||
if tp.Disguise != tp.Type {
|
||||
t.Errorf("disguise = %q, want %q", tp.Disguise, tp.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapTeleportToPullsMonsterIn covers WS_TELTO: the victim lands on
|
||||
// hero + delta, which is the square next to the hero along the ray.
|
||||
func TestZapTeleportToPullsMonsterIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 22)
|
||||
hero := Coord{X: 5, Y: corridorY}
|
||||
placeHero(g, hero)
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
from := Coord{X: 8, Y: corridorY}
|
||||
tp := putMonster(g, 'Z', from)
|
||||
|
||||
g.zapTeleport(zapWand(WandTeleportTo))
|
||||
|
||||
want := Coord{X: hero.X + 1, Y: hero.Y}
|
||||
if tp.Pos != want {
|
||||
t.Errorf("monster at %v after teleport-to, want %v", tp.Pos, want)
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(want.Y, want.X) != tp {
|
||||
t.Error("the map does not have the monster at its new spot")
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(from.Y, from.X) != nil {
|
||||
t.Error("the monster is still on the map where it came from")
|
||||
}
|
||||
|
||||
if tp.Dest != &g.Player.Pos {
|
||||
t.Error("the teleported monster is not chasing the hero")
|
||||
}
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("the teleported monster was not woken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapTeleportAwayMovesMonsterOff covers WS_TELAWAY, whose C loop
|
||||
// re-draws until the spot is not the hero's own.
|
||||
func TestZapTeleportAwayMovesMonsterOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 23)
|
||||
hero := Coord{X: 5, Y: corridorY}
|
||||
placeHero(g, hero)
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
from := Coord{X: 8, Y: corridorY}
|
||||
tp := putMonster(g, 'Z', from)
|
||||
|
||||
g.zapTeleport(zapWand(WandTeleportAway))
|
||||
|
||||
if tp.Pos == from {
|
||||
t.Error("teleport away did not move the monster")
|
||||
}
|
||||
|
||||
if tp.Pos == hero {
|
||||
t.Error("teleport away dropped the monster onto the hero")
|
||||
}
|
||||
|
||||
if g.Level.Char(tp.Pos.Y, tp.Pos.X) != Floor {
|
||||
t.Errorf("monster landed on %q, want floor",
|
||||
g.Level.Char(tp.Pos.Y, tp.Pos.X))
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(from.Y, from.X) != nil {
|
||||
t.Error("the monster is still on the map where it came from")
|
||||
}
|
||||
}
|
||||
|
||||
// vanishMsg is what C says when the missile finds nobody to hit, with
|
||||
// the original spelling of "missile" intact (sticks.c 191).
|
||||
//
|
||||
//nolint:misspell // C's spelling, kept faithfully
|
||||
const vanishMsg = "the missle vanishes with a puff of smoke"
|
||||
|
||||
// TestZapMagicMissile covers WS_MISSILE both ways: a victim that saves
|
||||
// gets C's puff-of-smoke message and no damage, one that does not is
|
||||
// hit by a bolt whose o_hplus of 100 cannot miss.
|
||||
func TestZapMagicMissile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lvl int
|
||||
wantMsg bool
|
||||
}{
|
||||
{name: "victim saves", lvl: saveProofLvl, wantMsg: true},
|
||||
{name: "victim is hit", lvl: 1, wantMsg: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 24)
|
||||
placeHero(g, Coord{X: 5, Y: corridorY})
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
|
||||
tp := putMonster(g, 'Z', Coord{X: 8, Y: corridorY})
|
||||
tp.Stats.Lvl = tt.lvl
|
||||
tp.Stats.HP = 500
|
||||
|
||||
pinRng(t, g, d20, 1) // the lowest save throw there is
|
||||
|
||||
g.zapMagicMissile(zapWand(WandMagicMissile))
|
||||
|
||||
if got := g.Msgs.Huh == vanishMsg; got != tt.wantMsg {
|
||||
t.Errorf("message = %q, want vanish = %v", g.Msgs.Huh, tt.wantMsg)
|
||||
}
|
||||
|
||||
if hurt := tp.Stats.HP < 500; hurt == tt.wantMsg {
|
||||
t.Errorf("hit points = %d, want damage = %v",
|
||||
tp.Stats.HP, !tt.wantMsg)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandMagicMissile].Know {
|
||||
t.Error("the magic missile wand did not identify itself")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixStickDamage covers the strcmp against ws_type: a staff swings
|
||||
// for 2x3, everything else for 1x1, and both hurl for 1x1.
|
||||
func TestFixStickDamage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
material string
|
||||
want string
|
||||
}{
|
||||
{material: staffName, want: "2x3"},
|
||||
{material: wandName, want: "1x1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.material, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 25)
|
||||
g.Items.WandType[WandCold] = tt.material
|
||||
|
||||
cur := newObject()
|
||||
cur.Kind = KindWand
|
||||
cur.Which = int(WandCold)
|
||||
g.fixStick(cur)
|
||||
|
||||
if !slices.Equal(cur.Damage, dice(tt.want)) {
|
||||
t.Errorf("damage = %v, want %v", cur.Damage, tt.want)
|
||||
}
|
||||
|
||||
if !slices.Equal(cur.HurlDmg, dice("1x1")) {
|
||||
t.Errorf("hurl damage = %v, want 1x1", cur.HurlDmg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixStickCharges covers the charge switch. C is rnd(10)+10 for the
|
||||
// wand of light and rnd(5)+3 for everything else, so both ends of both
|
||||
// ranges must show up over enough draws and nothing outside them ever.
|
||||
func TestFixStickCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind WandKind
|
||||
lo, hi int
|
||||
}{
|
||||
{name: "light", kind: WandLight, lo: 10, hi: 19},
|
||||
{name: "other", kind: WandCold, lo: 3, hi: 7},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 26)
|
||||
lo, hi := 1<<30, -1
|
||||
|
||||
for range 500 {
|
||||
cur := newObject()
|
||||
cur.Kind = KindWand
|
||||
cur.Which = int(tt.kind)
|
||||
g.fixStick(cur)
|
||||
lo = min(lo, cur.Charges)
|
||||
hi = max(hi, cur.Charges)
|
||||
}
|
||||
|
||||
if lo != tt.lo || hi != tt.hi {
|
||||
t.Errorf("charges ranged over %d..%d, want %d..%d",
|
||||
lo, hi, tt.lo, tt.hi)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChargeStr covers sticks.c charge_str: nothing at all until the
|
||||
// stick is known, then the terse or verbose bracket.
|
||||
func TestChargeStr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
known bool
|
||||
terse bool
|
||||
want string
|
||||
}{
|
||||
{name: "unknown", known: false, terse: false, want: ""},
|
||||
{name: "unknown and terse", known: false, terse: true, want: ""},
|
||||
{name: "known", known: true, terse: false, want: " [7 charges]"},
|
||||
{name: "known and terse", known: true, terse: true, want: " [7]"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 27)
|
||||
g.Options.Terse = tt.terse
|
||||
|
||||
obj := zapWand(WandCold)
|
||||
obj.Charges = 7
|
||||
|
||||
if tt.known {
|
||||
obj.Flags.Set(Known)
|
||||
}
|
||||
|
||||
if got := chargeStr(g, obj); got != tt.want {
|
||||
t.Errorf("chargeStr = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapBoltNames covers the name each of the three bolt wands fires
|
||||
// under (sticks.c 225-231), read back out of the weapon table entry
|
||||
// fire_bolt overwrites and out of the bounce message.
|
||||
func TestZapBoltNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
kind WandKind
|
||||
want string
|
||||
}{
|
||||
{kind: WandLightning, want: boltName},
|
||||
{kind: WandFire, want: flameName},
|
||||
{kind: WandCold, want: iceName},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkCarvedGame(t, 28)
|
||||
placeHero(g, Coord{X: roomAX + 1, Y: corridorY})
|
||||
g.Player.Stats.Lvl = saveProofLvl // never hurt by the rebound
|
||||
g.Delta = Coord{X: -1, Y: 0} // straight at the west wall
|
||||
|
||||
g.zapBolt(zapWand(tt.kind))
|
||||
|
||||
if got := g.Items.Weapons[WeaponFlame].Name; got != tt.want {
|
||||
t.Errorf("weapon name = %q, want %q", got, tt.want)
|
||||
}
|
||||
|
||||
if !strings.Contains(g.Msgs.Huh, tt.want) {
|
||||
t.Errorf("message = %q, want it to name the %q",
|
||||
g.Msgs.Huh, tt.want)
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[tt.kind].Know {
|
||||
t.Error("the bolt wand did not identify itself")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1140
game/traps_test.go
Normal file
1140
game/traps_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -530,3 +532,962 @@ func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) {
|
||||
t.Error("'+' consumed a turn; C sets after = FALSE")
|
||||
}
|
||||
}
|
||||
|
||||
// The rest of this file covers game/wizard.go proper (issue #7). Every
|
||||
// expected value below is transcribed from origin/c-master — wizard.c for
|
||||
// create_obj/whatis/set_know/teleport/show_map, command.c for the CTRL('I')
|
||||
// kit, extern.c for a_class[], weapons.c for init_dam[], and rogue.h for
|
||||
// the R_* numbering and the F_* place flags — never from what the port
|
||||
// happens to return.
|
||||
//
|
||||
// Two shapes recur. Scripted input always ends with an abort tail (a space
|
||||
// for a --More--, then ESCAPE), because testTerm.ReadChar hands out filler
|
||||
// forever once the script runs dry and a re-prompting loop would spin to
|
||||
// the suite timeout instead of failing. And where C issues no prompt at
|
||||
// all, the test asserts on the scripted input cursor rather than on state:
|
||||
// a stray readchar would eat the next answer and desynchronise everything
|
||||
// after it, which no state assertion would notice.
|
||||
|
||||
// mkWizard builds a headless game in wizard mode the way the program does.
|
||||
// cmd/rogue/main.go turns ROGUE_WIZARD into Params.Wizard and New consumes
|
||||
// that field, so no test here pokes g.Wizard. depth is what decides
|
||||
// whether the generator produces secret (non-F_REAL) squares at all.
|
||||
func mkWizard(t *testing.T, seed int32, depth int) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := New(Params{Seed: seed, Wizard: true, Term: &testTerm{}})
|
||||
if !g.Wizard {
|
||||
t.Fatal("Params.Wizard did not turn on wizard mode")
|
||||
}
|
||||
|
||||
g.Depth = depth
|
||||
g.NewLevel()
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// packSet snapshots pack membership by identity. add_pack files a new item
|
||||
// in kind order, so its position is no guide to which one it is.
|
||||
func packSet(g *RogueGame) map[*Object]bool {
|
||||
seen := make(map[*Object]bool, len(g.Player.Pack))
|
||||
for _, o := range g.Player.Pack {
|
||||
seen[o] = true
|
||||
}
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
// onlyNewItem returns the single object added to the pack since before.
|
||||
func onlyNewItem(t *testing.T, g *RogueGame, before map[*Object]bool) *Object {
|
||||
t.Helper()
|
||||
|
||||
var made []*Object
|
||||
|
||||
for _, o := range g.Player.Pack {
|
||||
if !before[o] {
|
||||
made = append(made, o)
|
||||
}
|
||||
}
|
||||
|
||||
if len(made) != 1 {
|
||||
t.Fatalf("pack gained %d objects, want exactly 1", len(made))
|
||||
}
|
||||
|
||||
return made[0]
|
||||
}
|
||||
|
||||
// inputUsed reports how many scripted keys have been consumed so far.
|
||||
func inputUsed(t *testing.T, g *RogueGame) int {
|
||||
t.Helper()
|
||||
|
||||
tt, ok := g.scr.term.(*testTerm)
|
||||
if !ok {
|
||||
t.Fatal("game terminal is not a testTerm")
|
||||
}
|
||||
|
||||
return tt.pos
|
||||
}
|
||||
|
||||
// TestCreateObjFilesTheItemInThePack covers the tail every arm of
|
||||
// wizard.c create_obj shares: o_group = 0, o_count = 1, then
|
||||
// add_pack(obj, FALSE). A potion is the kind C's switch does nothing for,
|
||||
// so nothing else is in the way.
|
||||
func TestCreateObjFilesTheItemInThePack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 21, 1)
|
||||
before := packSet(g)
|
||||
|
||||
setInput(t, g, Potion, '0', ' ', Escape)
|
||||
g.createObj()
|
||||
|
||||
made := onlyNewItem(t, g, before)
|
||||
|
||||
if made.Kind != KindPotion || made.Which != int(PotionConfusion) {
|
||||
t.Fatalf("created %v which %d, want %v which %d",
|
||||
made.Kind, made.Which, KindPotion, int(PotionConfusion))
|
||||
}
|
||||
|
||||
if made.Count != 1 {
|
||||
t.Errorf("count = %d, want the 1 C sets", made.Count)
|
||||
}
|
||||
|
||||
if made.Group != 0 {
|
||||
t.Errorf("group = %d, want the 0 C sets", made.Group)
|
||||
}
|
||||
|
||||
if made.PackCh == 0 {
|
||||
t.Error("created object has no pack letter: add_pack never filed it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateObjGoldAsksHowMuch covers the GOLD arm, C's
|
||||
// msg("how much?") followed by get_num(&obj->o_goldval, stdscr).
|
||||
func TestCreateObjGoldAsksHowMuch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 22, 1)
|
||||
before := packSet(g)
|
||||
|
||||
setInput(t, g, Gold, '0', '2', '5', '0', '\n', ' ', Escape)
|
||||
g.createObj()
|
||||
|
||||
made := onlyNewItem(t, g, before)
|
||||
|
||||
if made.Kind != KindGold {
|
||||
t.Fatalf("created %v, want %v", made.Kind, KindGold)
|
||||
}
|
||||
|
||||
if made.GoldValue != 250 {
|
||||
t.Errorf("gold value = %d, want the typed 250", made.GoldValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateWeaponBlessing pins the weapon arm of create_obj to C:
|
||||
//
|
||||
// if (bless == '-') obj->o_flags |= ISCURSED;
|
||||
// if (obj->o_type == WEAPON) {
|
||||
// init_weapon(obj, obj->o_which);
|
||||
// if (bless == '-') obj->o_hplus -= rnd(3)+1;
|
||||
// if (bless == '+') obj->o_hplus += rnd(3)+1;
|
||||
//
|
||||
// A curse subtracts and a blessing adds — the opposite of the armor arm
|
||||
// below, and rnd(3)+1 is 1..3 either way.
|
||||
//
|
||||
// The curse itself does not survive on a weapon, and that is C's own
|
||||
// behavior, not a port bug: weapons.c init_weapon *assigns*
|
||||
// weap->o_flags = iwp->iw_flags, so it overwrites the ISCURSED bit set
|
||||
// three lines earlier with the init_dam[] row's flags. A wizard-created
|
||||
// "cursed" weapon therefore carries only the hit penalty and can still be
|
||||
// dropped and unwielded. The mace row's flags are 0, so the whole word
|
||||
// must come back 0 here whatever was answered. The armor arm has no such
|
||||
// clobber, which is why TestCreateArmorBlessing does expect ISCURSED.
|
||||
func TestCreateWeaponBlessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
bless byte
|
||||
low, hi int
|
||||
}{
|
||||
{"no blessing", 'n', 0, 0},
|
||||
{"blessed adds to the hit bonus", '+', 1, 3},
|
||||
{"cursed subtracts from it", '-', -3, -1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 23, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = KindWeapon
|
||||
obj.Which = int(WeaponMace)
|
||||
|
||||
setInput(t, g, tc.bless, ' ', Escape)
|
||||
g.createWeaponArmor(obj)
|
||||
|
||||
if obj.Flags != 0 {
|
||||
t.Errorf("flags = %d, want the init_dam mace row's 0: "+
|
||||
"init_weapon assigns o_flags over any curse",
|
||||
obj.Flags)
|
||||
}
|
||||
|
||||
if obj.HPlus < tc.low || obj.HPlus > tc.hi {
|
||||
t.Errorf("hit bonus = %d, want %d..%d",
|
||||
obj.HPlus, tc.low, tc.hi)
|
||||
}
|
||||
|
||||
// init_weapon ran: the mace row of C's init_dam[].
|
||||
if got := obj.Damage.String(); got != "2x4" {
|
||||
t.Errorf("damage = %q, want the init_dam mace row 2x4", got)
|
||||
}
|
||||
|
||||
if got := obj.HurlDmg.String(); got != "1x3" {
|
||||
t.Errorf("hurl damage = %q, want 1x3", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateArmorBlessing pins the armor arm, where C moves o_arm the
|
||||
// other way because a lower armor class is better:
|
||||
//
|
||||
// obj->o_arm = a_class[obj->o_which];
|
||||
// if (bless == '-') obj->o_arm += rnd(3)+1;
|
||||
// if (bless == '+') obj->o_arm -= rnd(3)+1;
|
||||
//
|
||||
// extern.c's a_class[] has PLATE_MAIL at 3, so the three answers land at
|
||||
// 3, 0..2 and 4..6.
|
||||
func TestCreateArmorBlessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
bless byte
|
||||
cursed bool
|
||||
low, hi int
|
||||
}{
|
||||
{"no blessing leaves the table value", 'n', false, 3, 3},
|
||||
{"blessed lowers the armor class", '+', false, 0, 2},
|
||||
{"cursed raises it", '-', true, 4, 6},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 24, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = KindArmor
|
||||
obj.Which = int(ArmorPlateMail)
|
||||
|
||||
setInput(t, g, tc.bless, ' ', Escape)
|
||||
g.createWeaponArmor(obj)
|
||||
|
||||
if got := obj.Flags.Has(Cursed); got != tc.cursed {
|
||||
t.Errorf("cursed = %v, want %v", got, tc.cursed)
|
||||
}
|
||||
|
||||
if obj.ArmorClass < tc.low || obj.ArmorClass > tc.hi {
|
||||
t.Errorf("armor class = %d, want %d..%d",
|
||||
obj.ArmorClass, tc.low, tc.hi)
|
||||
}
|
||||
|
||||
// The armor arm must not fall into init_weapon.
|
||||
if obj.Kind != KindArmor || obj.Which != int(ArmorPlateMail) {
|
||||
t.Errorf("armor became %v which %d", obj.Kind, obj.Which)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateRingBonus covers the four bonus rings, C's
|
||||
// obj->o_arm = (bless == '-' ? -1 : rnd(2) + 1), where rnd(2)+1 is 1..2.
|
||||
// R_ADDHIT is RingDexterity and R_ADDDAM is RingIncreaseDamage; the
|
||||
// RingKind iota matches C's R_ numbering index for index.
|
||||
func TestCreateRingBonus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rings := []RingKind{
|
||||
RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage,
|
||||
}
|
||||
|
||||
blessings := []struct {
|
||||
name string
|
||||
bless byte
|
||||
cursed bool
|
||||
low, hi int
|
||||
}{
|
||||
{"blessed", '+', false, 1, 2},
|
||||
{"unblessed", 'n', false, 1, 2},
|
||||
{"cursed", '-', true, -1, -1},
|
||||
}
|
||||
|
||||
for _, ring := range rings {
|
||||
for _, tc := range blessings {
|
||||
t.Run(ringTestName(ring, tc.name), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 25, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = KindRing
|
||||
obj.Which = int(ring)
|
||||
|
||||
setInput(t, g, tc.bless, ' ', Escape)
|
||||
g.createRing(obj)
|
||||
|
||||
if got := obj.Flags.Has(Cursed); got != tc.cursed {
|
||||
t.Errorf("cursed = %v, want %v", got, tc.cursed)
|
||||
}
|
||||
|
||||
if obj.Bonus < tc.low || obj.Bonus > tc.hi {
|
||||
t.Errorf("bonus = %d, want %d..%d",
|
||||
obj.Bonus, tc.low, tc.hi)
|
||||
}
|
||||
|
||||
if used := inputUsed(t, g); used != 1 {
|
||||
t.Errorf("read %d keys, want the 1 blessing answer",
|
||||
used)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ringTestName labels a subtest by ring index, the R_ number from rogue.h.
|
||||
func ringTestName(ring RingKind, what string) string {
|
||||
return "R_" + strconv.Itoa(int(ring)) + " " + what
|
||||
}
|
||||
|
||||
// TestCreateRingCursedKindsSkipThePrompt covers C's second case group,
|
||||
// "when R_AGGR: case R_TELEPORT: obj->o_flags |= ISCURSED": cursed with
|
||||
// no blessing question and no bonus at all.
|
||||
func TestCreateRingCursedKindsSkipThePrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ring := range []RingKind{RingAggravateMonsters, RingTeleportation} {
|
||||
t.Run(ringTestName(ring, "is cursed silently"), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 26, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = KindRing
|
||||
obj.Which = int(ring)
|
||||
|
||||
setInput(t, g, ' ', Escape)
|
||||
g.createRing(obj)
|
||||
|
||||
if !obj.Flags.Has(Cursed) {
|
||||
t.Error("ring is not cursed")
|
||||
}
|
||||
|
||||
if obj.Bonus != 0 {
|
||||
t.Errorf("bonus = %d, want 0: C sets none here", obj.Bonus)
|
||||
}
|
||||
|
||||
if used := inputUsed(t, g); used != 0 {
|
||||
t.Errorf("read %d keys; C asks nothing for this kind", used)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateRingOtherKindsAreLeftAlone is the default arm: every ring
|
||||
// outside C's two case groups gets no prompt, no curse and no bonus.
|
||||
func TestCreateRingOtherKindsAreLeftAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
others := []RingKind{
|
||||
RingSustainStrength, RingSearching, RingSeeInvisible, RingAdornment,
|
||||
RingRegeneration, RingSlowDigestion, RingStealth, RingMaintainArmor,
|
||||
}
|
||||
|
||||
for _, ring := range others {
|
||||
t.Run(ringTestName(ring, "is untouched"), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 27, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = KindRing
|
||||
obj.Which = int(ring)
|
||||
|
||||
setInput(t, g, ' ', Escape)
|
||||
g.createRing(obj)
|
||||
|
||||
if obj.Flags.Has(Cursed) {
|
||||
t.Error("ring was cursed; C curses only R_AGGR and R_TELEPORT")
|
||||
}
|
||||
|
||||
if obj.Bonus != 0 {
|
||||
t.Errorf("bonus = %d, want 0", obj.Bonus)
|
||||
}
|
||||
|
||||
if used := inputUsed(t, g); used != 0 {
|
||||
t.Errorf("read %d keys; C asks nothing for this kind", used)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowMapRendersTheWholeLevel covers wizard.c show_map against a
|
||||
// generated level. C clears hw, walks y from 1 to NUMLINES-2 and x across
|
||||
// every column writing chat(y,x), then show_win()s it, so the whole map
|
||||
// including squares the hero has never seen has to land in the hw window.
|
||||
//
|
||||
// What show_map does *not* do is mark anything seen: it touches no PLACE
|
||||
// at all, in C or here, so there is no F_SEEN assertion to make.
|
||||
//
|
||||
// The standout attribute is only asserted up to the first non-real
|
||||
// square, deliberately. C's two tests are not the same test:
|
||||
//
|
||||
// real = flat(y, x);
|
||||
// if (!(real & F_REAL)) wstandout(hw);
|
||||
// ...
|
||||
// if (!real) wstandend(hw); /* whole word, not the bit */
|
||||
//
|
||||
// new_level.c seeds every square with p_flags = F_REAL, and exactly three
|
||||
// sites clear that bit. putpass sets F_PASS first, so a secret passage is
|
||||
// left at 0x80. door's secret-door arm clears it on a room-wall exit whose
|
||||
// flags are still exactly F_REAL, leaving p_flags == 0. And the trap loop
|
||||
// ORs in rnd(NTRAPS), which is 0..7, so the T_DOOR (00) case is zero too
|
||||
// until be_trapped ORs F_SEEN in. So C's wstandend does fire, at secret
|
||||
// doors and unsprung trapdoors; what it gets wrong is leaking standout
|
||||
// forward from a secret passage or a non-trapdoor trap until it reaches
|
||||
// one of those — intermittent bands, not a permanently reversed map.
|
||||
// game/wizard.go tests isReal both times and highlights the single square.
|
||||
// That divergence is reported on issue #7 rather than settled here, so
|
||||
// this test asserts only
|
||||
// what both agree on: the characters everywhere, standout on every
|
||||
// non-real square, and no standout on real squares before the first
|
||||
// non-real one.
|
||||
func TestShowMapRendersTheWholeLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Deep enough that putpass and the trap loop actually fire; both are
|
||||
// gated on the depth, so a level-1 map would have nothing secret.
|
||||
g := mkWizard(t, 31, 20)
|
||||
|
||||
setInput(t, g, ' ')
|
||||
g.showMap()
|
||||
|
||||
hw := g.scr.Hw
|
||||
seenSecret := false
|
||||
|
||||
for y := 1; y < NumLines-1; y++ {
|
||||
for x := range NumCols {
|
||||
c := hw.at(y, x)
|
||||
if c.ch != g.Level.Char(y, x) {
|
||||
t.Fatalf("hw(%d,%d) = %q, want the map char %q",
|
||||
y, x, c.ch, g.Level.Char(y, x))
|
||||
}
|
||||
|
||||
isReal := g.Level.FlagsAt(y, x).Has(FReal)
|
||||
if !isReal && !c.standout {
|
||||
t.Errorf("secret square (%d,%d) was not drawn in standout",
|
||||
y, x)
|
||||
}
|
||||
|
||||
if !seenSecret && isReal && c.standout {
|
||||
t.Errorf("ordinary square (%d,%d) was drawn in standout",
|
||||
y, x)
|
||||
}
|
||||
|
||||
seenSecret = seenSecret || !isReal
|
||||
}
|
||||
}
|
||||
|
||||
if !seenSecret {
|
||||
t.Fatal("generated level has no non-F_REAL squares: the standout " +
|
||||
"half of show_map went untested, pick a deeper level or seed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowMapLoopBoundsMatchC pins the loop bounds. C starts at y = 1
|
||||
// and stops before NUMLINES-1, so the top line stays free for show_win's
|
||||
// prompt and the status line is never overwritten.
|
||||
func TestShowMapLoopBoundsMatchC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 32, 10)
|
||||
|
||||
// Rows 0 and NUMLINES-1 are blank on a generated level, so a bound
|
||||
// that ran off either end would copy blanks onto blanks and look
|
||||
// identical. Planting a marker in places[] there is what makes the
|
||||
// bound observable at all.
|
||||
const marker = 'Z'
|
||||
|
||||
for x := range NumCols {
|
||||
g.Level.SetChar(0, x, marker)
|
||||
g.Level.SetChar(NumLines-1, x, marker)
|
||||
}
|
||||
|
||||
setInput(t, g, ' ')
|
||||
g.showMap()
|
||||
|
||||
hw := g.scr.Hw
|
||||
|
||||
for x := range NumCols {
|
||||
if got := hw.at(NumLines-1, x).ch; got == marker {
|
||||
t.Fatalf("hw(%d,%d) = %q: the loop ran onto the status line",
|
||||
NumLines-1, x, got)
|
||||
}
|
||||
}
|
||||
|
||||
const want = "---More (level map)---"
|
||||
|
||||
// show_win's prompt covers the start of row 0; past it the row must
|
||||
// still be untouched by the map loop.
|
||||
for x := len(want); x < NumCols; x++ {
|
||||
if got := hw.at(0, x).ch; got == marker {
|
||||
t.Fatalf("hw(0,%d) = %q: the loop ran onto the message line",
|
||||
x, got)
|
||||
}
|
||||
}
|
||||
|
||||
top := make([]byte, 0, len(want))
|
||||
|
||||
for x := range len(want) {
|
||||
top = append(top, hw.at(0, x).ch)
|
||||
}
|
||||
|
||||
if string(top) != want {
|
||||
t.Errorf("top line = %q, want show_win's %q", string(top), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhatisMarksTheRightTable covers wizard.c whatis's switch: scrolls,
|
||||
// potions, sticks and rings each go through set_know on their own
|
||||
// per-game table, and the function ends with msg(inv_name(obj, FALSE)),
|
||||
// so the reported name is the newly identified one.
|
||||
func TestWhatisMarksTheRightTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
kind ObjectKind
|
||||
which int
|
||||
table func(g *RogueGame) []ObjInfo
|
||||
}{
|
||||
{"scroll", KindScroll, int(ScrollEnchantArmor),
|
||||
func(g *RogueGame) []ObjInfo { return g.Items.Scrolls[:] }},
|
||||
{"potion", KindPotion, int(PotionHealing),
|
||||
func(g *RogueGame) []ObjInfo { return g.Items.Potions[:] }},
|
||||
{"wand", KindWand, int(WandLight),
|
||||
func(g *RogueGame) []ObjInfo { return g.Items.Sticks[:] }},
|
||||
{"ring", KindRing, int(RingSearching),
|
||||
func(g *RogueGame) []ObjInfo { return g.Items.Rings[:] }},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 33, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = tc.kind
|
||||
obj.Which = tc.which
|
||||
ch := give(g, obj)
|
||||
|
||||
tbl := tc.table(g)
|
||||
tbl[tc.which].Guess = "a wild guess"
|
||||
before := g.inventoryName(obj, false)
|
||||
|
||||
setInput(t, g, ch, ' ', Escape)
|
||||
g.whatis(false, KindNone)
|
||||
|
||||
if !tbl[tc.which].Know {
|
||||
t.Error("set_know did not mark the table entry known")
|
||||
}
|
||||
|
||||
if tbl[tc.which].Guess != "" {
|
||||
t.Errorf("guess = %q, want it freed", tbl[tc.which].Guess)
|
||||
}
|
||||
|
||||
if !obj.Flags.Has(Known) {
|
||||
t.Error("the object did not get ISKNOW")
|
||||
}
|
||||
|
||||
after := g.inventoryName(obj, false)
|
||||
if after == before {
|
||||
t.Errorf("name is still %q; identifying changed nothing",
|
||||
after)
|
||||
}
|
||||
|
||||
if g.Msgs.Huh != after {
|
||||
t.Errorf("reported %q, want inv_name's %q", g.Msgs.Huh, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhatisIdentifiesOnlyTheChosenEntry is the other half of set_know's
|
||||
// contract: one table entry, not a whole table and not its neighbours.
|
||||
func TestWhatisIdentifiesOnlyTheChosenEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 34, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = KindScroll
|
||||
obj.Which = int(ScrollEnchantArmor)
|
||||
ch := give(g, obj)
|
||||
|
||||
setInput(t, g, ch, ' ', Escape)
|
||||
g.whatis(false, KindNone)
|
||||
|
||||
for i := range g.Items.Scrolls {
|
||||
if i == obj.Which {
|
||||
continue
|
||||
}
|
||||
|
||||
if g.Items.Scrolls[i].Know {
|
||||
t.Errorf("scroll %d was marked known too", i)
|
||||
}
|
||||
}
|
||||
|
||||
if g.Items.Potions[obj.Which].Know {
|
||||
t.Error("identifying a scroll marked the potion at the same index")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhatisWeaponAndArmorOnlySetTheFlag pins C's WEAPON/ARMOR arm, which
|
||||
// is "obj->o_flags |= ISKNOW" and no set_know call: knowing this sword is
|
||||
// a sword says nothing about the kind, so the per-kind table entry must
|
||||
// stay untouched.
|
||||
func TestWhatisWeaponAndArmorOnlySetTheFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
kind ObjectKind
|
||||
which int
|
||||
table func(g *RogueGame) []ObjInfo
|
||||
}{
|
||||
{"a mace", KindWeapon, int(WeaponMace),
|
||||
func(g *RogueGame) []ObjInfo { return g.Items.Weapons[:] }},
|
||||
{"plate mail", KindArmor, int(ArmorPlateMail),
|
||||
func(g *RogueGame) []ObjInfo { return g.Items.Armors[:] }},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 35, 1)
|
||||
obj := newObject()
|
||||
obj.Kind = tc.kind
|
||||
obj.Which = tc.which
|
||||
ch := give(g, obj)
|
||||
|
||||
setInput(t, g, ch, ' ', Escape)
|
||||
g.whatis(false, KindNone)
|
||||
|
||||
if !obj.Flags.Has(Known) {
|
||||
t.Error("the object did not get ISKNOW")
|
||||
}
|
||||
|
||||
if tc.table(g)[tc.which].Know {
|
||||
t.Error("the kind table was marked known; C calls no " +
|
||||
"set_know for weapons or armor")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhatisEmptyPackSaysSo covers the early return C takes when
|
||||
// pack == NULL, before any prompt happens.
|
||||
func TestWhatisEmptyPackSaysSo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 36, 1)
|
||||
g.Player.Pack = nil
|
||||
|
||||
g.whatis(false, KindNone)
|
||||
|
||||
const want = "you don't have anything in your pack to identify"
|
||||
|
||||
if g.Msgs.Huh != want {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, want)
|
||||
}
|
||||
|
||||
if used := inputUsed(t, g); used != 0 {
|
||||
t.Errorf("read %d keys; C returns before get_item", used)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhatisInsistRepromptsUntilAMatch drives both re-prompting arms of
|
||||
// C's insist loop in one pass: a wrong-kind pick ("you must identify a
|
||||
// %s") and then a bare escape with n_objs non-zero ("you must identify
|
||||
// something"), before the scroll finally satisfies it. The spaces in the
|
||||
// script are the --More-- acknowledgements those two messages force, and
|
||||
// without insist neither arm exists — the loop would have returned the
|
||||
// potion on the first answer.
|
||||
func TestWhatisInsistRepromptsUntilAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 37, 1)
|
||||
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
pot.Which = int(PotionHealing)
|
||||
potCh := give(g, pot)
|
||||
|
||||
scr := newObject()
|
||||
scr.Kind = KindScroll
|
||||
scr.Which = int(ScrollEnchantArmor)
|
||||
scrCh := give(g, scr)
|
||||
|
||||
setInput(t, g, potCh, ' ', Escape, ' ', scrCh, ' ', Escape)
|
||||
g.whatis(true, KindScroll)
|
||||
|
||||
if !g.Items.Scrolls[scr.Which].Know {
|
||||
t.Error("the scroll was never identified: the loop gave up early")
|
||||
}
|
||||
|
||||
if g.Items.Potions[pot.Which].Know {
|
||||
t.Error("the wrong-kind potion was identified anyway")
|
||||
}
|
||||
|
||||
if used := inputUsed(t, g); used < 5 {
|
||||
t.Errorf("consumed %d keys, want at least the 5 the two "+
|
||||
"re-prompts need", used)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhatisInsistGivesUpWhenNothingMatches covers "if (n_objs == 0)
|
||||
// return": asking for the list with nothing appropriate in the pack sets
|
||||
// n_objs to 0, and that is the one way out of the insist loop short of
|
||||
// picking something. Getting it wrong is not a wrong answer but a hang.
|
||||
func TestWhatisInsistGivesUpWhenNothingMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 38, 1)
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
pot.Which = int(PotionHealing)
|
||||
give(g, pot)
|
||||
|
||||
setInput(t, g, '*', ' ', Escape)
|
||||
g.whatis(true, KindScroll)
|
||||
|
||||
if g.NObjs != 0 {
|
||||
t.Fatalf("n_objs = %d; this test needs the empty-list path", g.NObjs)
|
||||
}
|
||||
|
||||
if g.Items.Potions[pot.Which].Know {
|
||||
t.Error("giving up identified something anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetKnowDoesNotLeakAcrossGames is the reason set_know is not just a
|
||||
// debug helper: the tables it writes are the per-game discovered lists
|
||||
// that drive item naming in ordinary play. They live on RogueGame, and a
|
||||
// second game must start ignorant.
|
||||
func TestSetKnowDoesNotLeakAcrossGames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g1 := mkWizard(t, 39, 1)
|
||||
g2 := mkWizard(t, 40, 1)
|
||||
|
||||
ring := newObject()
|
||||
ring.Kind = KindRing
|
||||
ring.Which = int(RingSearching)
|
||||
g1.Items.Rings[ring.Which].Guess = "a hunch"
|
||||
|
||||
setKnow(ring, g1.Items.Rings[:])
|
||||
|
||||
if !g1.Items.Rings[ring.Which].Know {
|
||||
t.Error("the entry was not marked known")
|
||||
}
|
||||
|
||||
if g1.Items.Rings[ring.Which].Guess != "" {
|
||||
t.Error("the old guess was not freed")
|
||||
}
|
||||
|
||||
if !ring.Flags.Has(Known) {
|
||||
t.Error("the object did not get ISKNOW")
|
||||
}
|
||||
|
||||
if g2.Items.Rings[ring.Which].Know {
|
||||
t.Error("the second game already knows the ring: the discovered " +
|
||||
"tables are shared between games")
|
||||
}
|
||||
|
||||
if g2.Items.Rings[ring.Which].Guess != "" {
|
||||
t.Error("the second game inherited the first game's guess")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeleportLandsTheHeroSomewhereLegal covers wizard.c teleport. C
|
||||
// picks the spot with find_floor(NULL, &c, FALSE, TRUE) — any room, and
|
||||
// monst TRUE, so the square must be steppable and unoccupied — then keeps
|
||||
// the room bookkeeping straight (leave_room/enter_room when the room
|
||||
// changed, look(TRUE) when it did not) and clears the run state.
|
||||
func TestTeleportLandsTheHeroSomewhereLegal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 41, 3)
|
||||
p := &g.Player
|
||||
from := p.Pos
|
||||
vacated := g.floorAt()
|
||||
|
||||
g.NoMove = 3
|
||||
g.Count = 5
|
||||
g.Running = true
|
||||
|
||||
g.teleport()
|
||||
|
||||
if p.Pos == from {
|
||||
t.Fatal("hero did not move; this seed teleported him onto himself")
|
||||
}
|
||||
|
||||
pp := g.Level.At(p.Pos.Y, p.Pos.X)
|
||||
if !stepOk(pp.Ch) || pp.Monst != nil {
|
||||
t.Errorf("landed on %q with monster %v: find_floor's contract is "+
|
||||
"a steppable, unoccupied square", pp.Ch, pp.Monst != nil)
|
||||
}
|
||||
|
||||
if p.Room != g.roomIn(p.Pos) {
|
||||
t.Error("player room does not match the square he is standing on")
|
||||
}
|
||||
|
||||
if got := g.mvinch(p.Pos.Y, p.Pos.X); got != PlayerCh {
|
||||
t.Errorf("new square shows %q, want the hero %q", got, PlayerCh)
|
||||
}
|
||||
|
||||
if got := g.mvinch(from.Y, from.X); got != vacated {
|
||||
t.Errorf("vacated square shows %q, want floor_at()'s %q",
|
||||
got, vacated)
|
||||
}
|
||||
|
||||
if g.NoMove != 0 || g.Count != 0 || g.Running {
|
||||
t.Errorf("run state left at no_move=%d count=%d running=%v",
|
||||
g.NoMove, g.Count, g.Running)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeleportReleasesTheFlytrap covers the tail C spells out: bamfing
|
||||
// away while a Flytrap has hold of you clears ISHELD, resets vf_hit and
|
||||
// puts the 'F' bestiary entry's damage back to "000x0" — the Flytrap
|
||||
// grows its own damage string as it holds on, so leaving it grown would
|
||||
// make the next Flytrap of the game start off mid-fight.
|
||||
func TestTeleportReleasesTheFlytrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 42, 3)
|
||||
p := &g.Player
|
||||
p.Flags.Set(Held)
|
||||
p.VfHit = 4
|
||||
g.Monsters['F'-'A'].Stats.Dmg = dice("3x4")
|
||||
|
||||
g.teleport()
|
||||
|
||||
if p.On(Held) {
|
||||
t.Error("hero is still held after teleporting away")
|
||||
}
|
||||
|
||||
if p.VfHit != 0 {
|
||||
t.Errorf("vf_hit = %d, want 0", p.VfHit)
|
||||
}
|
||||
|
||||
// C strcpy's the literal "000x0"; the port keeps damage parsed, so
|
||||
// the same thing reads back as the single 0x0 attack that string
|
||||
// means rather than as those five characters.
|
||||
dmg := g.Monsters['F'-'A'].Stats.Dmg
|
||||
if len(dmg) != 1 || dmg[0].Count != 0 || dmg[0].Sides != 0 {
|
||||
t.Errorf("flytrap damage = %q, want C's 000x0, one 0x0 attack", dmg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeleportLeavesTheFlytrapAloneWhenFree pins the other side of C's
|
||||
// "if (on(player, ISHELD))" guard: an ordinary wizard teleport must not
|
||||
// reach into the bestiary and reset a Flytrap that is busy elsewhere.
|
||||
func TestTeleportLeavesTheFlytrapAloneWhenFree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 43, 3)
|
||||
g.Player.VfHit = 2
|
||||
g.Monsters['F'-'A'].Stats.Dmg = dice("3x4")
|
||||
|
||||
g.teleport()
|
||||
|
||||
if g.Player.VfHit != 2 {
|
||||
t.Errorf("vf_hit = %d, want the untouched 2", g.Player.VfHit)
|
||||
}
|
||||
|
||||
if got := g.Monsters['F'-'A'].Stats.Dmg.String(); got != "3x4" {
|
||||
t.Errorf("flytrap damage = %q, want the untouched %q", got, "3x4")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWizardKitEquipsTheHero covers the CTRL('I') arm of command.c's
|
||||
// wizard switch: nine raise_level() calls, a (+1,+1) two-handed sword
|
||||
// wielded, and plate mail at o_arm -5 already known and worn.
|
||||
func TestWizardKitEquipsTheHero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkWizard(t, 44, 1)
|
||||
p := &g.Player
|
||||
|
||||
if p.Stats.Lvl != 1 {
|
||||
t.Fatalf("hero starts at level %d, not 1", p.Stats.Lvl)
|
||||
}
|
||||
|
||||
// raise_level messages queue up --More-- prompts; spaces clear them.
|
||||
setInput(t, g, ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ')
|
||||
g.wizardKit()
|
||||
|
||||
if p.Stats.Lvl != 10 {
|
||||
t.Errorf("level = %d, want 10 after nine raise_level calls",
|
||||
p.Stats.Lvl)
|
||||
}
|
||||
|
||||
checkKitWeapon(t, g)
|
||||
checkKitArmor(t, g)
|
||||
}
|
||||
|
||||
// checkKitWeapon asserts the sword half of the wizard kit.
|
||||
func checkKitWeapon(t *testing.T, g *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
weap := g.Player.CurWeapon
|
||||
if weap == nil {
|
||||
t.Fatal("no weapon wielded")
|
||||
}
|
||||
|
||||
if weap.Kind != KindWeapon || weap.Which != int(WeaponTwoHandedSword) {
|
||||
t.Errorf("wielding %v which %d, want the two-handed sword",
|
||||
weap.Kind, weap.Which)
|
||||
}
|
||||
|
||||
if weap.HPlus != 1 || weap.DPlus != 1 {
|
||||
t.Errorf("sword is (%+d,%+d), want (+1,+1)", weap.HPlus, weap.DPlus)
|
||||
}
|
||||
|
||||
// init_dam[]'s 2h sword row.
|
||||
if got := weap.Damage.String(); got != "4x4" {
|
||||
t.Errorf("damage = %q, want 4x4", got)
|
||||
}
|
||||
|
||||
if !inPack(g, weap) {
|
||||
t.Error("the sword was never added to the pack")
|
||||
}
|
||||
}
|
||||
|
||||
// checkKitArmor asserts the plate mail half of the wizard kit.
|
||||
func checkKitArmor(t *testing.T, g *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
armor := g.Player.CurArmor
|
||||
if armor == nil {
|
||||
t.Fatal("no armor worn")
|
||||
}
|
||||
|
||||
if armor.Kind != KindArmor || armor.Which != int(ArmorPlateMail) {
|
||||
t.Errorf("wearing %v which %d, want plate mail",
|
||||
armor.Kind, armor.Which)
|
||||
}
|
||||
|
||||
if armor.ArmorClass != -5 {
|
||||
t.Errorf("armor class = %d, want -5", armor.ArmorClass)
|
||||
}
|
||||
|
||||
if !armor.Flags.Has(Known) {
|
||||
t.Error("the armor is not known")
|
||||
}
|
||||
|
||||
if armor.Count != 1 {
|
||||
t.Errorf("count = %d, want 1", armor.Count)
|
||||
}
|
||||
|
||||
if !inPack(g, armor) {
|
||||
t.Error("the armor was never added to the pack")
|
||||
}
|
||||
}
|
||||
|
||||
// inPack reports whether obj is filed in the hero's pack.
|
||||
func inPack(g *RogueGame, obj *Object) bool {
|
||||
return slices.Contains(g.Player.Pack, obj)
|
||||
}
|
||||
|
||||
37
script/lint
Executable file
37
script/lint
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# script/lint: lint in docker. golangci-lint is never installed on the host.
|
||||
#
|
||||
# Traps, each of which yields a green run over an unlinted or partly linted
|
||||
# tree:
|
||||
#
|
||||
# 1. --target and --no-cache-filter must both stay, and $stage must match
|
||||
# the stage name in Dockerfile.lint. BuildKit ignores --no-cache-filter
|
||||
# when no stage matches its argument, serving the lint layer from cache
|
||||
# without a word; --target rejects a name that is not in the file, which
|
||||
# is what makes the single $stage safe.
|
||||
#
|
||||
# 2. --target checks that the stage exists, not that it is the stage
|
||||
# running golangci-lint, and it halts the build there. Moving the lint
|
||||
# step to another stage, or adding a stage after it, is not caught.
|
||||
#
|
||||
# 3. .dockerignore decides what reaches the container, and only what
|
||||
# reaches it is linted. Excluding a self-contained Go file drops it from
|
||||
# the lint silently. Never exclude Go sources, go.mod/go.sum or
|
||||
# .golangci.yml.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# Must match the stage name in Dockerfile.lint.
|
||||
stage=lint
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build \
|
||||
--target "$stage" \
|
||||
--no-cache-filter="$stage" \
|
||||
--output=type=cacheonly \
|
||||
-f Dockerfile.lint .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user