Compare commits
13 Commits
ba444a2002
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ab4aba8b | |||
| 60442ce103 | |||
| 6f997b8d5c | |||
| 9f079ab594 | |||
| 3eb9f81fc4 | |||
| 20cfb47912 | |||
| 329c03f06e | |||
| 599286a88e | |||
| bde4eae450 | |||
|
|
3061931291 | ||
| 13caec4298 | |||
| df45f4cb24 | |||
| 6f409bda9e |
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
|
*.log
|
||||||
*.out
|
*.out
|
||||||
*.test
|
*.test
|
||||||
|
/build/
|
||||||
/rogue
|
/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 ./...
|
||||||
53
Makefile
53
Makefile
@@ -1,18 +1,47 @@
|
|||||||
# Development convenience targets. This repo is exempt from the standard
|
# Development convenience targets. This repo is exempt from the standard
|
||||||
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
|
# policy scaffold (no CI config, no REPO_POLICIES.md, no application
|
||||||
# is only a thin wrapper around the Go toolchain, golangci-lint, and
|
# Dockerfile) except for the lint container: per sneak's 2026-08-09
|
||||||
# prettier so `make fmt` / `make check` behave the same as in sneak's
|
# ruling, linting runs in docker only, so Dockerfile.lint and script/lint
|
||||||
# other repos.
|
# are part of this repo. This Makefile is otherwise only a thin wrapper
|
||||||
|
# around the Go toolchain and prettier so `make fmt` / `make check` behave
|
||||||
|
# the same as in sneak's other repos.
|
||||||
|
|
||||||
GO_PKGS := ./...
|
GO_PKGS := ./...
|
||||||
MD_FILES := $(shell git ls-files '*.md')
|
MD_FILES := $(shell git ls-files '*.md')
|
||||||
PRETTIER := prettier --tab-width 4 --prose-wrap always
|
PRETTIER := prettier --tab-width 4 --prose-wrap always
|
||||||
|
|
||||||
.PHONY: check fmt fmt-check lint test
|
# Every generated artifact goes here, and the whole directory is
|
||||||
|
# git-ignored. Targets that write outside it can commit their output.
|
||||||
|
BUILD_DIR := build
|
||||||
|
BIN := $(BUILD_DIR)/rogue
|
||||||
|
COVERPROF := $(BUILD_DIR)/coverage.out
|
||||||
|
COVERHTML := $(BUILD_DIR)/coverage.html
|
||||||
|
|
||||||
# Format, lint, and test — the full local pre-commit gate.
|
.PHONY: build check cover cover-html fmt fmt-check lint test
|
||||||
|
|
||||||
|
# Format, lint, and test — the full local pre-commit gate. Keep this list
|
||||||
|
# to targets that write nothing into the working tree.
|
||||||
check: fmt-check lint test
|
check: fmt-check lint test
|
||||||
|
|
||||||
|
# Build the executable into $(BUILD_DIR). `go build -o` does not create the
|
||||||
|
# parent directory.
|
||||||
|
build:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
go build -o $(BIN) ./cmd/rogue
|
||||||
|
|
||||||
|
# Per-function coverage, for finding which functions are untested. The
|
||||||
|
# percentage `make test` prints is a per-package total and cannot answer
|
||||||
|
# that. Writes files, so it stays out of `check`.
|
||||||
|
cover:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
go test -timeout 30s -coverprofile=$(COVERPROF) $(GO_PKGS)
|
||||||
|
go tool cover -func=$(COVERPROF)
|
||||||
|
|
||||||
|
# Render the same profile as annotated source.
|
||||||
|
cover-html: cover
|
||||||
|
go tool cover -html=$(COVERPROF) -o $(COVERHTML)
|
||||||
|
@echo "wrote $(COVERHTML)"
|
||||||
|
|
||||||
# Format Go and Markdown in place.
|
# Format Go and Markdown in place.
|
||||||
fmt:
|
fmt:
|
||||||
gofmt -w .
|
gofmt -w .
|
||||||
@@ -26,9 +55,11 @@ fmt-check:
|
|||||||
fi
|
fi
|
||||||
$(PRETTIER) --check $(MD_FILES)
|
$(PRETTIER) --check $(MD_FILES)
|
||||||
|
|
||||||
# Run the house linter (config in .golangci.yml).
|
# Run the house linter. golangci-lint is never installed on the host: the
|
||||||
|
# work happens inside the pinned container built by Dockerfile.lint, and
|
||||||
|
# this target is a thin shim over the script that builds it.
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run $(GO_PKGS)
|
./script/lint
|
||||||
|
|
||||||
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
|
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
|
||||||
# full output and still fail the target (the first run already proved the
|
# full output and still fail the target (the first run already proved the
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -21,19 +21,19 @@ original program structure and the design of this port.
|
|||||||
Requires Go 1.25 or later and a terminal at least 80x24.
|
Requires Go 1.25 or later and a terminal at least 80x24.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./cmd/rogue
|
make build
|
||||||
./rogue
|
./build/rogue
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Restore a saved game
|
# Restore a saved game
|
||||||
./rogue ~/rogue.save
|
./build/rogue ~/rogue.save
|
||||||
|
|
||||||
# View high scores
|
# View high scores
|
||||||
./rogue -s
|
./build/rogue -s
|
||||||
|
|
||||||
# Test the death screen (demo mode)
|
# Test the death screen (demo mode)
|
||||||
./rogue -d
|
./build/rogue -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## In-game commands
|
## In-game commands
|
||||||
@@ -57,7 +57,7 @@ Press `?` in game for the full list.
|
|||||||
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
||||||
|
|
||||||
# Wizard (debug) mode, with a reproducible dungeon
|
# Wizard (debug) mode, with a reproducible dungeon
|
||||||
ROGUE_WIZARD=1 SEED=12345 ./rogue
|
ROGUE_WIZARD=1 SEED=12345 ./build/rogue
|
||||||
```
|
```
|
||||||
|
|
||||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
||||||
@@ -77,10 +77,14 @@ sequences, dungeon-generation golden checks, and an RNG compatibility test
|
|||||||
against the original C generator.
|
against the original C generator.
|
||||||
|
|
||||||
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
||||||
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race
|
prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
|
||||||
detector with coverage and a timeout), and `make check` (all three). Use the
|
pinned container built from `Dockerfile.lint` — it is never installed on the
|
||||||
targets rather than invoking `go test` directly — they carry the flags the
|
host, so docker is required), `make test` (the suite, under the race detector
|
||||||
project relies on.
|
with coverage and a timeout), `make check` (all three), `make build` (the
|
||||||
|
executable), and `make cover` / `make cover-html` (per-function coverage, and
|
||||||
|
the same profile as annotated source at `build/coverage.html`). Everything they
|
||||||
|
generate lands in the git-ignored `build/`. Use the targets rather than the
|
||||||
|
toolchain directly — they carry the flags the project relies on.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
204
TODO.md
204
TODO.md
@@ -29,12 +29,186 @@ Refactor ground rules:
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Broaden unit test coverage where playtesting finds thin spots — wizard commands
|
Tag a release once a full game (Amulet retrieval and score entry) completes
|
||||||
(#7). Rings and sticks, the first two thirds of this step, are done; see the top
|
without defects. Promoted from Future Steps now that the coverage step above it
|
||||||
of Completed Steps.
|
is finished.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-10 `make cover` added (https://git.eeqj.de/sneak/rgoue/issues/17).
|
||||||
|
`make cover` writes `build/coverage.out` and prints the per-function report;
|
||||||
|
`make cover-html` renders the same profile to `build/coverage.html`. The
|
||||||
|
per-package percentage `make test` prints cannot say _which_ function is
|
||||||
|
untested, which is how the coverage gaps closed so far had to be found — by
|
||||||
|
grepping test files for identifiers.
|
||||||
|
|
||||||
|
Neither target is in `check`, and neither may be added to it: both write
|
||||||
|
files, and `make check` must not modify the working tree.
|
||||||
|
|
||||||
|
- 2026-08-10 `make build` added (https://git.eeqj.de/sneak/rgoue/issues/19). The
|
||||||
|
executable is built to `build/rogue`; `README.md` no longer contains a raw
|
||||||
|
`go` invocation anywhere. `build` is in neither `check` nor `test` —
|
||||||
|
`make check` stays `fmt-check lint test` and still writes nothing into the
|
||||||
|
working tree.
|
||||||
|
|
||||||
|
Generated artifacts now all live under `build/`, which `.gitignore` covers
|
||||||
|
as a whole. Anything written outside it is committable, so a target that
|
||||||
|
puts its output elsewhere reintroduces the stray-artifact problem.
|
||||||
|
|
||||||
|
- 2026-08-10 Linting moved into a container
|
||||||
|
(https://git.eeqj.de/sneak/rgoue/issues/41). `golangci-lint` is no longer
|
||||||
|
invoked on the host anywhere in the repo: `Dockerfile.lint` pins
|
||||||
|
`golangci/golangci-lint:v2.12.2` by digest and runs the linter as a build
|
||||||
|
step, so a successful build is a clean lint, and `make lint` is now a shim
|
||||||
|
over `script/lint`. This is what killed the false green seen earlier, where a
|
||||||
|
branch that was genuinely red with a `goconst` finding reported `0 issues` off
|
||||||
|
the shared host cache; a container per run has its own cache and lock.
|
||||||
|
|
||||||
|
`script/lint` builds with `--target "$stage"`, `--no-cache-filter="$stage"`
|
||||||
|
and `--output=type=cacheonly`. The durable property to check when touching
|
||||||
|
any of this: the lint stage executes on every run and is never served from
|
||||||
|
cache. Three things no tooling checks, left to whoever edits the gate —
|
||||||
|
`$stage` must match the stage name in `Dockerfile.lint`; that stage must
|
||||||
|
stay the one running `golangci-lint`, since `--target` halts the build
|
||||||
|
there; and `.dockerignore` governs what reaches the container, so excluding
|
||||||
|
a self-contained Go source drops it from the lint silently.
|
||||||
|
|
||||||
|
Verified rather than assumed, since a green docker build is the classic
|
||||||
|
false green: two consecutive runs on an unchanged tree each showed the
|
||||||
|
`golangci-lint run` layer executing and reporting `0 issues.` while the
|
||||||
|
`deps` layers reported `CACHED`; the same build with `--no-cache-filter`
|
||||||
|
removed reported that layer `CACHED`, so the re-execution is attributable to
|
||||||
|
the flag rather than to a changed context; deliberate violations failed the
|
||||||
|
build naming the specific finding and reverted clean; a stage-name typo
|
||||||
|
failed loudly at exit 1; and a Go file excluded via `.dockerignore` reported
|
||||||
|
`0 issues.` at exit 0 with the violation still in the tree. Wall-clock
|
||||||
|
durations vary per host and per run, so they are not recorded here.
|
||||||
|
|
||||||
|
- 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause
|
||||||
|
(`fix/autosave-turn-budget-36`, closes #36). The failure text was captured
|
||||||
|
before anything was changed and it is **not** a data race: the assertion was
|
||||||
|
`driveUntilDone`'s
|
||||||
|
`t.Fatal("the turn loop ran out of turns before the saves were taken")`, with
|
||||||
|
no `WARNING: DATA RACE` anywhere in the log. The handoff fixed in #24 was
|
||||||
|
working; the test's own drive loop was running out of its fixed 1000-turn
|
||||||
|
budget first.
|
||||||
|
|
||||||
|
Confirmed rather than taken on trust. Instrumenting the loop to report the
|
||||||
|
turns it actually used showed the count tracking scheduling pressure and
|
||||||
|
nothing else: about 60-120 turns at host load ~57 with the whole machine to
|
||||||
|
spread over, 418 at `GOMAXPROCS=4`, 539 and 655 at 2 and 1, and past 1000 —
|
||||||
|
the recorded failure — under the doubled load of the verbose rerun that the
|
||||||
|
test target performs after a failure. The turns between one save being
|
||||||
|
answered and the next request arriving are not work; they are the saving
|
||||||
|
goroutine's wake-up latency, so a fixed turn count is a wall-clock
|
||||||
|
assumption in disguise, which is why raising it would have hidden the flake
|
||||||
|
rather than fixed it.
|
||||||
|
|
||||||
|
So the budget is gone rather than larger. `driveUntilDone` now drives until
|
||||||
|
the saving goroutine finishes and nothing else. Termination is not lost, it
|
||||||
|
just belongs to the code under test instead of to the test: every
|
||||||
|
`AutoSaveOnSignal` returns within the timeout it is handed, so the saving
|
||||||
|
goroutine always finishes. A handoff that has stopped answering costs one
|
||||||
|
`autoSaveWait` in total — `g.sigSave` is one deep, so an unserviced request
|
||||||
|
stays in the channel and every later call finds it full and fails at once —
|
||||||
|
and the failure is then the real assertion (`saves taken = 0, want 25`)
|
||||||
|
instead of "out of turns". The worst case is not that one: a handoff that
|
||||||
|
drains each request but slower than `autoSaveWait` costs one timeout per
|
||||||
|
save, `wantSaves × autoSaveWait` = 250s, which would run past the 30s
|
||||||
|
package timeout instead of reaching the assertion. It takes ~10s of
|
||||||
|
scheduler starvation per save against a measured 0.12s per 1000 turns, so it
|
||||||
|
is remote, and the turn cap did not bound it either. The comment in the test
|
||||||
|
states that bound rather than the optimistic one.
|
||||||
|
|
||||||
|
Removing the cap exposed a second assumption underneath it, which is the
|
||||||
|
reason this is not a one-line diff. `testTerm` answers space and newline for
|
||||||
|
ever once its script is exhausted, and neither key takes a turn, so
|
||||||
|
`command()` — which loops until the player consumes one — never returns; the
|
||||||
|
old cap was silently sized to the script (4000 characters, two per turn,
|
||||||
|
against 1000 turns). An uncapped drive wedged inside a single `command()`
|
||||||
|
call. The two drive tests therefore use a new `driveTerm`, a headless
|
||||||
|
terminal whose script repeats. Repeating is necessary but not sufficient,
|
||||||
|
and the test says so: `' '` clears `After` outright and all eight movement
|
||||||
|
keys clear it on a refused step, so a script of only those keys wedges just
|
||||||
|
as `testTerm`'s tail did. What makes the wedge impossible is that the cycle
|
||||||
|
always holds an _unconditional_ turn-taker, and these scripts hold two —
|
||||||
|
`'.'` (empty handler) and `'s'` (`search`, which writes `After` on no path),
|
||||||
|
neither refusable by blocked-in-all-directions, `Held`, a bear trap, or
|
||||||
|
`NoCommand > 0`. Removing both would bring the wedge back.
|
||||||
|
|
||||||
|
Both halves of the definition of done were demonstrated by mutation, with
|
||||||
|
the deliberately-broken tree reverted afterwards and `.golangci.yml` left
|
||||||
|
byte-identical (sha256 `021cc83f...46bcb`). Reverting #24 —
|
||||||
|
`AutoSaveOnSignal` replaced by a direct `g.autoSave()`, encoding on the
|
||||||
|
calling goroutine — still fails the test with 139 `WARNING: DATA RACE`
|
||||||
|
reports naming `snapshotHeader` reading what `executeCommand` writes, so the
|
||||||
|
guard is undiminished. Removing the `serviceAutoSaveRequest` call from
|
||||||
|
`command()` still fails it too, now in 10s with `saves taken = 0, want 25`
|
||||||
|
rather than by hanging.
|
||||||
|
|
||||||
|
Under load, an A/B at `GOMAXPROCS=2` on a 48-core host at load ~150, with an
|
||||||
|
unrelated deliberate failure in the tree so that every run took the verbose
|
||||||
|
rerun: the old code failed 8 of 8 runs with "ran out of turns"; the new code
|
||||||
|
failed 0 of 8, the only failure being the planted one. Also green across 24
|
||||||
|
concurrent unconstrained runs at load ~120, 10 runs alongside a spinner
|
||||||
|
load, and 5 runs each at `GOMAXPROCS` 1, 2 and 4. `make check` green, lint 0
|
||||||
|
issues.
|
||||||
|
|
||||||
|
- 2026-08-09 Wizard commands under test (`test/wizard-coverage`, closes #7): the
|
||||||
|
last of the three thin spots, so the coverage step is now closed rather than
|
||||||
|
narrowed. `game/wizard.go`'s eight functions had no tests of their own, and
|
||||||
|
the file is not purely a debug surface — `set_know` writes the per-game
|
||||||
|
discovered tables that name items in ordinary play, and `teleport` is what the
|
||||||
|
teleport ring calls every fiftieth turn. Package coverage 60.6% -> 62.4%.
|
||||||
|
Everything expected was transcribed from `wizard.c`, `command.c` (the
|
||||||
|
`CTRL('I')` kit), `extern.c` (`a_class[]`), `weapons.c` (`init_dam[]`) and
|
||||||
|
`rogue.h`; 30 mutations were tried and all 30 were caught.
|
||||||
|
|
||||||
|
Two findings came out of the reading. (1) **A wizard-created cursed weapon
|
||||||
|
is not cursed, in C or here.** `create_obj` sets `ISCURSED` and then calls
|
||||||
|
`init_weapon`, which _assigns_ `weap->o_flags = iwp->iw_flags` and so
|
||||||
|
overwrites the bit it just set; only the `o_hplus` penalty survives, and the
|
||||||
|
"cursed" weapon can still be dropped and unwielded. The port reproduces this
|
||||||
|
exactly. The test asserts the whole flag word comes back as the `init_dam[]`
|
||||||
|
row's value whatever blessing was answered, and deleting the `ISCURSED` line
|
||||||
|
from the port leaves every weapon test green — which is the evidence that
|
||||||
|
the line is dead for weapons. The armor arm has no such clobber and does
|
||||||
|
keep the curse. (2) **`show_map`'s standout is asymmetric in C and symmetric
|
||||||
|
here.** C tests `!(real & F_REAL)` before drawing and `!real` — the whole
|
||||||
|
flag word — after. `new_level` seeds every square with `p_flags = F_REAL`,
|
||||||
|
and exactly three sites clear that bit. `passages.c putpass` sets `F_PASS`
|
||||||
|
first, so its secret passage is left at `0x80`. `passages.c door`'s
|
||||||
|
secret-door arm clears it on a room-wall exit whose flags are still exactly
|
||||||
|
`F_REAL` (`rooms.c` writes no `p_flags` at all), leaving `p_flags == 0`; its
|
||||||
|
per-square gate is `rnd(5) == 0` against `putpass`'s `rnd(40) == 0`, and
|
||||||
|
`game/passages.go`'s `door` reproduces it. `new_level`'s trap loop then ORs
|
||||||
|
in `rnd(NTRAPS)`, which is `abs((int) RN) % 8` and so yields `0..7`, and
|
||||||
|
`T_DOOR` is `00` — an unsprung trapdoor square is also exactly zero
|
||||||
|
(`be_trapped` is what later ORs `F_SEEN` into it). So C _does_ turn standout
|
||||||
|
off again, at secret doors and unsprung trapdoors; what it gets wrong is
|
||||||
|
leaking the attribute forward from a secret passage or a non-trapdoor trap
|
||||||
|
until it reaches one of those. Intermittent bands of reverse video, not a
|
||||||
|
permanently reversed map. `game/wizard.go` tests `isReal` both times and
|
||||||
|
highlights the one square. That is a display-only difference in a
|
||||||
|
wizard-only command and was reported on the issue rather than changed here;
|
||||||
|
the test asserts the map characters unconditionally but the standout
|
||||||
|
attribute only up to the first secret square, so it pins nothing that C
|
||||||
|
contradicts.
|
||||||
|
|
||||||
|
Two things the tests had to be built around. The `insist` arm of `whatis` is
|
||||||
|
a loop whose only exits are picking a matching item and `n_objs == 0`, so a
|
||||||
|
script that runs dry hangs instead of failing — every sequence that can
|
||||||
|
re-prompt ends in an abort tail, the `n_objs == 0` exit is reached the way a
|
||||||
|
player reaches it (`*` for a list with nothing appropriate in the pack)
|
||||||
|
rather than by poking the counter, and the one mutation that deletes that
|
||||||
|
exit is the only one of the 30 that fails by timeout instead of fast,
|
||||||
|
necessarily so. And `show_map` does **not** mark squares seen — it writes
|
||||||
|
into `hw` and touches no `PLACE` at all — so the issue's wording for it
|
||||||
|
could not be tested as written; the loop bounds are asserted instead by
|
||||||
|
planting a marker in the rows C's loop excludes, since those rows are blank
|
||||||
|
on a real level and copying blanks over blanks would have made the bound
|
||||||
|
unfalsifiable.
|
||||||
|
|
||||||
- 2026-08-09 Wands and staffs under test (`test/sticks-coverage`, closes #6):
|
- 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 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
|
the largest under-tested file in the repo — 534 lines, 23 functions, one test
|
||||||
@@ -580,7 +754,11 @@ of Completed Steps.
|
|||||||
24 long lines wrapped or their comments tightened, control bytes in
|
24 long lines wrapped or their comments tightened, control bytes in
|
||||||
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
|
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
|
||||||
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
|
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
|
||||||
`make lint` runs whatever `golangci-lint` is on the host).
|
`make lint` runs whatever `golangci-lint` is on the host). Superseded
|
||||||
|
2026-08-10: there is a pin now, and no host lint path — `Dockerfile.lint` pins
|
||||||
|
the linter image by digest and `script/lint` runs it in a container. See the
|
||||||
|
2026-08-10 entry at the top of this section
|
||||||
|
(https://git.eeqj.de/sneak/rgoue/issues/41).
|
||||||
|
|
||||||
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
|
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
|
||||||
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
|
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
|
||||||
@@ -724,13 +902,17 @@ of Completed Steps.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
1. Tag a release once a full game (Amulet retrieval and score entry) completes
|
1. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
||||||
without defects.
|
|
||||||
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
|
||||||
per-game dungeon dimensions instead of the 80x24 constants; open design
|
per-game dungeon dimensions instead of the 80x24 constants; open design
|
||||||
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
||||||
80x24 mode.
|
80x24 mode.
|
||||||
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
2. Note: this repo is exempt from the standard policy scaffold, but the
|
||||||
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
exemption is narrower than it was. A minimal dev Makefile
|
||||||
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
(fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07
|
||||||
REPO_POLICIES.md.
|
request. `Dockerfile.lint` and `script/lint` are now also permitted, and
|
||||||
|
required, along with the `.dockerignore` that scopes their build context:
|
||||||
|
sneak's 2026-08-09 ruling (https://git.eeqj.de/sneak/rgoue/issues/41) is that
|
||||||
|
every repo lints in a container invoked through `script/lint`, and being
|
||||||
|
later and explicit it overrides the 2026-07-07 exemption for those three
|
||||||
|
files only. Still do not add: CI config, `REPO_POLICIES.md`, an application
|
||||||
|
`Dockerfile`, or any other `script/` entrypoint.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -17,7 +16,9 @@ import (
|
|||||||
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
|
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
|
||||||
// expect the save to be taken. It is long enough that a loaded machine
|
// 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
|
// 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
|
const autoSaveWait = 10 * time.Second
|
||||||
|
|
||||||
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
|
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
|
||||||
@@ -34,12 +35,14 @@ const autoSaveWait = 10 * time.Second
|
|||||||
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Same mix as TestTurnLoopCrashSweep: the spaces answer any --More--
|
// Same mix as TestTurnLoopCrashSweep — the spaces answer any --More--
|
||||||
// prompt, and the script is long enough that the drive never runs it
|
// prompt — on a driveTerm, so the drive can run for as long as the
|
||||||
// out.
|
// saves take rather than for as long as a script lasts. The '.' and
|
||||||
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
|
// 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.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||||
g.startLevel()
|
g.startLevel()
|
||||||
g.prePlay()
|
g.prePlay()
|
||||||
@@ -74,14 +77,50 @@ func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
|||||||
|
|
||||||
// driveUntilDone runs turns until the saving goroutine is finished,
|
// driveUntilDone runs turns until the saving goroutine is finished,
|
||||||
// fortifying the hero each turn so no death exits the test binary. The
|
// 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
|
// condition it waits on is that goroutine finishing — nothing else.
|
||||||
// failing it.
|
//
|
||||||
|
// 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{}) {
|
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const maxTurns = 1000
|
for {
|
||||||
|
|
||||||
for range maxTurns {
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
return
|
return
|
||||||
@@ -91,8 +130,6 @@ func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
|
|||||||
fortify(g)
|
fortify(g)
|
||||||
g.command()
|
g.command()
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Fatal("the turn loop ran out of turns before the saves were taken")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
|
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
|
||||||
@@ -266,9 +303,7 @@ func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
|
|||||||
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
|
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
g := New(Params{Seed: 5, Term: &testTerm{
|
g := New(Params{Seed: 5, Term: &driveTerm{script: []byte("s . ")}})
|
||||||
input: []byte(strings.Repeat("s . ", 200)),
|
|
||||||
}})
|
|
||||||
g.FileName = ""
|
g.FileName = ""
|
||||||
g.startLevel()
|
g.startLevel()
|
||||||
g.prePlay()
|
g.prePlay()
|
||||||
@@ -437,6 +472,66 @@ func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
|
|||||||
return g
|
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
|
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
|
||||||
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
|
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
|
||||||
// block, cannot reproduce.
|
// block, cannot reproduce.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -530,3 +532,962 @@ func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) {
|
|||||||
t.Error("'+' consumed a turn; C sets after = FALSE")
|
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