13 Commits

Author SHA1 Message Date
e3ab4aba8b build: add a make cover target for per-function coverage (closes #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.

Both write files, so neither is in check, and neither may be added to it:
check must not modify the working tree. Their output lands under the
already-ignored build/.

Verified: make cover printed per-function lines and a 62.6% total,
make cover-html wrote build/coverage.html, and git status stayed clean.
GOFLAGS=-count=1 make check green in 24s with the lint layer executing
(12.0s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.037s, game 3.410s); check is still fmt-check lint test and git status is
clean afterwards.
2026-08-10 13:48:58 +00:00
60442ce103 build: add a make build target, drop README's raw go build (closes #19)
The executable builds to build/rogue. All generated artifacts go under
build/, which .gitignore covers as a whole; a target writing outside it
can commit its output.

build is in neither check nor test: check stays fmt-check lint test and
still writes nothing into the working tree.

README's "Building and running" block and the run examples use
./build/rogue, and no raw go invocation is left in the file.

Verified: make build writes build/rogue and git status stays clean;
GOFLAGS=-count=1 make check green in 34s with the lint layer executing
(21.9s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.027s, game 3.450s), git status clean afterwards.
2026-08-10 13:48:58 +00:00
6f997b8d5c docs: trim the lint-gate comments to the traps (closes #44)
Comments and documentation only; the docker build invocation and its three
flags are byte-identical and .dockerignore's effective rules are unchanged.

script/lint and Dockerfile.lint stated how the shape was derived — why two
stages, why `golangci-lint config verify` was omitted, what earlier drafts
of the comments claimed. That is in the history. What survives is the three
traps, each of which yields a green run over an unlinted or partly linted
tree: --target and --no-cache-filter must both stay with $stage matching the
stage name in Dockerfile.lint; --target checks that the stage exists, not
that it runs golangci-lint, and halts the build there; and .dockerignore
decides what reaches the container, so excluding a self-contained Go file
drops it from the lint silently.

The TODO.md entry loses its "Hardened" and "Corrected" paragraphs, which
argued with earlier versions of themselves, and keeps the flags, the durable
property, the three unguarded seams, and the evidence that the gate was
verified rather than assumed.
2026-08-10 13:38:15 +00:00
9f079ab594 Merge next: run all linting in Docker (closes #41)
Pins golangci-lint by digest in Dockerfile.lint and removes the host lint path entirely, killing the false-green class that started the issue.
2026-08-10 15:33:21 +02:00
3eb9f81fc4 docs: name the two required flags, and record .dockerignore as part of the gate
Comments and documentation only; the docker build invocation and its flags
are untouched, and .dockerignore's effective rules are unchanged.

Two prose gaps from review. First, "both flags below must stay" had lost
its anchor: it ended a paragraph naming only --no-cache-filter, --target
was not introduced until the next one, and three flags follow on the
command, so a reader could pick the wrong pair. It now names --target and
--no-cache-filter explicitly.

Second, the list of things the tooling does not check covered the $stage
seam but not .dockerignore, which sits in the same trust boundary and is
the more likely thing to be edited — the first review on this change
actively suggested extending it for build artifacts. Only what reaches the
container is linted, so excluding a Go source there removes it from the
lint with no warning. Verified rather than asserted: a planted violation
plus that one path in .dockerignore yields `0 issues.` at exit 0 with the
violation still in the working tree, while excluding a file other code
still references fails loudly on `undefined:` typecheck errors instead.
The warning is recorded in script/lint alongside the $stage seam and in
.dockerignore itself, where the edit would actually be made.
2026-08-10 13:29:44 +00:00
20cfb47912 build: define the lint stage name once so the two flags cannot diverge
The previous commit claimed --target and --no-cache-filter "validate each
other's magic string". They do not. --target validates only its own
argument; a typo confined to --no-cache-filter left the build green and
linting nothing:

    docker build --target lint --no-cache-filter=lnit ...
    #10 [lint 2/2] RUN golangci-lint run ... CACHED   exit 0

Three of the four edit paths were caught and one was not, so the original
false green survived in the narrow case.

The duplication was the defect: the stage name appeared twice on one
command line and nothing tied the copies together. Correcting only the
prose would have left the hazard live and merely warned about, so the name
is now written once, as `stage=lint`, and passed to both flags. Divergence
is unrepresentable rather than documented — there is a single name to get
wrong, and --target rejects it loudly when it is not a stage in
Dockerfile.lint, which now covers the filter too because it is the same
string.

The comments in script/lint and Dockerfile.lint and the TODO.md entry drop
the false "validate each other" claim and state the real property, along
with the residual hazard that is genuinely unguarded: --target checks that
the name exists, not that it names the stage which actually runs
golangci-lint, and it stops the build there, so relocating the lint step
or appending a stage after it would go unnoticed.
2026-08-10 13:11:42 +00:00
329c03f06e build: make the lint cache-busting self-validating in script/lint
`--no-cache-filter=lint` is silently ignored by BuildKit when no stage
matches the name, so the entire anti-false-green mechanism hung on one
unvalidated magic string: renaming or mistyping the `lint` stage would
have left the lint layer served from cache and `script/lint` reporting
green having linted nothing. Reproduced here — with the filter pointed at
a nonexistent stage and no `--target`, an unchanged tree built with
`RUN golangci-lint run ... CACHED` and exited 0.

`--target lint` closes it: a stage name that does not exist now fails
loudly (`target stage "nosuchstage" could not be found`, exit 1) instead
of passing. The two flags name the same stage from the same string and
validate each other; both the script and the stage definition in
Dockerfile.lint carry a comment saying they must be kept in sync.

`--output=type=cacheonly` drops the image export. Nothing consumes the
image — the deliverable of this build is an exit code — and the export
cost seconds per run and left one dangling image behind every time, on a
host where pruning is prohibited. The lint stage still executes and a
lint failure still exits non-zero, both verified rather than assumed.

TODO.md: the 2026-08-07 entry's claim that the repo has no linter pin and
lints on the host is marked superseded in place rather than rewritten;
the narrowed scaffold exemption now names `.dockerignore` alongside
`Dockerfile.lint` and `script/lint`; and the specific wall-clock timings
are replaced by the durable property they were evidence for, since they
vary per host and per run.
2026-08-10 12:55:40 +00:00
599286a88e build: run golangci-lint in a pinned container via script/lint (closes #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` becomes a thin shim over script/lint. This removes the host
linter install that produced a false green here, 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.

Two deliberate divergences from the sneak/homoicon reference shape:

  - Two stages rather than one. A cached `deps` stage holds
    `go mod download`, then `FROM deps AS lint` carries the source copy
    and the lint run, and script/lint builds with
    `--no-cache-filter=lint`. Caching of the lint result is explicitly
    waived (a cached build lints nothing), and splitting the stages means
    busting the lint layer does not re-fetch the module cache over the
    network on every run.

  - No `golangci-lint config verify` step. It resolves its JSON schema
    over a live, unpinned HTTPS call: an unpinned network input inside
    the one step whose purpose is a pinned, reproducible gate, and a
    schema-host outage would surface as a red build. `golangci-lint run`
    already fails on a malformed config. The reason is recorded in a
    comment in Dockerfile.lint.

.dockerignore excludes .git only; the lint reads the Go sources,
go.mod/go.sum and .golangci.yml, none of which come from there.

The TODO.md scaffold-exemption note is narrowed rather than dropped:
Dockerfile.lint and script/lint are now permitted and required, while CI
config, REPO_POLICIES.md, an application Dockerfile and any other
script/ entrypoint still are not.

Verified, since a green docker build is the classic false green: two
consecutive script/lint runs on an unchanged tree each showed the
`golangci-lint run` layer executing (9.8s and 7.9s, both "0 issues.")
while the deps layers reported CACHED; a deliberate indent-error-flow
violation failed the build naming that finding and the unused one, and a
revert went clean again. `make check` green.
2026-08-10 12:33:41 +00:00
bde4eae450 Merge fix/autosave-turn-budget-36 (drive on a condition, not a turn budget) 2026-08-09 19:18:56 +02:00
clawbot
3061931291 test: drive the autosave race test to a condition, not a turn count (closes #36)
TestAutoSaveOnSignalRacesTurnLoop failed intermittently under load. The
captured failure text settles what it 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 is fine; the
test's own drive loop ran out of its fixed 1000-turn budget first.

Confirmed by instrumenting the loop to report the turns it actually
used. The count tracks 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 under the doubled
load of the verbose rerun the test target performs after a failure. The
turns spent 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. Raising it
would hide the flake, not fix it. Each old-code failure took 0.12
seconds - 1000 turns burned in a tenth of a second - which is why every
attempt to reproduce this by loading the host failed: the cap was never
a wall-clock allowance at all.

So the budget is gone rather than larger. driveUntilDone drives until
the saving goroutine finishes and nothing else. Termination still holds,
it just belongs to the code under 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, because g.sigSave is one deep and an unserviced request stays in
the channel for every later call to find full and fail on at once; what
fails is then the real assertion, "saves taken = 0, want 25", rather
than "out of turns". That is not the worst case, and the comment states
the bound that actually holds: 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. It
needs about ten seconds of scheduler starvation per save against the
0.12s-per-1000-turns regime above, so it is remote, and a turn cap did
not bound it either.

Removing the cap exposed a second assumption underneath it. 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. An uncapped drive wedged inside a single command() call. The two
drive tests now use driveTerm, a headless terminal whose script repeats.
Repeating is necessary and not sufficient, and the comment on driveTerm
says which property is load-bearing: ' ' clears After outright and all
eight movement keys clear it on a refused step, so a script of only
those keys wedges exactly as testTerm's tail did - with the script set
to " " the drive hits the 30s timeout inside command(). What makes the
wedge impossible is that the cycle always contains an unconditional
turn-taker, and these scripts contain two, '.' and 's', neither of which
can be refused by being blocked in all directions, Held, in a bear trap,
or under NoCommand > 0. Trimming both out would bring the wedge back.

The guard is undiminished, shown by mutation and reverted afterwards.
Reverting the fix from the earlier signal-autosave work - AutoSaveOnSignal
replaced by a direct g.autoSave(), encoding on the calling goroutine -
still fails the test with a flood of DATA RACE reports (139 here, 62-110
on another machine; the property is what is pinned, not the number),
the encoder reading what the turn loop writes. Removing
serviceAutoSaveRequest from command() still fails it too, now in 10s
with "saves taken = 0, want 25" instead of by hanging.

Under load: at GOMAXPROCS=2 on a 48-core host at load ~150, with an
unrelated deliberate failure in the tree so every run took the verbose
rerun, the old code failed 8 of 8 runs and the new code 0 of 8. 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.

No non-test code changed. make check green, lint 0 issues, .golangci.yml
byte-identical.
2026-08-09 17:06:45 +00:00
13caec4298 Merge test/wizard-coverage (wizard commands verified against C) 2026-08-09 18:15:32 +02:00
df45f4cb24 Merge trap coverage (eight trap effects verified against C) 2026-08-09 18:15:08 +02:00
6f409bda9e Cover the wizard commands with C-verified unit tests (closes #7)
game/wizard.go had no tests of its own. It 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, so a defect in either leaks into a normal game.

Adds coverage for createObj (pack filing and the gold arm),
createWeaponArmor, createRing, showMap, whatis, whatisPick, setKnow,
teleport and command.go's wizardKit. Package coverage 60.6% -> 62.4%.
Expected values are transcribed from wizard.c, command.c, extern.c,
weapons.c and rogue.h rather than read off the port; wizard mode is
entered through Params.Wizard, the field main.go fills from
ROGUE_WIZARD, so no test pokes the flag.

Two notes from the C. A wizard-created "cursed" weapon is not cursed in
either language: init_weapon assigns o_flags over the ISCURSED bit
create_obj had just set, leaving only the o_hplus penalty, and the test
pins the whole flag word to the init_dam[] row to say so. And show_map
turns standout on for a square missing F_REAL but off only for a square
whose whole flag word is zero. Exactly three sites clear F_REAL:
putpass, which sets F_PASS first and so leaves 0x80; door's secret-door
arm, on a room-wall exit still holding exactly F_REAL, leaving zero;
and new_level's trap loop, whose rnd(NTRAPS) is 0..7, so the T_DOOR
(00) case leaves zero as well. C's wstandend therefore does fire, 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. That display-only difference is reported on
the issue and left alone here, and the test asserts standout only up to
the first secret square.

No game behavior is changed.
2026-08-09 16:01:07 +00:00
9 changed files with 1388 additions and 49 deletions

8
.dockerignore Normal file
View 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
View File

@@ -1,4 +1,5 @@
*.log *.log
*.out *.out
*.test *.test
/build/
/rogue /rogue

20
Dockerfile.lint Normal file
View 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 ./...

View File

@@ -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

View File

@@ -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
View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View 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 "$@"