Compare commits
85 Commits
refactor/l
...
aa8aeb2497
| Author | SHA1 | Date | |
|---|---|---|---|
| aa8aeb2497 | |||
| a653cc76f2 | |||
| 1142f43aed | |||
| 727dfb2642 | |||
| c95f98ffe5 | |||
| 630038eedb | |||
| f7670cf86a | |||
| 85354f2e6b | |||
|
|
3a01283358 | ||
| e1bf46b241 | |||
| dfb34be1c4 | |||
| 4aa4babe40 | |||
| af3050b187 | |||
| eb31473ef0 | |||
| 56bcad9fc6 | |||
| c922a16781 | |||
| e376b2bf11 | |||
| d6cd418f38 | |||
| 63d1e797e2 | |||
| 8ce238dd62 | |||
| c30da22e43 | |||
| e595b87718 | |||
| 11223caa7c | |||
| e7e1bc3c40 | |||
| 061da11877 | |||
| 0e6ed41351 | |||
| b431af8b74 | |||
| cb1e302102 | |||
| bcdfaf4ab4 | |||
| a7d27ef65f | |||
| 194ce1dd16 | |||
| cd0ba6c8ee | |||
| 8241cf4bee | |||
| 35b538e888 | |||
| 88f18fc635 | |||
| ad098f9d99 | |||
| 5b7e258195 | |||
| 5c14a829aa | |||
| b68836dde0 | |||
| 730d91d160 | |||
| 71713d68b7 | |||
| 444bc30f2c | |||
| ff7ee95395 | |||
| 8895db530d | |||
| 3e1c30c787 | |||
| 432ea4f019 | |||
| bac9e361bc | |||
| 9083967ed3 | |||
| 80484bcd31 | |||
| 43b4fbe746 | |||
| 389db14bbf | |||
| e1f065e783 | |||
| 0b798c9c82 | |||
| 0274460e62 | |||
| c6dae3cf3d | |||
| 5849dddcf0 | |||
| cc2efb86e8 | |||
| ea68df32f0 | |||
| fec79b939a | |||
| aa57349c34 | |||
| 4a248eb392 | |||
| a20f500655 | |||
| ebe477ba28 | |||
| 1a25beead8 | |||
| 8e2915f60d | |||
| 3047f729aa | |||
| cc025eb808 | |||
| acef593288 | |||
| 0b56ac8019 | |||
| a094f7c6c3 | |||
| 0caaa14198 | |||
| d3ef07cfa7 | |||
| f432c8718c | |||
| ae79fd5e84 | |||
| 6d798c56ed | |||
| 0554f5d4f1 | |||
| 6850c87ae7 | |||
| 65a1cd68b8 | |||
| 525465a68b | |||
| a49d857970 | |||
| 32067eb318 | |||
| d6aa74d9f1 | |||
| a8feb6c05d | |||
| 50afbec8e3 | |||
| 5ba9fe8f66 |
34
.golangci.yml
Normal file
34
.golangci.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
version: "2"
|
||||
|
||||
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||
# linters.settings, not top-level linters-settings) so that the
|
||||
# thresholds below are actually applied by golangci-lint >= v2.
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- depguard # Dependency allow/block lists
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 50
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
1713
ARCHITECTURE.md
1713
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
88
MEMORY.md
Normal file
88
MEMORY.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Project Memory
|
||||
|
||||
Working notes for agents on this repo. Read this alongside TODO.md (which holds
|
||||
the step queue and workflow) before starting work.
|
||||
|
||||
## Error handling
|
||||
|
||||
Panicking on bad/unexpected errors is allowed and preferred over threading
|
||||
unlikely error returns through game code — e.g. write-side Close/encode failures
|
||||
where continuing would mean corrupt state. Return errors where a caller
|
||||
genuinely handles them (save-file prompts, restore validation). Reserve
|
||||
deliberate `_ =` discards for true best-effort paths (scorefile writes,
|
||||
`Terminal.Interrupt`'s post to a full event queue), always with a comment saying
|
||||
why.
|
||||
|
||||
Signal-time autosave used to be on that list and no longer is (issue #24). It is
|
||||
best effort in the sense that nothing can be reported to a player whose terminal
|
||||
is already going away, but the outcome is a value, not a discard: the signal
|
||||
goroutine calls `AutoSaveOnSignal`, which hands the save to the game goroutine —
|
||||
the only one allowed to touch game state — and returns whether it was taken
|
||||
before the deadline. The game answers between turns (`command`), while parked
|
||||
waiting for a key (`readchar`), and while parked in the `!` shell escape
|
||||
(`runShellEscape`). `saveFile` writes a temporary file and renames it over the
|
||||
target, so a save that fails or never happens leaves the player's previous save
|
||||
whole; never reintroduce a `Remove` before the write in `autoSave`, and never
|
||||
encode game state from any goroutine but the game's.
|
||||
|
||||
Do not upgrade that into "the snapshot is always taken between commands" — it is
|
||||
not. What is true is that the encode runs on the state-owning goroutine, so the
|
||||
snapshot is internally consistent and restorable. Only the check at the top of
|
||||
`command` is a between-commands snapshot; the other two service points both sit
|
||||
inside a `command` call already under way. `readchar` is reached from
|
||||
mid-command prompts (`--More--`, `askOverwrite`, `getStr`, direction and pack
|
||||
prompts) with the command's mutations already applied, and `runShellEscape` is
|
||||
reached from `shell`, an ordinary `'!'` command handler, with that turn's
|
||||
`DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER pass not yet.
|
||||
Restoring re-enters `playit` at the top of `command`, so either way the rest of
|
||||
that command is lost and a fresh BEFORE pass runs on top of the one already in
|
||||
the snapshot. That is acceptable and documented; two successive false claims —
|
||||
first that `readchar` was safe, then that two of the three service points were
|
||||
between-commands — were caught in review of PR #26, and neither may come back.
|
||||
|
||||
Related, and easy to reintroduce: work moved onto a helper goroutine must not be
|
||||
allowed to panic there. A panic at the top of any goroutine kills the process
|
||||
without running the other goroutines' defers, including `cmd/rogue/main.go`'s
|
||||
`defer t.Fini()`, which is what leaves a raw tty (issue #12). `runShellEscape`
|
||||
recovers its helper's panic and re-raises it on the game goroutine for exactly
|
||||
that reason.
|
||||
|
||||
C's exit() calls are not unwound: one game run is one process, so myExit
|
||||
(game/rip.go) restores the terminal via Terminal.Fini and calls os.Exit(0), and
|
||||
Run() never returns. There is nothing to recover — do not write code that
|
||||
expects to regain control after game-over. The testing consequence is that any
|
||||
death (combat, starvation, level drain, freezing) exits the _test binary_, so
|
||||
tests drive command() directly rather than Run(), and crash-sweep drives pin the
|
||||
hero each turn with the fortify() helper in game/run_test.go.
|
||||
|
||||
## Linting
|
||||
|
||||
The .golangci.yml is byte-identical to the canonical shared config and must not
|
||||
be edited — not even to add an exception. To disable a linter, ask sneak,
|
||||
explaining what the linter does; approved exceptions are recorded as in-code
|
||||
//nolint directives (file-level where a whole file is affected) carrying the
|
||||
approval date, which is what keeps the config canonical. Approved so far:
|
||||
testpackage, exhaustive, and mnd (2026-07-07). paralleltest was approved on
|
||||
2026-07-06 but the exception is no longer in force — it was fixed instead, with
|
||||
t.Parallel() in all 32 tests. The complexity linters (cyclop, gocognit, nestif)
|
||||
are enabled and clean as of refactor step 7 (2026-07-07): the whole
|
||||
golangci-lint run is 0 issues, so keep it that way — decompose new hot spots
|
||||
rather than reaching for a nolint. Line-level //nolint with a reason is used
|
||||
sparingly for C-faithfulness (e.g. the authentic "missle" message spellings) and
|
||||
provably-safe gosec conversions; each needs a justifying comment.
|
||||
|
||||
## Faithfulness
|
||||
|
||||
Behavior must not change during the idiomatic-Go refactor unless a TODO step
|
||||
says so. The 80x24 seed-compatible gameplay, message text (including original
|
||||
typos), RNG call order, and C quirks (documented in tests like
|
||||
TestHoldScrollGreedyMonsterQuirk) are contract. Doc comments keep their "(file.c
|
||||
func_name)" breadcrumbs.
|
||||
|
||||
## Debugging
|
||||
|
||||
Write real, committed test files with t.Logf output and run them with the make
|
||||
targets — `make test` (or `make check` for the full gate); never raw `go test`.
|
||||
The target carries `-timeout 30s -race -cover` and reruns verbosely on failure,
|
||||
so a raw invocation silently drops the race detector. No throwaway scratch
|
||||
scripts. Successful debug probes become regression tests.
|
||||
39
Makefile
Normal file
39
Makefile
Normal file
@@ -0,0 +1,39 @@
|
||||
# Development convenience targets. This repo is exempt from the standard
|
||||
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
|
||||
# is only a thin wrapper around the Go toolchain, golangci-lint, and
|
||||
# prettier so `make fmt` / `make check` behave the same as in sneak's
|
||||
# other repos.
|
||||
|
||||
GO_PKGS := ./...
|
||||
MD_FILES := $(shell git ls-files '*.md')
|
||||
PRETTIER := prettier --tab-width 4 --prose-wrap always
|
||||
|
||||
.PHONY: check fmt fmt-check lint test
|
||||
|
||||
# Format, lint, and test — the full local pre-commit gate.
|
||||
check: fmt-check lint test
|
||||
|
||||
# Format Go and Markdown in place.
|
||||
fmt:
|
||||
gofmt -w .
|
||||
$(PRETTIER) --write $(MD_FILES)
|
||||
|
||||
# Fail if any Go or Markdown file is not formatted.
|
||||
fmt-check:
|
||||
@unformatted="$$(gofmt -l .)"; \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; \
|
||||
fi
|
||||
$(PRETTIER) --check $(MD_FILES)
|
||||
|
||||
# Run the house linter (config in .golangci.yml).
|
||||
lint:
|
||||
golangci-lint run $(GO_PKGS)
|
||||
|
||||
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
|
||||
# full output and still fail the target (the first run already proved the
|
||||
# tests are broken, so a flaky pass on the rerun must not rescue the build).
|
||||
test:
|
||||
@go test -timeout 30s -race -cover $(GO_PKGS) || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 30s -race -v $(GO_PKGS); exit 1; }
|
||||
47
README.md
47
README.md
@@ -2,19 +2,19 @@
|
||||
|
||||
[](LICENSE.TXT)
|
||||
|
||||
**Rogue** is the original dungeon-crawling adventure game that spawned an
|
||||
entire genre. This branch is a faithful Go port of Rogue 5.4.4: explore
|
||||
procedurally generated dungeons, fight monsters, collect treasure, and
|
||||
attempt to retrieve the Amulet of Yendor.
|
||||
**Rogue** is the original dungeon-crawling adventure game that spawned an entire
|
||||
genre. This branch is a faithful Go port of Rogue 5.4.4: explore procedurally
|
||||
generated dungeons, fight monsters, collect treasure, and attempt to retrieve
|
||||
the Amulet of Yendor.
|
||||
|
||||
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman
|
||||
(1980–1983, 1985, 1999).
|
||||
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman (1980–1983,
|
||||
1985, 1999).
|
||||
|
||||
The port is function-by-function faithful to the classic C sources — same
|
||||
dungeon generation (seed-compatible RNG), same combat math, same item
|
||||
tables, same messages. The C reference implementation lives on the
|
||||
`master` and `modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
documents both the original program structure and the design of this port.
|
||||
dungeon generation (seed-compatible RNG), same combat math, same item tables,
|
||||
same messages. The C reference implementation lives on the `master` and
|
||||
`modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md) documents both the
|
||||
original program structure and the design of this port.
|
||||
|
||||
## Building and running
|
||||
|
||||
@@ -40,13 +40,12 @@ go build ./cmd/rogue
|
||||
|
||||
Press `?` in game for the full list.
|
||||
|
||||
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run
|
||||
until adjacent)
|
||||
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run until
|
||||
adjacent)
|
||||
- **`.`** rest, **`s`** search for hidden doors and traps
|
||||
- **`i`** inventory, **`,`** pick up, **`d`** drop
|
||||
- **`q`** quaff potion, **`r`** read scroll, **`e`** eat food
|
||||
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on /
|
||||
remove ring
|
||||
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on / remove ring
|
||||
- **`t`** throw, **`z`** zap a wand, **`f`**/**`F`** fight
|
||||
- **`>`**/**`<`** take the stairs
|
||||
- **`S`** save, **`Q`** quit
|
||||
@@ -61,8 +60,8 @@ export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
||||
ROGUE_WIZARD=1 SEED=12345 ./rogue
|
||||
```
|
||||
|
||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob
|
||||
snapshots and, as in the original, are deleted when restored.
|
||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
||||
and, as in the original, are deleted when restored.
|
||||
|
||||
## Code layout
|
||||
|
||||
@@ -73,13 +72,19 @@ term/ tcell-backed terminal, replacing curses
|
||||
cmd/rogue/ the executable
|
||||
```
|
||||
|
||||
The engine package is fully headless-testable: `go test ./game/` runs
|
||||
scripted game sessions, dungeon-generation golden checks, and an RNG
|
||||
compatibility test against the original C generator.
|
||||
The engine package is fully headless-testable: `make test` runs scripted command
|
||||
sequences, dungeon-generation golden checks, and an RNG compatibility test
|
||||
against the original C generator.
|
||||
|
||||
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
||||
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race
|
||||
detector with coverage and a timeout), and `make check` (all three). Use the
|
||||
targets rather than invoking `go test` directly — they carry the flags the
|
||||
project relies on.
|
||||
|
||||
## License
|
||||
|
||||
BSD-style; see [LICENSE.TXT](LICENSE.TXT).
|
||||
|
||||
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn
|
||||
Wichman. All rights reserved.
|
||||
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman.
|
||||
All rights reserved.
|
||||
|
||||
628
TODO.md
628
TODO.md
@@ -1,120 +1,548 @@
|
||||
# Workflow
|
||||
|
||||
* branch (from `main`)
|
||||
* do the work in Next Step
|
||||
* move Next Step to the top of Completed Steps
|
||||
* move the top item of Future Steps into Next Step
|
||||
* commit (`TODO.md` changes in the same commit as the work)
|
||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||
* push
|
||||
- branch (from `main`)
|
||||
- do the work in Next Step
|
||||
- move Next Step to the top of Completed Steps
|
||||
- move the top item of Future Steps into Next Step
|
||||
- commit (`TODO.md` changes in the same commit as the work)
|
||||
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||
- push
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0
|
||||
|
||||
The port on main is complete and faithful (function-by-function from
|
||||
Rogue 5.4.4 C; reference sources on c-master/modern-rogue). Current
|
||||
phase: refactor from a transliterated port into idiomatic Go — one
|
||||
feature branch per step below, descriptive naming, real types, house
|
||||
style per ~/dev/prompts/prompts/CODE_STYLEGUIDE_GO.md.
|
||||
The port on main is complete and faithful (function-by-function from Rogue 5.4.4
|
||||
C; reference sources on c-master/modern-rogue). Current phase: refactor from a
|
||||
transliterated port into idiomatic Go — one feature branch per step below,
|
||||
descriptive naming, real types, house style per
|
||||
~/dev/prompts/prompts/CODE_STYLEGUIDE_GO.md.
|
||||
|
||||
Refactor ground rules:
|
||||
|
||||
- Behavior must not change unless a step says so. The full test suite
|
||||
(scripted sessions, generation invariants, C-compatible RNG goldens)
|
||||
gates every step; 80x24 seed-compatible gameplay stays intact.
|
||||
- Renames keep the C lineage greppable: doc comments retain their
|
||||
"(file.c func_name)" breadcrumbs, and the docs refresh step adds a
|
||||
C-name → Go-name table to ARCHITECTURE.md.
|
||||
- Behavior must not change unless a step says so. The full test suite (scripted
|
||||
sessions, generation invariants, C-compatible RNG goldens) gates every step;
|
||||
80x24 seed-compatible gameplay stays intact.
|
||||
- Renames keep the C lineage greppable: doc comments retain their "(file.c
|
||||
func_name)" breadcrumbs, and the docs refresh step adds a C-name → Go-name
|
||||
table to ARCHITECTURE.md.
|
||||
|
||||
# Next Step
|
||||
|
||||
Adopt the house Go linting standards: copy .golangci.yml from the
|
||||
prompts repo and bring game/, term/, and cmd/ lint-clean (the port is
|
||||
greenfield code, so no exemptions apply).
|
||||
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
|
||||
wizard commands).
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-07-06 Module base path updated to git.eeqj.de/sneak/rgoue
|
||||
(go.mod, term/ and cmd/ imports, ARCHITECTURE.md, version string).
|
||||
- 2026-07-06 Refactor step 3 (refactor/object-fields): Object.Arm split
|
||||
into ArmorClass/Charges/GoldValue/Bonus (rings); Stats.Arm →
|
||||
ArmorClass; damage strings parsed once into DiceSpec at table
|
||||
definition (ParseDice keeps C roll_em parse semantics, incl. "%%%x0"
|
||||
and "000x0" edge cases, regression-tested); save format 5.4.4-go3.
|
||||
- 2026-07-06 Refactor step 2 (refactor/typed-kinds, b940cfc):
|
||||
ObjectKind separates item category from map glyph (Object.Type byte
|
||||
→ Kind ObjectKind with Glyph()); PotionKind/ScrollKind/RingKind/
|
||||
WandKind/WeaponKind/ArmorKind/TrapKind typed iota enums with
|
||||
Stringer; typed accessors on Object; getItem/inventory/whatis
|
||||
filters take ObjectKind (KindCallable/KindRingOrStick replace
|
||||
CALLABLE/R_OR_S); save format bumped to 5.4.4-go2. Suite green.
|
||||
- 2026-07-06 Refactor step 1 (refactor/descriptive-constants): renamed
|
||||
all flag bits, trap types, item subtype constants, and Max* counts to
|
||||
descriptive names (IsHuh→Confused, SeeMonst→SenseMonsters,
|
||||
WsHasteM→WandHasteMonster, MaxSticks→NumWandTypes, ...);
|
||||
Level.NTraps→TrapCount; C names kept as comment breadcrumbs. Pure
|
||||
rename, suite green.
|
||||
- 2026-08-09 Command dispatch audit (`audit/command-switch-coverage`, closes
|
||||
#31): checked every case label in C's `command.c` against this port's
|
||||
dispatch, and left the audit behind as a standing test
|
||||
(`game/dispatch_test.go`) so the two cannot silently drift again. **No further
|
||||
missing keys were found** — `'+'` (#11) was the only one. That is the result,
|
||||
and it is worth recording as a negative: the class of bug exists, it has now
|
||||
been searched for exhaustively rather than stumbled upon, and the search came
|
||||
back empty. Three tables transcribe C's labels with their line numbers:
|
||||
main-switch keys answered from `commandHandlers`, main-switch keys whose arms
|
||||
need `dispatchKey`'s own switch (the `goto over` re-dispatches, `F`-to-`f`,
|
||||
`a`, `m`), and the `if (wizard)` sub-switch. `commandHandlers` is pinned by
|
||||
set equality in **both** directions: a missing key is the `'+'` bug, and an
|
||||
extra key is the same bug mirrored — a MASTER debug command leaking into
|
||||
ordinary play. Two traps make this audit harder than it sounds and are
|
||||
documented in the file: `rogue.h` 52-53 defines `when` as `break;case`, so a
|
||||
grep for `case ` finds six of the eighty labels; and the main/wizard split is
|
||||
load-bearing, since `'+'` was a divergence in ordinary play precisely because
|
||||
it is a main-switch key. Confirms the port targets the MASTER build — all four
|
||||
`#ifdef MASTER` sites in `command.c` are ported unconditionally, as is
|
||||
`sticks.c` 237.
|
||||
|
||||
- 2026-08-09 Three small lost C behaviors (`fix/lost-c-behaviors`, closes #13):
|
||||
grouped because each is a few lines and all are "restore something the port
|
||||
dropped silently". (1) **"what a bizarre schtick!"**, `sticks.c` 237 — the
|
||||
`otherwise` arm that closes `do_zap`'s switch, which `doZap` had turned into
|
||||
doing nothing at all. Two things about it are easy to get wrong and are why
|
||||
the fix is not one line. It is under `#ifdef MASTER`, **not** under a `wizard`
|
||||
test, so in the MASTER build this port is it printed for every player — gating
|
||||
it on `g.Wizard` would be issue #11's trap in reverse. And `WS_NOP` is a case
|
||||
of that switch in its own right (`when WS_NOP: break;`), so "no handler ran"
|
||||
cannot be the trigger: the wand of nothing does nothing _quietly_, and only a
|
||||
kind C had no case for is bizarre. Since C's switch covers all 14 `WS_`
|
||||
values, its `otherwise` is reachable only for an `o_which` outside the table,
|
||||
which is exactly what `Object.hasValidWhich` already screens for — so the
|
||||
split needed no new state, just a three-way switch on handler / valid-Which /
|
||||
neither. All three arms fall through to `obj.Charges--`, as C's do: even the
|
||||
bizarre schtick costs a charge. Replaces the deferral comment PR #20 left
|
||||
there. (2) **`CTRL('R')` now actually redraws.** C is
|
||||
`after = FALSE; clearok(curscr, TRUE); wrefresh(curscr);` (`command.c`
|
||||
288-291); the port called `g.refresh()`, the ordinary diffing blit, **which
|
||||
cannot fix the only situation the command exists for** — a screen corrupted by
|
||||
something else's output leaves the game's record of it still correct, so the
|
||||
diff sends nothing and the corruption stays. New `Terminal.Repaint` (tcell
|
||||
`Screen.Sync`, which discards tcell's record of the terminal instead of
|
||||
diffing against it), `Screen.Repaint`, `g.repaint()`; three implementations to
|
||||
update, the same shape as PR #26's `ReadChar` change, so no split was needed.
|
||||
Named for the curses operation, not for tcell: the interface is the game's
|
||||
abstraction. It repaints what was last rendered — C repainted `curscr`, not
|
||||
`stdscr` — so it takes no window, and the arm drops the `refresh()` C never
|
||||
had there (`command` refreshes before the next key read anyway). (3) **The
|
||||
startup greeting**, `main.c` 107-113, which existed nowhere in the tree. New
|
||||
`game.Greeting`, printed by `cmd/rogue/main.go` before `term.New()` — the
|
||||
port's `initscr()`. Only the wizard wording is `#ifdef MASTER`; the other is
|
||||
unconditional. The `%d` is `dnum`, which `main.c` has just assigned to `seed`,
|
||||
so it is `Params.Seed`. Two placement details the issue did not mention and
|
||||
the tests now pin: the printf sits **after** `parse_opts`, so a ROGUEOPTS
|
||||
`name=` is what the player is greeted by and the account name is only the
|
||||
fallback (`Greeting` re-runs `ParseOpts`, which does nothing but assign into
|
||||
fields — no RNG, no screen); and it sits after the `-s`/`-d` handling and
|
||||
after `restore()`, which never returns, so a resumed game does not announce
|
||||
that a dungeon is being dug (`digsNewDungeon`). The game `Greeting` parses
|
||||
into is a throwaway but is built the way `New` builds the real one, tables and
|
||||
home directory included, because `ParseOpts` handles every option and not just
|
||||
the one the greeting reads: `inven=` is matched against `inv_t_name[]`, which
|
||||
lives on the game, so a bare `&RogueGame{}` turned a legal `ROGUEOPTS` into a
|
||||
nil dereference before the player saw a character. No RNG call is added on any
|
||||
path and nothing under `game/testdata/` moved; `TestSeedCompatItemTables` is
|
||||
green against the untouched golden. Mutation-proved, each new behaviour
|
||||
deleted in turn and only its own test failing: dropping the message arm fails
|
||||
`TestZapUnhandledWandSaysBizarreSchtick`; extending it to `WandNothing` fails
|
||||
`TestZapWandOfNothingIsSilent`; putting `g.refresh()` back fails
|
||||
`TestRedrawCommandForcesFullRepaint`; swapping the two wordings, or the
|
||||
ROGUEOPTS name for the account name, fails `TestGreeting`; greeting on the
|
||||
restore path fails `TestDigsNewDungeon`. ARCHITECTURE.md §5.3 gains `Repaint`
|
||||
and the paragraph on why a blit cannot substitute for it. `Next Step`
|
||||
deliberately not rotated: out-of-band issue work.
|
||||
|
||||
- 2026-08-09 The `'+'` wizard-mode toggle (`fix/wizard-toggle-off`, closes #11):
|
||||
C's `command.c` 317-338 has a `when '+'` arm that leaves wizard mode —
|
||||
`wizard = FALSE`, `turn_see(TRUE)`, `msg("not wizard any more")` — and the
|
||||
port had no `'+'` anywhere, so the key fell through `dispatchKey`'s default to
|
||||
`illcom` and answered "illegal command '+'". The password half of that arm was
|
||||
dropped on purpose (wizard mode is `ROGUE_WIZARD` configuration) and is in
|
||||
ARCHITECTURE.md §9; the leave half was lost silently and is not the same
|
||||
decision — it does not touch the password machinery at all. **The substantive
|
||||
part is `turn_see(TRUE)`**, not the flag: wizard sight draws every monster the
|
||||
hero cannot see, so without the re-hide there is no way back to normal
|
||||
visibility once wizard mode is on, and clearing the flag alone would have left
|
||||
the screen lying. New `wizardToggleCommand` in `game/command.go`, registered
|
||||
in `commandHandlers` between `'^'` and `Escape` — C's own switch order, and
|
||||
note that C's arm sits in the **main** command switch under `#ifdef MASTER`,
|
||||
not in the `if (wizard) switch (ch)` sub-switch that `wizardCommand` ports, so
|
||||
it is reachable whether or not `wizard` is set. That makes the non-wizard case
|
||||
a divergence too, and it resolves the way the dropped `passwd()` forces: a
|
||||
password check that no longer exists can never succeed, so the else arm is
|
||||
what C did on a wrong answer, the message "sorry" — no prompt, since nothing
|
||||
typed into one could change the outcome, and none of the
|
||||
`noscore`/`turn_see(FALSE)` bookkeeping of C's unreachable success branch. The
|
||||
choice is stated in the function's doc comment and in §9, whose password row
|
||||
now names the `'+'` enter arm and whose new paragraph records that the leave
|
||||
arm is ported in full. Two tests in `game/wizard_test.go` drive `'+'` through
|
||||
`g.dispatch`: the wizard one spawns a phantom (`ISINVIS` straight from the
|
||||
monster table, so `seeMonst` is false and it is on screen only because wizard
|
||||
sight put it there), asserts the precondition — monster glyph drawn in
|
||||
standout at its cell, `SenseMonsters` set — and then asserts the flag cleared,
|
||||
`SenseMonsters` cleared, the cell back to the map char under the monster with
|
||||
standout off, the exact message, and `After` false; the non-wizard one pins
|
||||
"sorry" and that `'+'` is no longer an illegal command. Mutation-proved:
|
||||
deleting the `turnSee(true)` call fails the test on all three visibility
|
||||
assertions, which is the half a flag-only test would have missed. No RNG call
|
||||
is added — the `turn_off` arm of `turn_see` never reaches `rnd`, only the
|
||||
turn-on arm does — and `TestSeedCompatItemTables` stays green against the
|
||||
untouched golden. `Next Step` deliberately not rotated: out-of-band issue
|
||||
work.
|
||||
|
||||
- 2026-08-09 Cleanups deferred from the PR #26 review (`cleanup/pr26-followups`,
|
||||
closes #27): four items, no behaviour change. (1) The `sig-leave` entry below
|
||||
still argued, in the present tense, that declining to save on SIGINT/SIGQUIT
|
||||
was the safe choice because `AutoSave` encodes live state after removing the
|
||||
file — both halves untrue since #24, and the entry read as a claim about how
|
||||
the code works now rather than a record of what was weighed then. It is in the
|
||||
past tense and marked superseded, pointing at the `fix/autosave-race` entry.
|
||||
Nothing else in the file was touched — in particular the `err113` linter name
|
||||
in the 2026-07-06 entry, which a `grep` for `113` still matches, and the "over
|
||||
a hundred reports" wording the #26 rework had already corrected. Note for
|
||||
anyone chasing this class of bug: the false claim was in the #12 entry, not
|
||||
the #24 one, whose account of the old remove-then-write is correctly past
|
||||
tense — find these by content, since `make fmt` reflows the file and cited
|
||||
line numbers rot. (2) `encodeSnapshot` is `writeSnapshotFile`: it encodes,
|
||||
fsyncs, chmods 0400 and closes, and the old name claimed only the first of
|
||||
those. One call site (`saveFile`), and the doc comment now lists what it does
|
||||
and why the fsync is there. (3) `TestAutoSaveOnSignalWhileInShellEscape` used
|
||||
`t.Error` for its precondition, so a save that was never taken fell through
|
||||
into `assertRestorable`, which can then only report a second, derived failure;
|
||||
it is `t.Fatal`, matching the identical assertion in the blocked-on-input
|
||||
test. (4) `serviceAutoSaveRequest`'s doc comment had a 24-column stub line
|
||||
("The result is still a") left by an earlier edit — `gofmt` does not rewrap
|
||||
comments, so `fmt-check` was legitimately green and nothing would ever have
|
||||
caught it. Rewrapped to the block's width. `Next Step` deliberately not
|
||||
rotated: out-of-band issue work.
|
||||
|
||||
- 2026-08-09 Signal-time autosave moved onto the game goroutine
|
||||
(`fix/autosave-race`, closes #24): the SIGHUP/SIGTERM handler gob-encoded the
|
||||
live game tree from the signal goroutine while the game goroutine was mid-turn
|
||||
mutating it, and `AutoSave` **removed** the save file before encoding — so the
|
||||
failure mode was not a stale save but a deleted one followed by a possibly
|
||||
torn replacement, with a window in which the player had neither. `make test`
|
||||
has run with `-race` since 2026-08-09 and was green, because no test had ever
|
||||
driven the turn loop concurrently with a signal: evidence of untested, not of
|
||||
safe. The handler now writes nothing itself. `AutoSaveOnSignal` posts a
|
||||
request on a one-deep channel, wakes the input read, and waits up to
|
||||
`signalSaveTimeout` (3s) for the game goroutine to take it; the encode happens
|
||||
on the goroutine that owns the state. **The blocked-on-input case is the whole
|
||||
point** — a dropped connection lands while the player is thinking, so a flag
|
||||
checked only between turns would never be looked at — and it is handled by
|
||||
making the read interruptible: `Terminal.ReadChar` returns `(byte, bool)` with
|
||||
`ok == false` meaning "woken by `Interrupt`, no key", `term.Tcell.Interrupt`
|
||||
posts a `tcell.EventInterrupt` onto tcell's own event queue to unpark
|
||||
`PollEvent`, and `readchar` services the request and reads again, so no caller
|
||||
sees the wake-up. The other unbounded park is the `!` shell escape, where a
|
||||
hangup used to save and would otherwise have regressed to not saving: the
|
||||
shell now runs on a helper goroutine and `runShellEscape` selects on {shell
|
||||
finished, save request}, keeping the encode on the game goroutine while it
|
||||
draws nothing. Between turns (`command`) covers a game that is busy rather
|
||||
than parked. The wait is bounded so that a game goroutine wedged with no
|
||||
service point can never stop a signal from getting the process out; giving up
|
||||
costs nothing now that `saveFile` writes a temporary file in the save's own
|
||||
directory, fsyncs it, and renames it over the target instead of truncating in
|
||||
place — a failed or skipped save leaves the previous save whole. New
|
||||
`game/autosave_test.go` drives the real turn loop while a second goroutine
|
||||
asks for 25 saves (the interleaving that never existed before), plus the
|
||||
parked-on-input case with a terminal fake that genuinely blocks, the shell
|
||||
case, the deadline case (previous save byte-for-byte intact), the no-file-name
|
||||
case, and the rename discipline — the last pinned by a handle opened before
|
||||
the save, which still reads the old file whole after it. Each was
|
||||
mutation-proved: reverting `AutoSaveOnSignal` to encode on the calling
|
||||
goroutine (the pre-fix behavior) makes the turn-loop test fail under `-race`
|
||||
with over a hundred reports, and removing each of the three service points
|
||||
fails exactly the test for that park with its own message. `pendingSaver` now
|
||||
reads the game out from under its mutex instead of delegating with it held,
|
||||
because the delegated call blocks until the save is taken — the PR #23
|
||||
review's N3 note, load-bearing rather than hypothetical, and pinned by a test.
|
||||
The SIGINT/SIGQUIT no-save decision and the single-signal-read ordering
|
||||
guarantee are untouched; `savesOnSignal`'s third ground ("safety") is
|
||||
rewritten, since the corruption window it weighed no longer exists.
|
||||
`MEMORY.md` stops listing signal-time autosave among the deliberate `_ =`
|
||||
discards and states the new discipline; `ARCHITECTURE.md` §5.3, the `Terminal`
|
||||
sketch, the C-to-Go mapping row and §9's SIGTSTP paragraph are corrected to
|
||||
match. Two things review caught and this entry records so they are not undone:
|
||||
moving the shell onto a helper goroutine also moved `term.Tcell.ShellEscape`'s
|
||||
`panic` on a failed `Screen.Resume` there, and a panic at the top of any
|
||||
goroutine kills the process without running the deferred calls of the others —
|
||||
including `cmd/rogue/main.go`'s `defer t.Fini()`, so the tty would have been
|
||||
left raw on exactly the path where the terminal is already broken (issue #12's
|
||||
failure, reintroduced on a new path). `runShellEscape` recovers the helper's
|
||||
panic and re-raises it on the game goroutine, pinned by
|
||||
`TestShellEscapePanicUnwindsTheGameGoroutine`. And the doc comment took two
|
||||
rounds to get right: the first version claimed in four places that nothing is
|
||||
half-mutated at the `readchar` service point, and the revision that fixed that
|
||||
claimed two of the three service points were between-commands. Both are false.
|
||||
Only the check at the top of `command` is between commands — `readchar` is
|
||||
reached from mid-command prompts, and `runShellEscape` is reached from
|
||||
`shell`, an ordinary `'!'` command handler dispatched inside `command`, with
|
||||
that turn's `DoDaemons(Before)`/`DoFuses(Before)` already fired and its AFTER
|
||||
pass and ring effects not yet. What is actually guaranteed is that the encode
|
||||
runs on the state-owning goroutine, so the snapshot is internally consistent
|
||||
and restorable, though it may freeze a command half applied. `Next Step`
|
||||
deliberately not rotated: out-of-band issue work.
|
||||
|
||||
- 2026-08-09 Signal-time terminal restore (`sig-leave`, closes #12): the port
|
||||
handled only SIGHUP and SIGTERM, so SIGINT and SIGQUIT killed the process with
|
||||
tcell still holding the tty, leaving the user at a shell with no echo. All
|
||||
four signals now go to one `os/signal` channel read by one goroutine in
|
||||
`cmd/rogue/main.go`, and every path calls `Terminal.Fini` before `os.Exit(0)`
|
||||
— C's `leave()`, "leave quickly but curteously". **The decision** (written
|
||||
into the `savesOnSignal` comment): SIGHUP/SIGTERM keep autosaving,
|
||||
SIGINT/SIGQUIT restore and exit **without** saving. C never saves on INT or
|
||||
QUIT anywhere — `leave()` is endwin-and-exit, `quit()` confirms/scores/exits,
|
||||
`endit()` goes through `fatal()`, and `save.c auto_save` is reserved for
|
||||
HUP/TERM — and the semantics agree: HUP/TERM are involuntary teardown worth
|
||||
rescuing a game from, while INT/QUIT are a deliberate "stop now" that must not
|
||||
become a one-keystroke checkpoint against a save discipline built to be
|
||||
anti-save-scum. A third ground was weighed at the time and has since been
|
||||
superseded: back then `AutoSave` gob-encoded live state that the main
|
||||
goroutine was still mutating, after removing the old file, so declining to
|
||||
save on the signals with nothing to rescue was also the option with no
|
||||
corruption window. That window is gone as of the `fix/autosave-race` entry
|
||||
above (#24) — the encode now runs on the game goroutine and `saveFile` renames
|
||||
a temporary file into place — so nothing here should be read as a statement
|
||||
about how saving works now; the split stands on C and on semantics alone, as
|
||||
the current `savesOnSignal` comment says. The single-reader design closes the
|
||||
window the issue warned about: a second signal arriving mid-save stays unread
|
||||
in the buffer instead of exiting out from under the writer
|
||||
(`TestLeaveOnSignalIgnoresLaterSignals` reproduces exactly that interleaving).
|
||||
New `cmd/rogue/main_test.go` pins the membership of `handledSignals()` itself
|
||||
(`TestHandledSignalsSet` — without it the rest of the file, which iterates
|
||||
that set, would pass against a set that had silently lost SIGINT and SIGQUIT
|
||||
again), and covers the ordering for each signal, the save/no-save split
|
||||
against `savesOnSignal`, the mid-save-second-signal case, the pre-game
|
||||
`pendingSaver` window, and real SIGINT/SIGQUIT/SIGHUP/SIGTERM delivered to the
|
||||
test process through the same `notifySignals` wiring the game uses; the tty
|
||||
leaving raw mode is the one step not checkable headlessly (it needs a
|
||||
controlling terminal), and `term.Tcell.Fini` is a direct pass-through to
|
||||
tcell's `Screen.Fini` that `myExit` already depends on. Two premises in the
|
||||
issue turned out to be wrong and are recorded in ARCHITECTURE.md: `leave()` is
|
||||
not installed on SIGINT/SIGQUIT during play (the wiring is in `mdport.c`, the
|
||||
shipped build calls `md_onsignal_default()` and installs nothing, and
|
||||
`leave()` appears only in the endgame paths of `rip.c`/`main.c`), and Ctrl-C
|
||||
never generated SIGINT here anyway, since tcell's raw mode clears `ISIG` and
|
||||
the key arrives as byte `0x03` — as it did in C, whose `setup()` calls curses
|
||||
`raw()`. The real exposure is `kill -INT`/`kill -QUIT`, a SIGINT to the
|
||||
process group while the `!` shell escape has the screen suspended, and the
|
||||
window **after** `term.New()`: nothing is raw before it, and the handlers used
|
||||
to be installed only once the game existed, leaving the restore path and
|
||||
`-d`'s `DeathDemo()` — which never returns, blocking in `waitFor` inside
|
||||
`death()` — running raw with no handler at all. The handlers are therefore
|
||||
installed immediately after `term.New()`, with the game handed to them
|
||||
afterwards via `pendingSaver`; a signal before the game exists restores the
|
||||
terminal and exits with nothing to save, and the SIGHUP/SIGTERM autosave
|
||||
behavior on the play path is unchanged. ARCHITECTURE.md §9 gained rows for
|
||||
SIGTSTP/`tstp()` (dropped: raw mode means Ctrl-Z cannot reach us, a suspend
|
||||
from the signal goroutine would race the drawing goroutine, and C armed `tstp`
|
||||
only after a `restore()`; the `!` shell escape covers the need), for SIGINT
|
||||
not routing to the interactive `quit()` prompt, and for `auto_save` on the
|
||||
fault signals; §5.3's claim that tcell handles SIGTSTP was false — tcell
|
||||
registers only SIGWINCH — and is corrected. `Next Step` deliberately not
|
||||
rotated: out-of-band issue work.
|
||||
|
||||
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
|
||||
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
|
||||
check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against
|
||||
a 14-entry table and panicked in `fixStick`. Input outside `0-f` overshoots
|
||||
much further rather than going negative: `readchar` returns a `byte`, so the
|
||||
`int(ch-'a') + 10` branch is byte arithmetic and wraps — `'A'` gives 234 and
|
||||
`'!'` gives 202 — and panicked the same way. C's `create_obj()` was equally
|
||||
unchecked, but every C consumer was either a `switch` (defined for any value)
|
||||
or a static-array read past the end (undefined, and survivable in practice),
|
||||
whereas since refactor step 8 one game is one process, so the Go panic kills
|
||||
the game with the terminal still in raw mode. Fixed at the two boundaries a
|
||||
bad `Which` can enter through: `createObj` now rejects an out-of-range choice
|
||||
with a message built from C's own `type_name()` vocabulary and adds nothing to
|
||||
the pack (a deliberate, commented divergence, since C had no defined behavior
|
||||
here to be faithful to), and `Restore` refuses a snapshot describing such an
|
||||
object (`ErrSaveCorrupt`) instead of loading a game that would explode later.
|
||||
Behind those, `whichLimit`/`hasValidWhich` back defensive guards at every
|
||||
dispatch named in the issue: the three effect tables (the new `quaffHandler`,
|
||||
`readHandler`, and `zapHandler` accessors return no handler rather than
|
||||
indexing — for wands that is exactly non-`MASTER` C, which matched no case and
|
||||
still ran `o_charges--`), the `callIt` lore lookups, `identifyType` (whose
|
||||
table is shorter than the scroll table keying it, though no scroll that can
|
||||
reach `readIdentify` overshoots it, so that one is defensive rather than a
|
||||
live bound), `armorClass` for the four `a_class[]` reads, `initWeapon` against
|
||||
the missing `init_dam[]` row for `WeaponFlame`, `fixStick`'s `ws_type[]` read,
|
||||
and `inventoryName`, hoisted so one check covers the scroll-title read the
|
||||
issue listed plus its potion-color, ring-stone, wand-material, weapon and
|
||||
armor siblings. `objectWorth` got the same hoisted guard, since the
|
||||
death-screen appraisal reads the identical per-kind tables. No in-range input
|
||||
changes behavior and no guard consumes a random number — the rejection
|
||||
precedes every `rnd()` call, verified both by an explicit seed-unchanged test
|
||||
and by `TestSeedCompatItemTables` staying green untouched. New
|
||||
`game/wizard_test.go`: the exact reproducer, a rejection sweep over every
|
||||
indexed kind including both wrapping-input forms, an acceptance sweep proving
|
||||
valid choices still build the right item, one no-panic test per guarded family
|
||||
(wand/potion/scroll/armor/weapon), the `fixStick` crash site, the corrupt-save
|
||||
rejection over the wrapped values and a negative `Which` (a decoded snapshot
|
||||
is the only source of one, so it is what exercises the `Which >= 0` arm of
|
||||
`hasValidWhich`), and a check that `whichLimit` still agrees with the table
|
||||
sizes. Each guard was confirmed load-bearing by reverting it and watching the
|
||||
test panic. `Next Step` deliberately not rotated: this was out-of-band issue
|
||||
work.
|
||||
|
||||
- 2026-08-09 Stale-docs correction (`docs-staleness`, closes #3): four claims in
|
||||
`MEMORY.md`/`TODO.md`/`README.md` had gone false and were misdirecting agents
|
||||
— the reviewer on PR #9 repeated one of them verbatim. Each was re-verified
|
||||
against the tree before rewriting. (1) `MEMORY.md` described C's `exit()`
|
||||
being unwound by a `gameEnd` panic recovered in `Run`; refactor step 8 deleted
|
||||
that, `gameEnd` appears nowhere in the sources, and `myExit` (`game/rip.go`)
|
||||
now calls `Terminal.Fini` then `os.Exit(0)` while `Run()` never returns — so
|
||||
the section states the exit model and its testing consequence (a death exits
|
||||
the test binary; hence `fortify()` in `game/run_test.go`). (2) `MEMORY.md`
|
||||
said approved lint exceptions live in a "Repo-specific exceptions" block in
|
||||
`.golangci.yml`; no such block exists and the config is byte-identical to
|
||||
canonical (sha256 `021cc83f…46bcb`), the approvals having moved to in-code
|
||||
`//nolint` directives carrying their dates — and `paralleltest` was listed as
|
||||
an approved disable when it was in fact fixed (no `paralleltest` token in the
|
||||
tree; 32 `t.Parallel()` calls against 32 tests). (3) `MEMORY.md` "Debugging"
|
||||
and (4) `README.md` both told the reader to run `go test` directly, which
|
||||
since PR #9 silently drops `-timeout 30s -race -cover`; both now point at
|
||||
`make test`/`make check`. Also dropped the false "currently v2.12.2" host
|
||||
linter claim from the 2026-08-07 entry (the host is v2.10.1 and nothing is
|
||||
pinned; the pin question is tracked separately). Documentation only — no code,
|
||||
`Makefile`, or config change; `Next Step` deliberately not rotated, since this
|
||||
was out-of-band issue work.
|
||||
|
||||
- 2026-08-09 Policy-shaped `make test` (`make-test-policy-pattern`): the `test:`
|
||||
target was a bare `go test $(GO_PKGS)` and now runs
|
||||
`-timeout 30s -race -cover` with the mandated conditional verbose rerun (on
|
||||
failure it reruns with `-v` and then `exit 1`, so a flaky pass on the second
|
||||
attempt cannot rescue the build). `$(GO_PKGS)` is kept rather than hardcoding
|
||||
`./...`. The substance was `-race`, not the Makefile edit: this is the first
|
||||
time the suite has run under the race detector, and it is clean — no data
|
||||
races across five consecutive uncached runs, including the tcell terminal
|
||||
layer and the `os.Exit`-path playthrough tests. Wall clock 5.1s cold
|
||||
(including the race build) and ~2.3s warm, against the 20s policy budget. The
|
||||
failure path was exercised with a throwaway failing test to confirm the rerun
|
||||
fires and `make` exits non-zero. Build tooling only; no game behavior change.
|
||||
|
||||
- 2026-08-07 Canonical linter config (`golangci-v2.12.2`): replaced
|
||||
`.golangci.yml` with the shared canonical config (v2 schema; settings now live
|
||||
under `linters.settings`, so the `lll`/`funlen`/`cyclop`/`dupl` thresholds
|
||||
actually apply — the old top-level `linters-settings` block was silently
|
||||
ignored). The four repo-specific disables (`mnd`, `exhaustive`,
|
||||
`paralleltest`, `testpackage`) moved out of the config into targeted in-code
|
||||
`//nolint` directives carrying the original approval dates, so the config
|
||||
stays byte-identical to canonical. Real fixes: `t.Parallel()` in all 32 tests,
|
||||
24 long lines wrapped or their comments tightened, control bytes in
|
||||
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
|
||||
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
|
||||
`make lint` runs whatever `golangci-lint` is on the host).
|
||||
|
||||
- 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
|
||||
forces the RNG seed and prints the per-seed item appearance tables (potion
|
||||
colors, scroll names, ring stones, wand/staff materials) before initscr, and
|
||||
captured its output for four seeds as testdata/item_tables.golden.
|
||||
TestSeedCompatItemTables regenerates the same tables from the Go port and they
|
||||
match byte for byte — proving the LCG and its consumption order through the
|
||||
whole init sequence agree with C. The remaining "same dungeon (map)" half
|
||||
would need the harder headless-curses C dump (new_level draws to curses);
|
||||
deferred — the item-table match already validates RNG-order faithfulness
|
||||
through init, and the Go generation goldens guard determinism thereafter.
|
||||
|
||||
- 2026-07-23 Playtest hardening (playtest-hardening): added two death-safe
|
||||
crash-sweep drives through the real turn loop, within the step-8 os.Exit
|
||||
constraint (a fortify() helper pins HP/food/exp and clears the freeze/stuck
|
||||
counters each turn so no death exits the test binary; fixed seeds keep them
|
||||
deterministic). TestDeepPlaythrough uses quaff/read/zap through command
|
||||
dispatch, then descends to depth 8 with a save/restore at depth 4;
|
||||
TestTurnLoopCrashSweep mashes movement/search/rest for 200 turns on four
|
||||
seeds. Neither surfaced a panic. The interactive "play several games at a real
|
||||
tcell terminal" portion needs a human at an 80x24 terminal and is left to the
|
||||
maintainer; the binary's non-interactive paths (`-s` scores) were
|
||||
smoke-tested.
|
||||
|
||||
- 2026-07-23 Docs refresh (docs-refresh): rewrote ARCHITECTURE.md Part 2 (the
|
||||
pre-implementation design sketch) to match the final code — current type/field
|
||||
names (ObjectKind, DiceSpec, split o_arm, step-1 flag names, TrapCount, Level
|
||||
list methods), the static tables now on the per-game gameData struct, the
|
||||
daemon/effect handler tables, the MessageLine extraction, the Terminal
|
||||
interface, the flat gob SaveState, and the New(Params) + os.Exit design. Added
|
||||
§7.1, a C-name → Go-name rename table, and a README note on the make targets.
|
||||
|
||||
- 2026-07-23 Refactor step 8 (refactor/constructor-style): constructor and exit
|
||||
pass. NewGame(Config) → New(Params) and Restore takes Params, so the package's
|
||||
primary type gets the canonical New() constructor with a named-field Params
|
||||
struct (styleguide 139/159). The gameEnd panic unwind is gone: one game run is
|
||||
one process, so myExit restores the terminal (new Terminal.Fini) and calls
|
||||
os.Exit(0), and Run() no longer returns; the four Run()-to-completion tests
|
||||
were reworked/dropped since death (combat or starvation) now exits the process
|
||||
(TestScoreRendersList and TestRunDownStairs preserve what is still drivable;
|
||||
save/restore stays covered by TestSaveRestoreRoundTrip). The 77-column wrap
|
||||
sweep was dropped per sneak (2026-07-23): line lengths left as-is (lll caps at
|
||||
88 and passes).
|
||||
|
||||
- 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects dispatch
|
||||
tables plus a full decomposition sweep — the quaff / readScroll / doZap
|
||||
switches, the attack monster-power switch, the be_trapped switch, the daemon
|
||||
d_func switch, and the command-key switch all became handler tables on
|
||||
gameData (quaffHandlers, readHandlers, zapHandlers, hitHandlers, trapHandlers,
|
||||
daemonHandlers, commandHandlers), one small named method per case. Every
|
||||
remaining cyclop/gocognit/nestif hot spot was split into named helpers across
|
||||
fight, misc (look), command, chase, move, passages, options, pack, things,
|
||||
save, daemons, rooms, score, monsters, rings, rip, io, object, weapons,
|
||||
wizard, and term/tcell, plus three test functions. Effect order and RNG call
|
||||
sequence preserved throughout; the whole golangci-lint run is now 0 issues.
|
||||
|
||||
- 2026-07-07 Refactor step 6 (refactor/god-object-extraction): MessageLine (was
|
||||
MsgLine) owns the msg/addmsg/endmsg machinery, wired to its screen/look/input
|
||||
needs via attach(); RogueGame keeps one-line msg/addmsgf/endmsg shorthands so
|
||||
call sites are unchanged. Player owns pack bookkeeping (nextPackChar,
|
||||
removeFromPack — the state half of leave_pack; leavePack keeps only LastPick
|
||||
tracking). Level owns object/monster list management and lookup (ObjectAt
|
||||
replaces findObj; AddObject/RemoveObject/AddMonster/RemoveMonster replace
|
||||
direct attachObj/detachObj/attachMon/detachMon on level lists).
|
||||
Inventory/pickup UI flows stay on RogueGame deliberately: they are display and
|
||||
turn orchestration, not state surgery.
|
||||
|
||||
- 2026-07-07 Refactor step 5 (refactor/item-combat-ui-renames, three commits,
|
||||
one subsystem each): items — getItem→promptPackItem now returning (obj, ok),
|
||||
invName→inventoryName, doPot→applyPotionFuse; combat — rollEm→rollAttacks,
|
||||
attack/moveMonster/chaseStep return (removed bool) instead of C -1/0 int
|
||||
codes; UI — getDir→promptDirection. C breadcrumbs kept; suite green.
|
||||
|
||||
- 2026-07-07 Refactor step 4 (refactor/movement-renames): movement/world renames
|
||||
(doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
|
||||
doRooms/doPassages/doMaze→digRooms/digPassages/digMaze, chgStr→changeStrength,
|
||||
doRun→startRun, moveStuff→finishMove, turnref→turnRefresh,
|
||||
moveMonst→moveMonster, doChase→chaseStep, setOldch→setOldChar, cansee→canSee,
|
||||
roomin→roomIn, runto→runTo, conn→connectRooms, putpass→putPassage,
|
||||
passnum→numberPassages, numpass→numberPassage, rndPos→randomPos,
|
||||
rndRoom→randomRoom, treasRoom→treasureRoom, accntMaze→accountMaze); all
|
||||
goto/label flows replaced with loops (moveHero retry loop + extracted
|
||||
passageTurn, dispatch re-dispatch loop, chaseStep passage loop, saveGame
|
||||
labeled prompt loop); C breadcrumbs kept in doc comments.
|
||||
- 2026-07-07 Lint adoption finished (refactor/no-package-globals): all 37
|
||||
package-level vars moved into `gameData` (built by `newGameData`, hung on
|
||||
RogueGame as `g.data`, set in NewGame and Restore); ObjectKind
|
||||
Glyph()/objectKindForGlyph became switches; the table-reading subtype
|
||||
Stringers were removed; isMagic became a RogueGame method; goconst fixed with
|
||||
named word constants (potionName, goldName, staffName, ripWall, ...);
|
||||
testpackage and exhaustive disabled in .golangci.yml with sneak's approval
|
||||
(2026-07-07); misspell's corruption of the "ther" scroll syllable reverted.
|
||||
mnd disabled with sneak's approval (2026-07-07, follow-up commit). Remaining
|
||||
red: cyclop (36), nestif (30), gocognit (23) stay until step 7 fixes them per
|
||||
sneak's ruling.
|
||||
- 2026-07-06 Lint adoption bulk (refactor/lint-adoption, 5ba9fe8): .golangci.yml
|
||||
copied verbatim from the prompts repo (plus the sneak-approved paralleltest
|
||||
exception, 2026-07-06); ~1,500 findings fixed (autofix formatting sweep,
|
||||
errcheck/err113/noinlineerr error handling, forbidigo, funcorder, recvcheck
|
||||
pointer receivers, goprintffuncname renames msg helpers to *f, revive doc
|
||||
comments, gocritic switch rewrites, gosec real fixes plus justified nolints,
|
||||
unparam signature tightening, C-faithful "missle" spellings restored after
|
||||
misspell autofix changed game text).
|
||||
- 2026-07-06 Module base path updated to git.eeqj.de/sneak/rgoue (go.mod, term/
|
||||
and cmd/ imports, ARCHITECTURE.md, version string).
|
||||
- 2026-07-06 Refactor step 3 (refactor/object-fields): Object.Arm split into
|
||||
ArmorClass/Charges/GoldValue/Bonus (rings); Stats.Arm → ArmorClass; damage
|
||||
strings parsed once into DiceSpec at table definition (ParseDice keeps C
|
||||
roll_em parse semantics, incl. "%%%x0" and "000x0" edge cases,
|
||||
regression-tested); save format 5.4.4-go3.
|
||||
- 2026-07-06 Refactor step 2 (refactor/typed-kinds, b940cfc): ObjectKind
|
||||
separates item category from map glyph (Object.Type byte → Kind ObjectKind
|
||||
with Glyph()); PotionKind/ScrollKind/RingKind/
|
||||
WandKind/WeaponKind/ArmorKind/TrapKind typed iota enums with Stringer; typed
|
||||
accessors on Object; getItem/inventory/whatis filters take ObjectKind
|
||||
(KindCallable/KindRingOrStick replace CALLABLE/R_OR_S); save format bumped to
|
||||
5.4.4-go2. Suite green.
|
||||
- 2026-07-06 Refactor step 1 (refactor/descriptive-constants): renamed all flag
|
||||
bits, trap types, item subtype constants, and Max* counts to descriptive names
|
||||
(IsHuh→Confused, SeeMonst→SenseMonsters, WsHasteM→WandHasteMonster,
|
||||
MaxSticks→NumWandTypes, ...); Level.NTraps→TrapCount; C names kept as comment
|
||||
breadcrumbs. Pure rename, suite green.
|
||||
- 2026-07-06 Made the rgoue branch Go-only: removed C sources and the
|
||||
autoconf/VS build system (they remain on master and modern-rogue),
|
||||
ported the last wizard command (item-probability listing), rewrote
|
||||
README.md for the Go port (c0b533e)
|
||||
- 2026-07-06 Ported the command loop, save/restore, the tcell terminal
|
||||
layer, and the playable binary at cmd/rogue (41fc104)
|
||||
- 2026-07-06 Ported item effects: potions, scrolls, options, call_it
|
||||
(cdf9bf7)
|
||||
- 2026-07-06 Ported combat, the chase driver, traps, zapping, death and
|
||||
scores (3c5add8)
|
||||
- 2026-07-06 Ported dungeon generation, base items, the pack, and
|
||||
monster creation (a69ef7d)
|
||||
- 2026-07-06 Ported the foundation: types, seed-compatible RNG, item
|
||||
tables, daemon scheduler (7fa2048)
|
||||
- 2026-07-06 Wrote ARCHITECTURE.md Parts 1 and 2: complete map of the C
|
||||
program and the Go port design (91eeee0, 45dba95)
|
||||
- Fork base: Davidslv/rogue C 5.4.4 with modernization fixes (C23
|
||||
prototypes, ncurses compat), preserved on master/modern-rogue
|
||||
autoconf/VS build system (they remain on master and modern-rogue), ported the
|
||||
last wizard command (item-probability listing), rewrote README.md for the Go
|
||||
port (c0b533e)
|
||||
- 2026-07-06 Ported the command loop, save/restore, the tcell terminal layer,
|
||||
and the playable binary at cmd/rogue (41fc104)
|
||||
- 2026-07-06 Ported item effects: potions, scrolls, options, call_it (cdf9bf7)
|
||||
- 2026-07-06 Ported combat, the chase driver, traps, zapping, death and scores
|
||||
(3c5add8)
|
||||
- 2026-07-06 Ported dungeon generation, base items, the pack, and monster
|
||||
creation (a69ef7d)
|
||||
- 2026-07-06 Ported the foundation: types, seed-compatible RNG, item tables,
|
||||
daemon scheduler (7fa2048)
|
||||
- 2026-07-06 Wrote ARCHITECTURE.md Parts 1 and 2: complete map of the C program
|
||||
and the Go port design (91eeee0, 45dba95)
|
||||
- Fork base: Davidslv/rogue C 5.4.4 with modernization fixes (C23 prototypes,
|
||||
ncurses compat), preserved on master/modern-rogue
|
||||
|
||||
# Future Steps
|
||||
|
||||
1. Refactor step 4: method renames, movement/world subsystem
|
||||
(doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
|
||||
doRooms/doPassages/doMaze→digRooms/digPassages/digMaze,
|
||||
chgStr→changeStrength, ...); remove the goto/label flows in doMove,
|
||||
dispatch, and saveGame in favor of loops and helpers.
|
||||
2. Refactor step 5: method renames, items/combat/UI subsystems
|
||||
(invName→inventoryName, rollEm→rollAttacks, doPot→applyPotionFuse,
|
||||
getItem→promptPackItem returning (obj, ok), getDir→promptDirection);
|
||||
int status codes (attack returning -1) become named results. Two or
|
||||
three commits, one subsystem each.
|
||||
3. Refactor step 6: extract types from the god object — MessageLine
|
||||
owns the msg/addmsg/endmsg machinery; pack/inventory operations move
|
||||
onto *Player; monster/object list management and map queries
|
||||
consolidate onto *Level; RogueGame keeps turn orchestration and
|
||||
cross-system effects only.
|
||||
4. Refactor step 7: effects dispatch — the giant quaff/readScroll/doZap
|
||||
switches become per-kind handler tables of small named methods,
|
||||
keeping effect order and RNG call sequence identical.
|
||||
5. Refactor step 8: constructor and style pass per the house
|
||||
styleguide — game.New(game.Params{...}) replacing NewGame(Config);
|
||||
replace the gameEnd panic unwind with error-based turn results where
|
||||
feasible; 77-column wrap sweep.
|
||||
6. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the
|
||||
post-refactor names; add the C name → Go name rename table.
|
||||
7. Playtest hardening pass: play several full games with the tcell
|
||||
binary and extend run_test.go to script a deeper multi-level
|
||||
playthrough (descend past level 5, use potions, scrolls, zapping,
|
||||
save/restore). Fix any panics, message mismatches, or divergences
|
||||
from the C behavior that this uncovers, with regression tests.
|
||||
8. Verify the seed-compatibility claim against the C reference on
|
||||
c-master: same seed, same dungeon, same item tables, for several
|
||||
seeds.
|
||||
9. Broaden unit test coverage where playtesting finds thin spots
|
||||
(rings, sticks, wizard commands).
|
||||
10. Tag a release once a full game (Amulet retrieval and score entry)
|
||||
completes without defects.
|
||||
11. Full-terminal-size support (deferred by explicit decision
|
||||
2026-07-06): per-game dungeon dimensions instead of the 80x24
|
||||
constants; open design questions are resize policy, gameplay
|
||||
tuning at larger sizes, and a --classic 80x24 mode.
|
||||
12. Note: this repo is exempt from the standard policy scaffold. Do not
|
||||
add Makefile, Dockerfile, or REPO_POLICIES.md.
|
||||
1. Tag a release once a full game (Amulet retrieval and score entry) completes
|
||||
without defects.
|
||||
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
||||
per-game dungeon dimensions instead of the 80x24 constants; open design
|
||||
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
||||
80x24 mode.
|
||||
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
||||
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
||||
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
||||
REPO_POLICIES.md.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -18,84 +19,318 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run())
|
||||
}
|
||||
|
||||
// run does the real work and returns an exit code. It only returns on a
|
||||
// startup error; once the game starts, it ends by exiting the process
|
||||
// from within (game.myExit restores the terminal first). The deferred
|
||||
// Fini covers the early-return paths.
|
||||
func run() int {
|
||||
scores := flag.Bool("s", false, "print the scoreboard and exit")
|
||||
deathDemo := flag.Bool("d", false, "die a random death (demo)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
// get options from environment (main.c)
|
||||
rogueOpts := os.Getenv("ROGUEOPTS")
|
||||
name := ""
|
||||
if u, err := user.Current(); err == nil {
|
||||
name = u.Username
|
||||
}
|
||||
|
||||
wizard := os.Getenv("ROGUE_WIZARD") != ""
|
||||
// dungeon number: SEED for reproducible dungeons (wizard mode in C),
|
||||
// else time+pid
|
||||
var seed int32
|
||||
if env := os.Getenv("SEED"); env != "" && wizard {
|
||||
n, _ := strconv.Atoi(env)
|
||||
seed = int32(n)
|
||||
} else {
|
||||
seed = int32(time.Now().Unix()) + int32(os.Getpid())
|
||||
}
|
||||
|
||||
cfg := game.Config{
|
||||
Seed: seed,
|
||||
Name: name,
|
||||
RogueOpts: rogueOpts,
|
||||
Home: home,
|
||||
ScorePath: home + "/.rogue.scores",
|
||||
Wizard: wizard,
|
||||
}
|
||||
params := loadParams()
|
||||
|
||||
if *scores {
|
||||
g := game.NewGame(cfg)
|
||||
g.ShowScores()
|
||||
return
|
||||
game.New(params).ShowScores()
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// C printed its greeting just before initscr(); here that means just
|
||||
// before the tcell screen takes the terminal, and on stdout, exactly
|
||||
// as C did (main.c main). C followed the printf with fflush because
|
||||
// its stdout was buffered; os.Stdout is not, so the write is the
|
||||
// flush.
|
||||
if digsNewDungeon(*deathDemo, flag.Args()) {
|
||||
_, _ = fmt.Fprint(os.Stdout, game.Greeting(params)) // CLI output
|
||||
}
|
||||
|
||||
t, err := term.New()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
return 1
|
||||
}
|
||||
defer t.Fini()
|
||||
cfg.Term = t
|
||||
|
||||
// Armed here, the instant the tty goes raw, not after the game is
|
||||
// built: everything below this line — the restore, the death demo
|
||||
// (which never returns), the game itself — would otherwise run raw
|
||||
// with no handler installed. There is no game to save yet, so the
|
||||
// saver is filled in below once there is one.
|
||||
pending := installSignalHandlers(t)
|
||||
|
||||
params.Term = t
|
||||
|
||||
var g *game.RogueGame
|
||||
|
||||
if args := flag.Args(); len(args) == 1 && !*deathDemo {
|
||||
// restore a saved game
|
||||
g, err = game.Restore(args[0], cfg)
|
||||
g, err = game.Restore(args[0], params)
|
||||
if err != nil {
|
||||
t.Fini()
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
|
||||
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
g = game.NewGame(cfg)
|
||||
g = game.New(params)
|
||||
}
|
||||
|
||||
if *deathDemo {
|
||||
g.DeathDemo()
|
||||
return
|
||||
// The demo is left without a saver on purpose: a signal still
|
||||
// restores the terminal, but a throwaway demo game is not worth
|
||||
// writing over the player's save file.
|
||||
g.DeathDemo() // does not return: death exits the process
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// SIGHUP/SIGTERM autosave (save.c auto_save)
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sig
|
||||
g.AutoSave()
|
||||
t.Fini()
|
||||
os.Exit(0)
|
||||
}()
|
||||
pending.set(g)
|
||||
|
||||
if err := g.Run(); err != nil {
|
||||
t.Fini()
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
g.Run() // does not return: the game ends by exiting the process
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// digsNewDungeon reports whether this invocation is the one that digs a
|
||||
// fresh dungeon, and so the only one that greets.
|
||||
//
|
||||
// C's printf is the last statement before initscr(), and everything that
|
||||
// does something else has already left by then: -s scores and exits, -d
|
||||
// runs the death demo and exits, and restore() — the argc == 2 case that
|
||||
// is neither — never returns. So a saved game resumes without a greeting,
|
||||
// which is right: nothing is being dug.
|
||||
//
|
||||
// The restore test is duplicated from run's own, deliberately. Keeping
|
||||
// them as one predicate would mean deciding the startup path before the
|
||||
// terminal exists and carrying it past the error returns, which is more
|
||||
// rearrangement of run than a greeting is worth.
|
||||
func digsNewDungeon(deathDemo bool, args []string) bool {
|
||||
return !deathDemo && len(args) != 1
|
||||
}
|
||||
|
||||
// loadParams gathers the game parameters from the environment: home
|
||||
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
|
||||
// (main.c's startup).
|
||||
func loadParams() game.Params {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
name := ""
|
||||
|
||||
u, userErr := user.Current()
|
||||
if userErr == nil {
|
||||
name = u.Username
|
||||
}
|
||||
|
||||
wizard := os.Getenv("ROGUE_WIZARD") != ""
|
||||
|
||||
return game.Params{
|
||||
Seed: chooseSeed(wizard),
|
||||
Name: name,
|
||||
RogueOpts: os.Getenv("ROGUEOPTS"),
|
||||
Home: home,
|
||||
ScorePath: home + "/.rogue.scores",
|
||||
Wizard: wizard,
|
||||
}
|
||||
}
|
||||
|
||||
// saver is the autosave half of *game.RogueGame that the signal handler
|
||||
// needs; an interface so the handler is testable headlessly.
|
||||
type saver interface {
|
||||
// AutoSaveOnSignal asks the game goroutine to write the save file and
|
||||
// waits up to timeout for it, reporting whether the save ran (save.c
|
||||
// auto_save). The handler never encodes anything itself; see
|
||||
// signalSaveTimeout.
|
||||
AutoSaveOnSignal(timeout time.Duration) bool
|
||||
}
|
||||
|
||||
// signalSaveTimeout bounds how long the signal handler waits for the game
|
||||
// goroutine to take its autosave.
|
||||
//
|
||||
// The handler cannot encode the game itself — that was issue #24's data
|
||||
// race — so it has to hand the work to the goroutine that owns the state
|
||||
// and wait. The game answers between turns, while parked waiting for a
|
||||
// key, and while parked in the shell escape, which covers everywhere it
|
||||
// can sit for any length of time; the deadline is the backstop for a game
|
||||
// goroutine wedged somewhere with no service point, so that a signal can
|
||||
// never fail to get the process out. It is generous next to the
|
||||
// milliseconds a gob encode of one game takes, and invisible to a player
|
||||
// whose connection has already dropped.
|
||||
//
|
||||
// Giving up costs nothing now that saveFile renames over the target
|
||||
// (game/save.go): a save that does not happen leaves the previous save
|
||||
// whole, where the old remove-then-encode could leave the player with
|
||||
// neither.
|
||||
const signalSaveTimeout = 3 * time.Second
|
||||
|
||||
// finisher is the terminal-restoring half of game.Terminal that the
|
||||
// signal handler needs (curses endwin).
|
||||
type finisher interface {
|
||||
// Fini restores the terminal to its pre-game state.
|
||||
Fini()
|
||||
}
|
||||
|
||||
// pendingSaver is the saver the signal handler holds from the moment the
|
||||
// terminal goes raw. The handler has to be armed before there is a game
|
||||
// to save — restoring a save file and the death demo both run with the
|
||||
// tty already raw — so AutoSaveOnSignal does nothing until set hands over
|
||||
// the real game. The mutex is not decoration: set runs on the main
|
||||
// goroutine and AutoSaveOnSignal on the signal goroutine.
|
||||
type pendingSaver struct {
|
||||
mu sync.Mutex
|
||||
game saver
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal saves the game if there is one yet, and otherwise does
|
||||
// nothing: a signal arriving before the game is built still restores the
|
||||
// terminal, which is the part that matters.
|
||||
//
|
||||
// The lock is held only long enough to read the game, not across the
|
||||
// delegated save. That changed with issue #24: the real
|
||||
// AutoSaveOnSignal now blocks until the game goroutine takes the save or
|
||||
// the deadline expires, and holding the mutex across a wait that long
|
||||
// would stall a concurrent set — the case the PR #23 review flagged as
|
||||
// safe only for as long as set is called exactly once. This shape does
|
||||
// not depend on that.
|
||||
func (p *pendingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
|
||||
p.mu.Lock()
|
||||
g := p.game
|
||||
p.mu.Unlock()
|
||||
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return g.AutoSaveOnSignal(timeout)
|
||||
}
|
||||
|
||||
// set hands the signal handler the game to autosave, once one exists.
|
||||
func (p *pendingSaver) set(g saver) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.game = g
|
||||
}
|
||||
|
||||
// handledSignals returns the signals the game leaves on. They split into
|
||||
// two groups with deliberately different save behavior; see
|
||||
// savesOnSignal.
|
||||
func handledSignals() []os.Signal {
|
||||
return []os.Signal{
|
||||
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
|
||||
}
|
||||
}
|
||||
|
||||
// savesOnSignal reports whether the game should autosave on its way out
|
||||
// for this signal.
|
||||
//
|
||||
// THE DECISION (issue #12): SIGHUP and SIGTERM save; SIGINT and SIGQUIT
|
||||
// restore the terminal and exit WITHOUT saving. This is deliberate, not
|
||||
// an oversight, on three grounds.
|
||||
//
|
||||
// C: no path in the C game saves on INT or QUIT. The shipped build
|
||||
// installs no handler at all during play (mach_dep.c setup calls
|
||||
// md_onsignal_default), and the only INT handler it ever installs is
|
||||
// rip.c/main.c's leave() in the endgame — endwin and exit, explicitly
|
||||
// discarding pending output. The build that does wire INT during play
|
||||
// (md_onsignal_autosave, mdport.c — defined unconditionally, but with
|
||||
// its only call site, mach_dep.c setup, inside #ifdef DUMP) sends it to
|
||||
// quit(), which confirms, scores, and exits, again without saving, and
|
||||
// sends QUIT to endit() -> fatal() -> endwin + exit. save.c auto_save is
|
||||
// reserved for HUP/TERM. Saving on HUP/TERM but not on INT/QUIT is
|
||||
// therefore exactly C's split.
|
||||
//
|
||||
// Semantics: HUP and TERM mean involuntary teardown — the line dropped
|
||||
// or the machine is going down — so rescuing the game is right. INT and
|
||||
// QUIT are the player deliberately saying "stop now". Rogue scores a
|
||||
// deliberate quit, and its save discipline is anti-save-scum by design
|
||||
// (restoring consumes the file), so making Ctrl-C a free checkpoint
|
||||
// would turn it into a one-keystroke undo for a bad turn: a gameplay
|
||||
// change, not a robustness fix.
|
||||
//
|
||||
// Safety: this used to be the third ground, back when the handler
|
||||
// gob-encoded live game state from its own goroutine after removing the
|
||||
// save file — a data race with a window in which the player had no save
|
||||
// at all, accepted on HUP/TERM because the process was dying anyway and
|
||||
// avoided entirely on INT/QUIT. Issue #24 removed the window instead of
|
||||
// living with it: the handler now hands the save to the game goroutine
|
||||
// and waits (AutoSaveOnSignal), and the write goes to a temporary file
|
||||
// renamed over the target. The split above stands on C and on semantics,
|
||||
// which is where it always belonged; INT and QUIT do not save because
|
||||
// the player asked to stop, not because saving is dangerous.
|
||||
func savesOnSignal(sig os.Signal) bool {
|
||||
return sig == syscall.SIGHUP || sig == syscall.SIGTERM
|
||||
}
|
||||
|
||||
// installSignalHandlers arranges for the game to leave the terminal
|
||||
// usable when it is signalled: C's leave(), "leave quickly but
|
||||
// curteously" (main.c), extended with save.c auto_save on the two
|
||||
// signals that warrant it.
|
||||
//
|
||||
// Call it the instant the terminal goes raw, which is earlier than the
|
||||
// game exists; the returned pendingSaver takes the game once it does.
|
||||
// Restoring the terminal is what has to be armed the moment the tty
|
||||
// stops being usable, and it does not need a game.
|
||||
func installSignalHandlers(t finisher) *pendingSaver {
|
||||
pending := &pendingSaver{}
|
||||
|
||||
go leaveOnSignal(notifySignals(), pending, t, os.Exit)
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
// notifySignals subscribes to the handled signals and returns the
|
||||
// channel they arrive on. Split out from installSignalHandlers so tests
|
||||
// can drive leaveOnSignal with real signal delivery.
|
||||
func notifySignals() chan os.Signal {
|
||||
// Buffered so signal delivery never blocks, and deliberately never
|
||||
// drained past the first signal: see leaveOnSignal.
|
||||
sig := make(chan os.Signal, 1)
|
||||
|
||||
signal.Notify(sig, handledSignals()...)
|
||||
|
||||
return sig
|
||||
}
|
||||
|
||||
// leaveOnSignal waits for one signal and takes the game out.
|
||||
//
|
||||
// Exactly one goroutine reads exactly one signal, which is what makes
|
||||
// the exit safe: a second signal (a SIGINT landing while a SIGHUP's save
|
||||
// is still being written, say) stays in the buffer unread and can never
|
||||
// call exit out from under an in-flight save. The order within is the
|
||||
// same one myExit uses (game/rip.go): save if this signal saves, then
|
||||
// restore the terminal, then exit.
|
||||
//
|
||||
// AutoSaveOnSignal returns once the game goroutine has finished writing,
|
||||
// or once signalSaveTimeout has run out, so the save is complete before
|
||||
// the terminal is torn down and the process leaves — and the process
|
||||
// leaves either way.
|
||||
func leaveOnSignal(sig <-chan os.Signal, g saver, t finisher, exit func(int)) {
|
||||
if savesOnSignal(<-sig) {
|
||||
g.AutoSaveOnSignal(signalSaveTimeout)
|
||||
}
|
||||
|
||||
t.Fini()
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// chooseSeed picks the dungeon number: SEED for reproducible dungeons
|
||||
// (wizard mode, as in the C game), else time+pid (main.c).
|
||||
func chooseSeed(wizard bool) int32 {
|
||||
if env := os.Getenv("SEED"); env != "" && wizard {
|
||||
n, err := strconv.ParseInt(env, 10, 32)
|
||||
if err == nil {
|
||||
return int32(n)
|
||||
}
|
||||
}
|
||||
|
||||
// The C game computed `lowtime + getpid()` in int; the truncation to
|
||||
// 32 bits is the same wraparound the C int arithmetic performed.
|
||||
//nolint:mnd // C-faithful: the C int wraparound mask
|
||||
return int32(time.Now().Unix()&0x7fffffff) +
|
||||
int32(os.Getpid()&0x7fffffff)
|
||||
}
|
||||
|
||||
419
cmd/rogue/main_test.go
Normal file
419
cmd/rogue/main_test.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package main
|
||||
|
||||
// White-box tests for the signal plumbing. Unlike the game package's test
|
||||
// files this one carries no //nolint:testpackage directive: testpackage
|
||||
// exempts package main, so nolintlint rejects the directive as unused.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The steps the signal handler can take, in the order signalRecorder
|
||||
// records them.
|
||||
const (
|
||||
stepSave = "save"
|
||||
stepFini = "fini"
|
||||
stepExit = "exit"
|
||||
)
|
||||
|
||||
// wantHandledSignals is the exact set of signals the game must leave on.
|
||||
// This is the subject of issue #12: SIGHUP and SIGTERM were handled and
|
||||
// SIGINT and SIGQUIT were not, so the latter two killed the process with
|
||||
// the tty still raw. Every other test here iterates handledSignals(), so
|
||||
// without this one the whole file would pass against a set that had
|
||||
// silently lost SIGINT and SIGQUIT again.
|
||||
func wantHandledSignals() []os.Signal {
|
||||
return []os.Signal{
|
||||
syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT,
|
||||
}
|
||||
}
|
||||
|
||||
// wantSteps is the expected handler step sequence for each handled
|
||||
// signal, and the single source of truth for the tests that check the
|
||||
// save/no-save split.
|
||||
func wantSteps() map[os.Signal][]string {
|
||||
return map[os.Signal][]string{
|
||||
syscall.SIGHUP: {stepSave, stepFini, stepExit},
|
||||
syscall.SIGTERM: {stepSave, stepFini, stepExit},
|
||||
syscall.SIGINT: {stepFini, stepExit},
|
||||
syscall.SIGQUIT: {stepFini, stepExit},
|
||||
}
|
||||
}
|
||||
|
||||
// signalRecorder stands in for the game and the terminal in the signal
|
||||
// handler, recording the order of the steps the handler takes. The mutex
|
||||
// matters: the handler runs on its own goroutine, so an unguarded slice
|
||||
// would be a data race under -race, which is exactly what these tests
|
||||
// are meant to rule out.
|
||||
type signalRecorder struct {
|
||||
mu sync.Mutex
|
||||
steps []string
|
||||
code int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSignalRecorder() *signalRecorder {
|
||||
return &signalRecorder{done: make(chan struct{})}
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal records a save attempt (the saver half). The real one
|
||||
// hands the work to the game goroutine and waits; the recorder stands in
|
||||
// for a game that takes it immediately.
|
||||
func (r *signalRecorder) AutoSaveOnSignal(time.Duration) bool {
|
||||
r.record(stepSave)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Fini records a terminal restore (the finisher half).
|
||||
func (r *signalRecorder) Fini() {
|
||||
r.record(stepFini)
|
||||
}
|
||||
|
||||
// exit records the process exit that ends the handler and releases any
|
||||
// waiter. It stands in for os.Exit, which cannot be called in a test.
|
||||
func (r *signalRecorder) exit(code int) {
|
||||
r.mu.Lock()
|
||||
r.code = code
|
||||
r.steps = append(r.steps, stepExit)
|
||||
r.mu.Unlock()
|
||||
|
||||
close(r.done)
|
||||
}
|
||||
|
||||
// record appends one step.
|
||||
func (r *signalRecorder) record(step string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.steps = append(r.steps, step)
|
||||
}
|
||||
|
||||
// taken returns the recorded steps and the exit code.
|
||||
func (r *signalRecorder) taken() ([]string, int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
return slices.Clone(r.steps), r.code
|
||||
}
|
||||
|
||||
// TestHandledSignalsSet pins the membership of handledSignals() itself.
|
||||
// The regression issue #12 exists to prevent is a signal dropping out of
|
||||
// that set — SIGINT and SIGQUIT reaching the process at SIG_DFL and
|
||||
// killing it with the tty raw — and every other test in this file is
|
||||
// driven by the set, so only this test can fail on it.
|
||||
func TestHandledSignalsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := handledSignals()
|
||||
want := wantHandledSignals()
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("handledSignals() = %v, want exactly %v", got, want)
|
||||
}
|
||||
|
||||
for _, sig := range want {
|
||||
if !slices.Contains(got, sig) {
|
||||
t.Errorf("handledSignals() = %v, missing %v", got, sig)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sig := range got {
|
||||
if !slices.Contains(want, sig) {
|
||||
t.Errorf("handledSignals() = %v, unexpected %v", got, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveOnSignalRestoresTerminalBeforeExit is the core of issue #12:
|
||||
// whatever the signal, the terminal is restored before the process ends,
|
||||
// so the player is never dropped into a shell with the tty still in raw
|
||||
// mode.
|
||||
func TestLeaveOnSignalRestoresTerminalBeforeExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, sig := range handledSignals() {
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
ch <- sig
|
||||
|
||||
leaveOnSignal(ch, rec, rec, rec.exit)
|
||||
|
||||
steps, code := rec.taken()
|
||||
|
||||
fini := slices.Index(steps, stepFini)
|
||||
exit := slices.Index(steps, stepExit)
|
||||
|
||||
if fini < 0 || exit < 0 || fini > exit {
|
||||
t.Errorf("%v: want the terminal restored before exit, got %v",
|
||||
sig, steps)
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
t.Errorf("%v: exit code = %d, want 0", sig, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveOnSignalSaveSplit pins the decision recorded on savesOnSignal:
|
||||
// SIGHUP/SIGTERM (involuntary teardown) save on the way out, SIGINT and
|
||||
// SIGQUIT (a deliberate "stop now" from the player) do not, matching C,
|
||||
// where auto_save is reserved for HUP/TERM and neither leave() nor quit()
|
||||
// nor endit() writes a save file.
|
||||
//
|
||||
// It is driven by the expectation table rather than by
|
||||
// handledSignals(), so that every entry — including the SIGINT and
|
||||
// SIGQUIT ones — is actually read, and a signal dropped from the handled
|
||||
// set fails here as well as in TestHandledSignalsSet.
|
||||
func TestLeaveOnSignalSaveSplit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for sig, want := range wantSteps() {
|
||||
if !slices.Contains(handledSignals(), sig) {
|
||||
t.Errorf("%v is not handled at all, so it cannot exit cleanly", sig)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
ch <- sig
|
||||
|
||||
leaveOnSignal(ch, rec, rec, rec.exit)
|
||||
|
||||
steps, _ := rec.taken()
|
||||
if !slices.Equal(steps, want) {
|
||||
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
||||
}
|
||||
|
||||
if saved := slices.Contains(steps, stepSave); saved != savesOnSignal(sig) {
|
||||
t.Errorf("%v: saved = %v, savesOnSignal = %v",
|
||||
sig, saved, savesOnSignal(sig))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveOnSignalIgnoresLaterSignals covers the ordering guarantee in
|
||||
// leaveOnSignal's comment: only the first signal is read, so a second one
|
||||
// arriving mid-save cannot exit out from under the save and truncate the
|
||||
// player's file. The saver here blocks until a second signal has been
|
||||
// queued, reproducing that window.
|
||||
func TestLeaveOnSignalIgnoresLaterSignals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 2)
|
||||
ch <- syscall.SIGHUP
|
||||
|
||||
blocker := &blockingSaver{rec: rec, queue: ch, extra: syscall.SIGINT}
|
||||
|
||||
leaveOnSignal(ch, blocker, rec, rec.exit)
|
||||
|
||||
steps, _ := rec.taken()
|
||||
if !slices.Equal(steps, []string{stepSave, stepFini, stepExit}) {
|
||||
t.Errorf("steps = %v, want one save, one fini, one exit", steps)
|
||||
}
|
||||
|
||||
if len(ch) != 1 {
|
||||
t.Errorf("queued signals left unread = %d, want 1", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
// blockingSaver queues another signal while the save is in flight, the
|
||||
// race window leaveOnSignal is built to close.
|
||||
type blockingSaver struct {
|
||||
rec *signalRecorder
|
||||
queue chan os.Signal
|
||||
extra os.Signal
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal delivers the extra signal mid-save, then records the
|
||||
// save.
|
||||
func (b *blockingSaver) AutoSaveOnSignal(timeout time.Duration) bool {
|
||||
b.queue <- b.extra
|
||||
|
||||
return b.rec.AutoSaveOnSignal(timeout)
|
||||
}
|
||||
|
||||
// TestLeaveOnRealSignal is the deepest headless check available: it
|
||||
// delivers real SIGINT/SIGQUIT/SIGHUP/SIGTERM to this process through
|
||||
// os/signal, exactly as notifySignals wires them in the game, and
|
||||
// verifies each one reaches the handler and produces the full expected
|
||||
// step sequence — including the save/no-save split, which this test is
|
||||
// the best placed to check end to end.
|
||||
//
|
||||
// What cannot be checked here is the tty itself coming back out of raw
|
||||
// mode: that needs a controlling terminal and a live tcell screen, which
|
||||
// a headless test run does not have. This test covers everything up to
|
||||
// the Terminal.Fini call; term.Tcell.Fini is a direct pass-through to
|
||||
// tcell's Screen.Fini, which is the same call myExit already relies on.
|
||||
func TestLeaveOnRealSignal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ch := notifySignals()
|
||||
defer signal.Stop(ch)
|
||||
|
||||
for _, sig := range handledSignals() {
|
||||
rec := newSignalRecorder()
|
||||
|
||||
go leaveOnSignal(ch, rec, rec, rec.exit)
|
||||
|
||||
unix, ok := sig.(syscall.Signal)
|
||||
if !ok {
|
||||
t.Fatalf("%v is not a unix signal", sig)
|
||||
}
|
||||
|
||||
err := syscall.Kill(os.Getpid(), unix)
|
||||
if err != nil {
|
||||
t.Fatalf("kill(%v): %v", sig, err)
|
||||
}
|
||||
|
||||
<-rec.done
|
||||
|
||||
steps, code := rec.taken()
|
||||
if want := wantSteps()[sig]; !slices.Equal(steps, want) {
|
||||
t.Errorf("%v: steps = %v, want %v", sig, steps, want)
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
t.Errorf("%v: exit code = %d, want 0", sig, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingSaverArmsBeforeTheGameExists covers what lets the handlers
|
||||
// be installed the instant the terminal goes raw rather than after the
|
||||
// game is built: a signal arriving before there is a game must still
|
||||
// reach Fini, and must not save anything, while one arriving after the
|
||||
// game is handed over saves it.
|
||||
func TestPendingSaverArmsBeforeTheGameExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pending := &pendingSaver{}
|
||||
rec := newSignalRecorder()
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
ch <- syscall.SIGHUP
|
||||
|
||||
// No game yet: the SIGHUP still restores the terminal and exits, it
|
||||
// just has nothing to write.
|
||||
leaveOnSignal(ch, pending, rec, rec.exit)
|
||||
|
||||
steps, code := rec.taken()
|
||||
if want := []string{stepFini, stepExit}; !slices.Equal(steps, want) {
|
||||
t.Errorf("before the game exists: steps = %v, want %v", steps, want)
|
||||
}
|
||||
|
||||
if code != 0 {
|
||||
t.Errorf("before the game exists: exit code = %d, want 0", code)
|
||||
}
|
||||
|
||||
// Once the game is handed over, the same saver writes it.
|
||||
started := newSignalRecorder()
|
||||
pending.set(started)
|
||||
|
||||
if !pending.AutoSaveOnSignal(signalSaveTimeout) {
|
||||
t.Error("after set: the save was not reported as taken")
|
||||
}
|
||||
|
||||
saved, _ := started.taken()
|
||||
if want := []string{stepSave}; !slices.Equal(saved, want) {
|
||||
t.Errorf("after set: steps = %v, want %v", saved, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingSaverDoesNotHoldItsLockAcrossTheSave pins the reason
|
||||
// pendingSaver reads the game out from under the mutex instead of
|
||||
// delegating with it held: since issue #24 the delegated save blocks
|
||||
// until the game goroutine takes it or the deadline expires, so a mutex
|
||||
// held across it would stall whoever calls set. Nothing calls set twice
|
||||
// today, which is why the PR #23 review recorded this as a future-proof
|
||||
// note rather than a bug — this test is what stops it becoming one.
|
||||
func TestPendingSaverDoesNotHoldItsLockAcrossTheSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pending := &pendingSaver{}
|
||||
stuck := &stuckSaver{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
pending.set(stuck)
|
||||
|
||||
go pending.AutoSaveOnSignal(signalSaveTimeout)
|
||||
|
||||
<-stuck.entered // the delegated save is in flight
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
pending.set(newSignalRecorder()) // must not block on the save
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("set blocked while a save was in flight: the lock is held across it")
|
||||
}
|
||||
|
||||
close(stuck.release)
|
||||
}
|
||||
|
||||
// stuckSaver blocks inside the delegated save until it is released,
|
||||
// standing in for a game goroutine that is slow to answer.
|
||||
type stuckSaver struct {
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal blocks until the test releases it.
|
||||
func (s *stuckSaver) AutoSaveOnSignal(time.Duration) bool {
|
||||
close(s.entered)
|
||||
<-s.release
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TestDigsNewDungeon pins which invocations reach C's greeting. In main.c
|
||||
// the printf is the last statement before initscr(), so -s and -d, which
|
||||
// exit earlier, never see it, and neither does a restored game, because
|
||||
// restore() does not return. The saved-game case is the one worth having
|
||||
// a test for: resuming a dungeon must not announce that one is being dug.
|
||||
func TestDigsNewDungeon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deathDemo bool
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "new game", args: nil, want: true},
|
||||
{name: "restore a save", args: []string{"rogue.save"}, want: false},
|
||||
{name: "death demo", deathDemo: true, want: false},
|
||||
{
|
||||
name: "death demo wins over a save argument",
|
||||
deathDemo: true,
|
||||
args: []string{"rogue.save"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := digsNewDungeon(tc.deathDemo, tc.args); got != tc.want {
|
||||
t.Errorf("digsNewDungeon(%v, %v) = %v, want %v",
|
||||
tc.deathDemo, tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,36 +5,47 @@ package game
|
||||
// wear lets the player put armor on (armor.c wear).
|
||||
func (g *RogueGame) wear() {
|
||||
p := &g.Player
|
||||
obj := g.getItem("wear", KindArmor)
|
||||
if obj == nil {
|
||||
|
||||
obj, ok := g.promptPackItem("wear", KindArmor)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if p.CurArmor != nil {
|
||||
g.addmsg("you are already wearing some")
|
||||
g.addmsgf("you are already wearing some")
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(". You'll have to take it off first")
|
||||
g.addmsgf(". You'll have to take it off first")
|
||||
}
|
||||
|
||||
g.endmsg()
|
||||
g.After = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Kind != KindArmor {
|
||||
g.msg("you can't wear that")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
g.wasteTime()
|
||||
obj.Flags.Set(Known)
|
||||
sp := g.invName(obj, true)
|
||||
sp := g.inventoryName(obj, true)
|
||||
p.CurArmor = obj
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("you are now ")
|
||||
g.addmsgf("you are now ")
|
||||
}
|
||||
|
||||
g.msg("wearing %s", sp)
|
||||
}
|
||||
|
||||
// takeOff gets the armor off of the player's back (armor.c take_off).
|
||||
func (g *RogueGame) takeOff() {
|
||||
p := &g.Player
|
||||
|
||||
obj := p.CurArmor
|
||||
if obj == nil {
|
||||
g.After = false
|
||||
@@ -43,18 +54,23 @@ func (g *RogueGame) takeOff() {
|
||||
} else {
|
||||
g.msg("you aren't wearing any armor")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !g.dropCheck(p.CurArmor) {
|
||||
return
|
||||
}
|
||||
|
||||
p.CurArmor = nil
|
||||
|
||||
if g.Options.Terse {
|
||||
g.addmsg("was")
|
||||
g.addmsgf("was")
|
||||
} else {
|
||||
g.addmsg("you used to be")
|
||||
g.addmsgf("you used to be")
|
||||
}
|
||||
g.msg(" wearing %c) %s", obj.PackCh, g.invName(obj, true))
|
||||
|
||||
g.msg(" wearing %c) %s", obj.PackCh, g.inventoryName(obj, true))
|
||||
}
|
||||
|
||||
// wasteTime does nothing but let other things happen (armor.c waste_time).
|
||||
|
||||
515
game/autosave_test.go
Normal file
515
game/autosave_test.go
Normal file
@@ -0,0 +1,515 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// Tests for the signal-triggered autosave handoff (issue #24): the signal
|
||||
// goroutine must never encode game state itself, and the game goroutine
|
||||
// must answer wherever it is parked.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// autoSaveWait is the deadline the tests hand AutoSaveOnSignal when they
|
||||
// expect the save to be taken. It is long enough that a loaded machine
|
||||
// cannot turn a working handoff into a spurious failure, and it is never
|
||||
// actually waited out on a passing run.
|
||||
const autoSaveWait = 10 * time.Second
|
||||
|
||||
// TestAutoSaveOnSignalRacesTurnLoop is the test issue #24 exists for: it
|
||||
// drives the real turn loop on one goroutine while another asks for a
|
||||
// signal-triggered autosave over and over, which is the interleaving no
|
||||
// test in the suite used to produce. `make test` runs with -race, so a
|
||||
// save that encodes the live game tree from the asking goroutine — what
|
||||
// the old AutoSave did straight from the signal handler — is reported as
|
||||
// a data race and fails this test.
|
||||
//
|
||||
// Non-vacuity: with AutoSaveOnSignal's body replaced by a direct
|
||||
// g.autoSave() call, i.e. exactly the pre-#24 behavior, this test fails
|
||||
// under -race with the encoder reading state that command() is writing.
|
||||
func TestAutoSaveOnSignalRacesTurnLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Same mix as TestTurnLoopCrashSweep: the spaces answer any --More--
|
||||
// prompt, and the script is long enough that the drive never runs it
|
||||
// out.
|
||||
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
|
||||
|
||||
g := New(Params{Seed: 20260809, Term: &testTerm{input: script}})
|
||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
const wantSaves = 25
|
||||
|
||||
var taken int
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
for range wantSaves {
|
||||
if g.AutoSaveOnSignal(autoSaveWait) {
|
||||
taken++
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
driveUntilDone(t, g, done)
|
||||
|
||||
// The close of done orders that goroutine's writes before this read.
|
||||
if taken != wantSaves {
|
||||
t.Errorf("saves taken = %d, want %d", taken, wantSaves)
|
||||
}
|
||||
|
||||
// Every request was answered by the turn loop, so the file is the
|
||||
// work of the game goroutine and must be a whole save.
|
||||
assertRestorable(t, g.FileName)
|
||||
}
|
||||
|
||||
// driveUntilDone runs turns until the saving goroutine is finished,
|
||||
// fortifying the hero each turn so no death exits the test binary. The
|
||||
// turn cap keeps a broken handoff from hanging the suite instead of
|
||||
// failing it.
|
||||
func driveUntilDone(t *testing.T, g *RogueGame, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
const maxTurns = 1000
|
||||
|
||||
for range maxTurns {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
fortify(g)
|
||||
g.command()
|
||||
}
|
||||
|
||||
t.Fatal("the turn loop ran out of turns before the saves were taken")
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalWhileBlockedOnInput is the case the fix is really
|
||||
// for: the connection drops while the player is staring at the screen,
|
||||
// so the game goroutine is parked in ReadChar and will not reach the
|
||||
// between-turns check on its own. A flag checked only between turns would
|
||||
// never be looked at here.
|
||||
func TestAutoSaveOnSignalWhileBlockedOnInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bt := newBlockingTerm()
|
||||
g := mkBlockedGame(t, bt)
|
||||
|
||||
read := make(chan byte)
|
||||
|
||||
go func() { read <- g.readchar() }()
|
||||
|
||||
// The wake is buffered, so this is correct whether or not the reader
|
||||
// has reached ReadChar yet.
|
||||
if !g.AutoSaveOnSignal(autoSaveWait) {
|
||||
t.Fatal("the save was not taken while the game was blocked on input")
|
||||
}
|
||||
|
||||
assertRestorable(t, g.FileName)
|
||||
|
||||
// The interrupt must not have been mistaken for a keystroke: the
|
||||
// reader is still waiting, and still returns the real key.
|
||||
bt.keys <- 'x'
|
||||
|
||||
if ch := <-read; ch != 'x' {
|
||||
t.Errorf("readchar() = %q, want 'x'", ch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalWhileInShellEscape covers the other place the game
|
||||
// goroutine parks for an unbounded time: the `!` shell escape, where it
|
||||
// used to sit inside the shell call with no way to answer. A dropped line
|
||||
// while the player is off in a shell is as much a hangup as any other.
|
||||
func TestAutoSaveOnSignalWhileInShellEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := &shellTerm{
|
||||
blockingTerm: newBlockingTerm(),
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
g := mkBlockedGame(t, st)
|
||||
|
||||
left := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(left)
|
||||
|
||||
g.shell()
|
||||
}()
|
||||
|
||||
<-st.entered
|
||||
|
||||
if !g.AutoSaveOnSignal(autoSaveWait) {
|
||||
t.Fatal("the save was not taken while the game was in the shell escape")
|
||||
}
|
||||
|
||||
assertRestorable(t, g.FileName)
|
||||
|
||||
close(st.release)
|
||||
<-left
|
||||
}
|
||||
|
||||
// TestShellEscapePanicUnwindsTheGameGoroutine pins the reason
|
||||
// runShellEscape recovers its helper's panic.
|
||||
//
|
||||
// term.Tcell.ShellEscape panics when Screen.Resume fails, and the shell
|
||||
// now runs on a helper goroutine. A panic reaching the top of that helper
|
||||
// would kill the process without running the deferred calls of any other
|
||||
// goroutine — including cmd/rogue/main.go's `defer t.Fini()`, which is
|
||||
// the only thing that takes the tty back out of raw mode. That is issue
|
||||
// #12's failure, and it would land on the one path where the terminal is
|
||||
// already broken.
|
||||
//
|
||||
// So the panic has to arrive on the goroutine that runs the game, with
|
||||
// that goroutine's deferred restore still on the stack. This test stands
|
||||
// in for main: a Fini deferred around the g.shell() call, and the panic
|
||||
// caught after it, asserting both that the restore ran and that the
|
||||
// original value came through. Against the unrecovered version there is
|
||||
// nothing to assert — the panic escapes a helper goroutine and takes the
|
||||
// whole test binary down, which is the failure being prevented.
|
||||
func TestShellEscapePanicUnwindsTheGameGoroutine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pt := &panickingShellTerm{blockingTerm: newBlockingTerm()}
|
||||
g := mkBlockedGame(t, pt)
|
||||
|
||||
caught := make(chan any, 1)
|
||||
|
||||
go func() {
|
||||
// Registered first, so it runs last: it sees the terminal
|
||||
// already restored, exactly as the runtime would have printed
|
||||
// the trace after main's Fini.
|
||||
defer func() { caught <- recover() }()
|
||||
|
||||
// Stands in for cmd/rogue/main.go's `defer t.Fini()`.
|
||||
defer pt.Fini()
|
||||
|
||||
g.shell()
|
||||
}()
|
||||
|
||||
got := <-caught
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("the resume failure did not reach the game goroutine")
|
||||
}
|
||||
|
||||
if msg, ok := got.(string); !ok || msg != errShellResume {
|
||||
t.Errorf("recovered %v, want %q", got, errShellResume)
|
||||
}
|
||||
|
||||
if !pt.restored {
|
||||
t.Error("the terminal was not restored on the way out")
|
||||
}
|
||||
|
||||
// shell() must not have resumed into its InShell reset and refresh:
|
||||
// there is no screen left to draw into.
|
||||
if !g.InShell {
|
||||
t.Error("shell() carried on drawing after the resume failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalTimesOutLeavingTheOldSave pins the backstop: a game
|
||||
// goroutine that never reaches a service point must not hold the process
|
||||
// open, and giving up must cost the player nothing. The old save is still
|
||||
// there, byte for byte — which is the whole point of renaming over the
|
||||
// target instead of removing it first.
|
||||
func TestAutoSaveOnSignalTimesOutLeavingTheOldSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 77)
|
||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||
|
||||
const old = "an older save nobody is allowed to destroy"
|
||||
|
||||
writeErr := os.WriteFile(g.FileName, []byte(old), 0o600)
|
||||
if writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
|
||||
// Nothing drives the turn loop, so nothing will ever answer.
|
||||
start := time.Now()
|
||||
|
||||
if g.AutoSaveOnSignal(100 * time.Millisecond) {
|
||||
t.Error("AutoSaveOnSignal reported a save that nobody took")
|
||||
}
|
||||
|
||||
if waited := time.Since(start); waited > time.Second {
|
||||
t.Errorf("waited %v for an unanswered save, want the deadline to bound it",
|
||||
waited)
|
||||
}
|
||||
|
||||
got, readErr := os.ReadFile(g.FileName)
|
||||
if readErr != nil {
|
||||
t.Fatalf("the previous save was destroyed: %v", readErr)
|
||||
}
|
||||
|
||||
if string(got) != old {
|
||||
t.Error("the previous save was overwritten by a save that never ran")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSaveOnSignalWithoutASaveFile covers the death demo's terminal
|
||||
// case: a game with no file name has nothing to write, and must say so
|
||||
// rather than reporting a save that did not happen.
|
||||
func TestAutoSaveOnSignalWithoutASaveFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 5, Term: &testTerm{
|
||||
input: []byte(strings.Repeat("s . ", 200)),
|
||||
}})
|
||||
g.FileName = ""
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
var answered bool
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
answered = g.AutoSaveOnSignal(autoSaveWait)
|
||||
}()
|
||||
|
||||
driveUntilDone(t, g, done)
|
||||
|
||||
if answered {
|
||||
t.Error("AutoSaveOnSignal = true with no save file name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveFileReplacesTargetAtomically pins the write discipline: the new
|
||||
// save arrives by rename, so the file the player already had is never
|
||||
// written into, and the temporary file it came from is not left lying in
|
||||
// the save directory.
|
||||
//
|
||||
// The load-bearing assertion is the handle opened before the save. A
|
||||
// rename leaves the old file whole and merely stops it being reachable by
|
||||
// name, so that handle still reads the old save; the truncate-in-place
|
||||
// write this replaced would empty it under the reader — the same
|
||||
// in-place write that, interrupted, left the player with a file that
|
||||
// could no longer be restored.
|
||||
func TestSaveFileReplacesTargetAtomically(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 11)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "rogue.save")
|
||||
|
||||
const old = "an older save"
|
||||
|
||||
writeErr := os.WriteFile(path, []byte(old), 0o600)
|
||||
if writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
|
||||
held, openErr := os.Open(path) //nolint:gosec // G304: test temp path
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
|
||||
defer func() { _ = held.Close() }()
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr != nil {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
kept, readErr := io.ReadAll(held)
|
||||
if readErr != nil {
|
||||
t.Fatalf("reading the file that was there before the save: %v", readErr)
|
||||
}
|
||||
|
||||
if string(kept) != old {
|
||||
t.Errorf("the previous save was written into rather than replaced: %q",
|
||||
string(kept))
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
|
||||
if len(entries) != 1 || entries[0].Name() != "rogue.save" {
|
||||
t.Errorf("save directory = %v, want just the save file", names(entries))
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
t.Fatal(statErr)
|
||||
}
|
||||
|
||||
if perm := info.Mode().Perm(); perm != 0o400 {
|
||||
t.Errorf("save file mode = %v, want 0400", perm)
|
||||
}
|
||||
|
||||
assertRestorable(t, path)
|
||||
}
|
||||
|
||||
// TestSaveFileLeavesTargetWhenTheRenameFails is the other half of the
|
||||
// same discipline: a save that cannot be completed must leave what the
|
||||
// player already had. The target here is a non-empty directory, which no
|
||||
// rename can replace — the one write failure that can be forced without
|
||||
// depending on file permissions, and therefore on not being root.
|
||||
func TestSaveFileLeavesTargetWhenTheRenameFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 12)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "rogue.save")
|
||||
|
||||
mkErr := os.Mkdir(path, 0o700)
|
||||
if mkErr != nil {
|
||||
t.Fatal(mkErr)
|
||||
}
|
||||
|
||||
keep := filepath.Join(path, "keep")
|
||||
|
||||
writeErr := os.WriteFile(keep, []byte("still here"), 0o600)
|
||||
if writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr == nil {
|
||||
t.Error("saveFile over an unreplaceable target reported success")
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(keep)
|
||||
if statErr != nil {
|
||||
t.Errorf("the target was damaged by a failed save: %v", statErr)
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("save directory = %v, want no temporary file left behind",
|
||||
names(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// names lists directory entry names for a failure message.
|
||||
func names(entries []os.DirEntry) []string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// assertRestorable checks that path holds a save this program can load,
|
||||
// which is what "the save was taken" has to mean: a file of the right
|
||||
// size proves nothing about a torn encode.
|
||||
func assertRestorable(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := Restore(path, Params{Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Errorf("the saved file does not restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mkBlockedGame builds a game with a save file name and a terminal whose
|
||||
// reads block, for the tests that park the game goroutine.
|
||||
func mkBlockedGame(t *testing.T, term Terminal) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := New(Params{Seed: 4242, Term: term})
|
||||
g.NewLevel()
|
||||
g.FileName = filepath.Join(t.TempDir(), "rogue.save")
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// blockingTerm is a Terminal that genuinely blocks in ReadChar until a
|
||||
// key is pushed or Interrupt wakes it — which testTerm, whose reads never
|
||||
// block, cannot reproduce.
|
||||
type blockingTerm struct {
|
||||
keys chan byte
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingTerm() *blockingTerm {
|
||||
return &blockingTerm{
|
||||
keys: make(chan byte),
|
||||
// Buffered by one and posted to without blocking, the same
|
||||
// contract term.Tcell.Interrupt has with tcell's event queue: an
|
||||
// interrupt that arrives before the read still wakes it.
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *blockingTerm) Render(*Window) {}
|
||||
|
||||
// Repaint has nothing to redraw: this terminal exists for its input
|
||||
// behaviour, and no autosave test types CTRL-R.
|
||||
func (t *blockingTerm) Repaint() {}
|
||||
|
||||
func (t *blockingTerm) Fini() {}
|
||||
|
||||
// Interrupt wakes a blocked ReadChar; called from the saving goroutine.
|
||||
func (t *blockingTerm) Interrupt() {
|
||||
select {
|
||||
case t.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ReadChar blocks until a key arrives or Interrupt wakes it.
|
||||
func (t *blockingTerm) ReadChar() (byte, bool) {
|
||||
select {
|
||||
case ch := <-t.keys:
|
||||
return ch, true
|
||||
case <-t.wake:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// shellTerm is a blockingTerm that also offers a shell escape which stays
|
||||
// in the shell until the test lets it out.
|
||||
type shellTerm struct {
|
||||
*blockingTerm
|
||||
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
// ShellEscape parks the caller in the "shell" until released.
|
||||
func (t *shellTerm) ShellEscape() {
|
||||
close(t.entered)
|
||||
<-t.release
|
||||
}
|
||||
|
||||
// errShellResume is what panickingShellTerm panics with, standing in for
|
||||
// the value term.Tcell.ShellEscape raises when Screen.Resume fails.
|
||||
const errShellResume = "resume failed"
|
||||
|
||||
// panickingShellTerm is a blockingTerm whose shell escape panics on the
|
||||
// way out, the way term.Tcell.ShellEscape does when the screen cannot be
|
||||
// resumed. It records whether Fini ran, which is the thing that must
|
||||
// still happen.
|
||||
type panickingShellTerm struct {
|
||||
*blockingTerm
|
||||
|
||||
restored bool
|
||||
}
|
||||
|
||||
func (t *panickingShellTerm) Fini() { t.restored = true }
|
||||
|
||||
func (t *panickingShellTerm) ShellEscape() { panic(errShellResume) }
|
||||
497
game/chase.go
497
game/chase.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// chase.c — code for one creature to chase another.
|
||||
@@ -9,44 +10,61 @@ const dragonShot = 5
|
||||
func (g *RogueGame) runners(int) {
|
||||
list := append([]*Monster(nil), g.Level.Monsters...)
|
||||
for _, tp := range list {
|
||||
if !tp.On(Held) && tp.On(Awake) {
|
||||
origPos := tp.Pos
|
||||
wastarget := tp.On(Targeted)
|
||||
if g.moveMonst(tp) == -1 {
|
||||
continue
|
||||
}
|
||||
if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 {
|
||||
if g.moveMonst(tp) == -1 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if wastarget && origPos != tp.Pos {
|
||||
tp.Flags.Clear(Targeted)
|
||||
g.ToDeath = false
|
||||
}
|
||||
}
|
||||
g.runnerTurn(tp)
|
||||
}
|
||||
|
||||
if g.HasHit {
|
||||
g.endmsg()
|
||||
g.HasHit = false
|
||||
}
|
||||
}
|
||||
|
||||
// moveMonst executes a single turn of running for a monster (chase.c
|
||||
// move_monst). Returns -1 if the monster died or left the level.
|
||||
func (g *RogueGame) moveMonst(tp *Monster) int {
|
||||
// runnerTurn gives one monster its motion for the turn; flying monsters
|
||||
// far from the hero move twice (the loop body of chase.c runners).
|
||||
func (g *RogueGame) runnerTurn(tp *Monster) {
|
||||
if tp.On(Held) || !tp.On(Awake) {
|
||||
return
|
||||
}
|
||||
|
||||
origPos := tp.Pos
|
||||
|
||||
wastarget := tp.On(Targeted)
|
||||
if removed := g.moveMonster(tp); removed {
|
||||
return
|
||||
}
|
||||
|
||||
if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 {
|
||||
if removed := g.moveMonster(tp); removed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if wastarget && origPos != tp.Pos {
|
||||
tp.Flags.Clear(Targeted)
|
||||
|
||||
g.ToDeath = false
|
||||
}
|
||||
}
|
||||
|
||||
// moveMonster executes a single turn of running for a monster (chase.c
|
||||
// move_monst). The result reports that the monster died or left the
|
||||
// level (the C -1 return).
|
||||
func (g *RogueGame) moveMonster(tp *Monster) bool {
|
||||
if !tp.On(Slowed) || tp.Turn {
|
||||
if g.doChase(tp) == -1 {
|
||||
return -1
|
||||
if g.chaseStep(tp) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if tp.On(Hasted) {
|
||||
if g.doChase(tp) == -1 {
|
||||
return -1
|
||||
if g.chaseStep(tp) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
tp.Turn = !tp.Turn
|
||||
return 0
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// relocate makes the monster's new location be the specified one, updating
|
||||
@@ -54,18 +72,21 @@ func (g *RogueGame) moveMonst(tp *Monster) int {
|
||||
func (g *RogueGame) relocate(th *Monster, newLoc Coord) {
|
||||
if newLoc != th.Pos {
|
||||
g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh)
|
||||
th.Room = g.roomin(newLoc)
|
||||
g.setOldch(th, newLoc)
|
||||
th.Room = g.roomIn(newLoc)
|
||||
g.setOldChar(th, newLoc)
|
||||
oroom := th.Room
|
||||
g.Level.SetMonsterAt(th.Pos.Y, th.Pos.X, nil)
|
||||
|
||||
if oroom != th.Room {
|
||||
th.Dest = g.findDest(th)
|
||||
}
|
||||
|
||||
th.Pos = newLoc
|
||||
g.Level.SetMonsterAt(newLoc.Y, newLoc.X, th)
|
||||
}
|
||||
|
||||
g.move(newLoc.Y, newLoc.X)
|
||||
|
||||
if g.seeMonst(th) {
|
||||
g.addch(th.Disguise)
|
||||
} else if g.Player.On(SenseMonsters) {
|
||||
@@ -75,31 +96,82 @@ func (g *RogueGame) relocate(th *Monster, newLoc Coord) {
|
||||
}
|
||||
}
|
||||
|
||||
// doChase makes one thing chase another (chase.c do_chase). Returns -1 if
|
||||
// the chaser died in the attempt.
|
||||
func (g *RogueGame) doChase(th *Monster) int {
|
||||
p := &g.Player
|
||||
// chaseStep makes one thing chase another (chase.c do_chase). The
|
||||
// result reports that the chaser died or left the level in the attempt
|
||||
// (the C -1 return).
|
||||
func (g *RogueGame) chaseStep(th *Monster) bool {
|
||||
stoprun := false // true means we are there
|
||||
mindist := 32767
|
||||
|
||||
rer, ree, door := g.chaseRooms(th)
|
||||
|
||||
this, shot := g.chaseGoal(th, rer, ree, door, 32767)
|
||||
if shot {
|
||||
return false
|
||||
}
|
||||
// This now contains what we want to run to this time so we run to it.
|
||||
// If we hit it we either want to fight it or stop running
|
||||
if g.chase(th, this) {
|
||||
if th.Type == 'F' {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
switch this {
|
||||
case g.Player.Pos:
|
||||
return g.attack(th)
|
||||
case *th.Dest:
|
||||
g.chaseTakeObject(th)
|
||||
|
||||
stoprun = th.Type != 'F'
|
||||
}
|
||||
}
|
||||
|
||||
g.relocate(th, g.chRet)
|
||||
// And stop running if need be
|
||||
if stoprun && th.Pos == *th.Dest {
|
||||
th.Flags.Clear(Awake)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// chaseRooms finds the rooms of the chaser and its desire; doors do not
|
||||
// count as inside rooms here (the setup of chase.c do_chase).
|
||||
func (g *RogueGame) chaseRooms(th *Monster) (*Room, *Room, bool) {
|
||||
p := &g.Player
|
||||
|
||||
rer := th.Room // find room of chaser
|
||||
if th.On(Greedy) && rer.GoldVal == 0 {
|
||||
th.Dest = &p.Pos // if gold has been taken, run after hero
|
||||
}
|
||||
|
||||
var ree *Room // find room of chasee
|
||||
if th.Dest == &p.Pos {
|
||||
ree = p.Room
|
||||
} else {
|
||||
ree = g.roomin(*th.Dest)
|
||||
ree = g.roomIn(*th.Dest)
|
||||
}
|
||||
// We don't count doors as inside rooms for this routine
|
||||
door := g.Level.Char(th.Pos.Y, th.Pos.X) == Door
|
||||
|
||||
return rer, ree, g.Level.Char(th.Pos.Y, th.Pos.X) == Door
|
||||
}
|
||||
|
||||
// chaseGoal picks the spot the chaser runs toward this turn: the
|
||||
// nearest exit toward its desire when it is in a different room, or the
|
||||
// desire itself. shot means a dragon breathed flame instead of moving
|
||||
// (the goal loop of chase.c do_chase).
|
||||
func (g *RogueGame) chaseGoal(
|
||||
th *Monster, rer, ree *Room, door bool, mindist int,
|
||||
) (Coord, bool) {
|
||||
var this Coord
|
||||
|
||||
over:
|
||||
// If the object of our desire is in a different room, and we are not
|
||||
// in a corridor, run to the door nearest to our goal.
|
||||
if rer != ree {
|
||||
for {
|
||||
// If the object of our desire is in a different room, and we are
|
||||
// not in a corridor, run to the door nearest to our goal.
|
||||
if rer == ree {
|
||||
this = *th.Dest
|
||||
|
||||
return this, g.dragonBreath(th)
|
||||
}
|
||||
|
||||
for i := range rer.Exits {
|
||||
curdist := distCp(*th.Dest, rer.Exits[i])
|
||||
if curdist < mindist {
|
||||
@@ -107,79 +179,86 @@ over:
|
||||
mindist = curdist
|
||||
}
|
||||
}
|
||||
if door {
|
||||
rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum]
|
||||
door = false
|
||||
goto over
|
||||
|
||||
if !door {
|
||||
return this, false
|
||||
}
|
||||
} else {
|
||||
this = *th.Dest
|
||||
// For dragons check and see if (a) the hero is on a straight line
|
||||
// from it, and (b) that it is within shooting distance, but
|
||||
// outside of striking range.
|
||||
if th.Type == 'D' && (th.Pos.Y == p.Pos.Y || th.Pos.X == p.Pos.X ||
|
||||
abs(th.Pos.Y-p.Pos.Y) == abs(th.Pos.X-p.Pos.X)) &&
|
||||
distCp(th.Pos, p.Pos) <= BoltLength*BoltLength &&
|
||||
!th.On(Cancelled) && g.rnd(dragonShot) == 0 {
|
||||
g.Delta.Y = sign(p.Pos.Y - th.Pos.Y)
|
||||
g.Delta.X = sign(p.Pos.X - th.Pos.X)
|
||||
if g.HasHit {
|
||||
g.endmsg()
|
||||
|
||||
rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum]
|
||||
door = false
|
||||
// the C goto over: redo with the passage as room
|
||||
}
|
||||
}
|
||||
|
||||
// dragonBreath checks whether a dragon shoots flame at the hero instead
|
||||
// of moving, and shoots it (the D block of chase.c do_chase).
|
||||
func (g *RogueGame) dragonBreath(th *Monster) bool {
|
||||
if th.Type != 'D' || !g.dragonShoots(th) {
|
||||
return false
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
|
||||
g.Delta.Y = sign(p.Pos.Y - th.Pos.Y)
|
||||
|
||||
g.Delta.X = sign(p.Pos.X - th.Pos.X)
|
||||
if g.HasHit {
|
||||
g.endmsg()
|
||||
}
|
||||
|
||||
g.fireBolt(th.Pos, &g.Delta, "flame")
|
||||
g.Running = false
|
||||
g.Count = 0
|
||||
|
||||
g.Quiet = 0
|
||||
if g.ToDeath && !th.On(Targeted) {
|
||||
g.ToDeath = false
|
||||
g.Kamikaze = false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// dragonShoots decides whether the dragon takes the shot: the hero is
|
||||
// on a straight line from it, within shooting distance but outside
|
||||
// striking range, it is not cancelled, and the shot roll comes up
|
||||
// (chase.c do_chase).
|
||||
func (g *RogueGame) dragonShoots(th *Monster) bool {
|
||||
p := &g.Player
|
||||
if th.Pos.Y != p.Pos.Y && th.Pos.X != p.Pos.X &&
|
||||
abs(th.Pos.Y-p.Pos.Y) != abs(th.Pos.X-p.Pos.X) {
|
||||
return false
|
||||
}
|
||||
|
||||
return distCp(th.Pos, p.Pos) <= BoltLength*BoltLength &&
|
||||
!th.On(Cancelled) && g.rnd(dragonShot) == 0
|
||||
}
|
||||
|
||||
// chaseTakeObject has the monster pick up the object it was running to
|
||||
// (the dest arm of chase.c do_chase).
|
||||
func (g *RogueGame) chaseTakeObject(th *Monster) {
|
||||
for _, obj := range g.Level.Objects {
|
||||
if th.Dest == &obj.Pos {
|
||||
g.Level.RemoveObject(obj)
|
||||
attachObj(&th.Pack, obj)
|
||||
|
||||
if th.Room.Flags.Has(Gone) {
|
||||
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Passage)
|
||||
} else {
|
||||
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Floor)
|
||||
}
|
||||
g.fireBolt(th.Pos, &g.Delta, "flame")
|
||||
g.Running = false
|
||||
g.Count = 0
|
||||
g.Quiet = 0
|
||||
if g.ToDeath && !th.On(Targeted) {
|
||||
g.ToDeath = false
|
||||
g.Kamikaze = false
|
||||
}
|
||||
return 0
|
||||
|
||||
th.Dest = g.findDest(th)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
// This now contains what we want to run to this time so we run to it.
|
||||
// If we hit it we either want to fight it or stop running
|
||||
if !g.chase(th, this) {
|
||||
if this == p.Pos {
|
||||
return g.attack(th)
|
||||
} else if this == *th.Dest {
|
||||
for _, obj := range g.Level.Objects {
|
||||
if th.Dest == &obj.Pos {
|
||||
detachObj(&g.Level.Objects, obj)
|
||||
attachObj(&th.Pack, obj)
|
||||
if th.Room.Flags.Has(Gone) {
|
||||
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Passage)
|
||||
} else {
|
||||
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Floor)
|
||||
}
|
||||
th.Dest = g.findDest(th)
|
||||
break
|
||||
}
|
||||
}
|
||||
if th.Type != 'F' {
|
||||
stoprun = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if th.Type == 'F' {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
g.relocate(th, g.chRet)
|
||||
// And stop running if need be
|
||||
if stoprun && th.Pos == *th.Dest {
|
||||
th.Flags.Clear(Awake)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// chase finds the spot for the chaser to move closer to the chasee
|
||||
// (chase.c chase). Returns true if we want to keep on chasing later, false
|
||||
// if we reach the goal. The chosen spot lands in g.chRet.
|
||||
func (g *RogueGame) chase(tp *Monster, ee Coord) bool {
|
||||
p := &g.Player
|
||||
er := tp.Pos
|
||||
plcnt := 1
|
||||
var curdist int
|
||||
|
||||
// If the thing is confused, let it move randomly. Invisible Stalkers
|
||||
@@ -188,85 +267,115 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool {
|
||||
if (tp.On(Confused) && g.rnd(5) != 0) || (tp.Type == 'P' && g.rnd(5) == 0) ||
|
||||
(tp.Type == 'B' && g.rnd(2) == 0) {
|
||||
// get a valid random move
|
||||
g.chRet = g.rndmove(&tp.Creature)
|
||||
g.chRet = g.randomStep(&tp.Creature)
|
||||
curdist = distCp(g.chRet, ee)
|
||||
// Small chance that it will become un-confused
|
||||
if g.rnd(20) == 0 {
|
||||
tp.Flags.Clear(Confused)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, find the empty spot next to the chaser that is
|
||||
// closest to the chasee. This will eventually hold where we move
|
||||
// to get closer. If we can't find an empty spot, we stay where we
|
||||
// are.
|
||||
curdist = distCp(er, ee)
|
||||
g.chRet = er
|
||||
|
||||
ey := er.Y + 1
|
||||
if ey >= NumLines-1 {
|
||||
ey = NumLines - 2
|
||||
}
|
||||
ex := er.X + 1
|
||||
if ex >= NumCols {
|
||||
ex = NumCols - 1
|
||||
}
|
||||
|
||||
for x := er.X - 1; x <= ex; x++ {
|
||||
if x < 0 {
|
||||
continue
|
||||
}
|
||||
for y := er.Y - 1; y <= ey; y++ {
|
||||
tryp := Coord{X: x, Y: y}
|
||||
if !g.diagOk(er, tryp) {
|
||||
continue
|
||||
}
|
||||
ch := g.Level.VisibleChar(y, x)
|
||||
if stepOk(ch) {
|
||||
// If it is a scroll, it might be a scare monster
|
||||
// scroll so we need to look it up to see what type
|
||||
// it is.
|
||||
if ch == Scroll {
|
||||
var found *Object
|
||||
for _, obj := range g.Level.Objects {
|
||||
if y == obj.Pos.Y && x == obj.Pos.X {
|
||||
found = obj
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != nil && found.ScrollKind() == ScrollScareMonster {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// It can also be a Xeroc, which we shouldn't step on
|
||||
if m := g.Level.MonsterAt(y, x); m != nil && m.Type == 'X' {
|
||||
continue
|
||||
}
|
||||
// If we didn't find any scrolls at this place or it
|
||||
// wasn't a scare scroll, then this place counts
|
||||
thisdist := distance(y, x, ee.Y, ee.X)
|
||||
if thisdist < curdist {
|
||||
plcnt = 1
|
||||
g.chRet = tryp
|
||||
curdist = thisdist
|
||||
} else if thisdist == curdist {
|
||||
if plcnt++; g.rnd(plcnt) == 0 {
|
||||
g.chRet = tryp
|
||||
curdist = thisdist
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
curdist = g.chaseBestSpot(tp, ee)
|
||||
}
|
||||
return curdist != 0 && g.chRet != p.Pos
|
||||
|
||||
return curdist != 0 && g.chRet != g.Player.Pos
|
||||
}
|
||||
|
||||
// setOldch sets the oldch character for the monster (chase.c set_oldch).
|
||||
func (g *RogueGame) setOldch(tp *Monster, cp Coord) {
|
||||
// chaseSearch is the scan state while chase looks for the step that
|
||||
// gets a monster closest to its chasee.
|
||||
type chaseSearch struct {
|
||||
er Coord // where the chaser is
|
||||
ee Coord // where it wants to go
|
||||
curdist int
|
||||
plcnt int
|
||||
}
|
||||
|
||||
// chaseBestSpot finds the empty spot next to the chaser that is closest
|
||||
// to the chasee, leaving it in g.chRet; if there is none, the chaser
|
||||
// stays where it is (the search half of chase.c chase).
|
||||
func (g *RogueGame) chaseBestSpot(tp *Monster, ee Coord) int {
|
||||
er := tp.Pos
|
||||
s := chaseSearch{er: er, ee: ee, curdist: distCp(er, ee), plcnt: 1}
|
||||
g.chRet = er
|
||||
|
||||
ey := er.Y + 1
|
||||
if ey >= NumLines-1 {
|
||||
ey = NumLines - 2
|
||||
}
|
||||
|
||||
ex := er.X + 1
|
||||
if ex >= NumCols {
|
||||
ex = NumCols - 1
|
||||
}
|
||||
|
||||
for x := er.X - 1; x <= ex; x++ {
|
||||
if x < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for y := er.Y - 1; y <= ey; y++ {
|
||||
g.chaseTry(&s, y, x)
|
||||
}
|
||||
}
|
||||
|
||||
return s.curdist
|
||||
}
|
||||
|
||||
// chaseTry scores one candidate square, reservoir-sampling among ties
|
||||
// (the scan body of chase.c chase).
|
||||
func (g *RogueGame) chaseTry(s *chaseSearch, y, x int) {
|
||||
tryp := Coord{X: x, Y: y}
|
||||
if !g.diagOk(s.er, tryp) {
|
||||
return
|
||||
}
|
||||
|
||||
ch := g.Level.VisibleChar(y, x)
|
||||
if !stepOk(ch) {
|
||||
return
|
||||
}
|
||||
// If it is a scroll, it might be a scare monster scroll so we need
|
||||
// to look it up to see what type it is.
|
||||
if ch == Scroll && g.scareScrollAt(y, x) {
|
||||
return
|
||||
}
|
||||
// It can also be a Xeroc, which we shouldn't step on
|
||||
if m := g.Level.MonsterAt(y, x); m != nil && m.Type == 'X' {
|
||||
return
|
||||
}
|
||||
// If we didn't find any scrolls at this place or it wasn't a scare
|
||||
// scroll, then this place counts
|
||||
thisdist := distance(y, x, s.ee.Y, s.ee.X)
|
||||
if thisdist < s.curdist {
|
||||
s.plcnt = 1
|
||||
g.chRet = tryp
|
||||
s.curdist = thisdist
|
||||
} else if thisdist == s.curdist {
|
||||
if s.plcnt++; g.rnd(s.plcnt) == 0 {
|
||||
g.chRet = tryp
|
||||
s.curdist = thisdist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scareScrollAt reports whether the object lying at (y, x) is a scare
|
||||
// monster scroll (chase.c chase).
|
||||
func (g *RogueGame) scareScrollAt(y, x int) bool {
|
||||
for _, obj := range g.Level.Objects {
|
||||
if y == obj.Pos.Y && x == obj.Pos.X {
|
||||
return obj.ScrollKind() == ScrollScareMonster
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// setOldChar sets the oldch character for the monster (chase.c set_oldch).
|
||||
func (g *RogueGame) setOldChar(tp *Monster, cp Coord) {
|
||||
if tp.Pos == cp {
|
||||
return
|
||||
}
|
||||
|
||||
sch := tp.OldCh
|
||||
|
||||
tp.OldCh = g.mvinch(cp.Y, cp.X)
|
||||
if !g.Player.On(Blind) {
|
||||
if (sch == Floor || tp.OldCh == Floor) && tp.Room.Flags.Has(Dark) {
|
||||
@@ -284,25 +393,30 @@ func (g *RogueGame) seeMonst(mp *Monster) bool {
|
||||
if p.On(Blind) {
|
||||
return false
|
||||
}
|
||||
|
||||
if mp.On(Invisible) && !p.On(CanSeeInvisible) {
|
||||
return false
|
||||
}
|
||||
|
||||
y, x := mp.Pos.Y, mp.Pos.X
|
||||
if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
|
||||
if y != p.Pos.Y && x != p.Pos.X &&
|
||||
!stepOk(g.Level.Char(y, p.Pos.X)) && !stepOk(g.Level.Char(p.Pos.Y, x)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if mp.Room != p.Room {
|
||||
return false
|
||||
}
|
||||
|
||||
return !mp.Room.Flags.Has(Dark)
|
||||
}
|
||||
|
||||
// runto sets a monster running after the hero (chase.c runto).
|
||||
func (g *RogueGame) runto(runner Coord) {
|
||||
// runTo sets a monster running after the hero (chase.c runto).
|
||||
func (g *RogueGame) runTo(runner Coord) {
|
||||
tp := g.Level.MonsterAt(runner.Y, runner.X)
|
||||
if tp == nil {
|
||||
return
|
||||
@@ -313,9 +427,9 @@ func (g *RogueGame) runto(runner Coord) {
|
||||
tp.Dest = g.findDest(tp)
|
||||
}
|
||||
|
||||
// roomin finds what room some coordinates are in; nil means they aren't in
|
||||
// roomIn finds what room some coordinates are in; nil means they aren't in
|
||||
// any room (chase.c roomin).
|
||||
func (g *RogueGame) roomin(cp Coord) *Room {
|
||||
func (g *RogueGame) roomIn(cp Coord) *Room {
|
||||
fp := *g.Level.FlagsAt(cp.Y, cp.X)
|
||||
if fp.Has(FPassage) {
|
||||
return &g.Level.Passages[fp&FPassNum]
|
||||
@@ -330,6 +444,7 @@ func (g *RogueGame) roomin(cp Coord) *Room {
|
||||
}
|
||||
|
||||
g.msg("in some bizarre place (%d, %d)", cp.Y, cp.X)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -338,19 +453,22 @@ func (g *RogueGame) diagOk(sp, ep Coord) bool {
|
||||
if ep.X < 0 || ep.X >= NumCols || ep.Y <= 0 || ep.Y >= NumLines-1 {
|
||||
return false
|
||||
}
|
||||
|
||||
if ep.X == sp.X || ep.Y == sp.Y {
|
||||
return true
|
||||
}
|
||||
|
||||
return stepOk(g.Level.Char(ep.Y, sp.X)) && stepOk(g.Level.Char(sp.Y, ep.X))
|
||||
}
|
||||
|
||||
// cansee returns true if the hero can see a certain coordinate (chase.c
|
||||
// canSee returns true if the hero can see a certain coordinate (chase.c
|
||||
// cansee).
|
||||
func (g *RogueGame) cansee(y, x int) bool {
|
||||
func (g *RogueGame) canSee(y, x int) bool {
|
||||
p := &g.Player
|
||||
if p.On(Blind) {
|
||||
return false
|
||||
}
|
||||
|
||||
if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
|
||||
if g.Level.FlagsAt(y, x).Has(FPassage) {
|
||||
if y != p.Pos.Y && x != p.Pos.X &&
|
||||
@@ -359,11 +477,13 @@ func (g *RogueGame) cansee(y, x int) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
// We can only see if the hero is in the same room as the coordinate
|
||||
// and the room is lit, or if it is close.
|
||||
rer := g.roomin(Coord{X: x, Y: y})
|
||||
rer := g.roomIn(Coord{X: x, Y: y})
|
||||
|
||||
return rer == p.Room && !rer.Flags.Has(Dark)
|
||||
}
|
||||
|
||||
@@ -374,22 +494,29 @@ func (g *RogueGame) findDest(tp *Monster) *Coord {
|
||||
if prob <= 0 || tp.Room == g.Player.Room || g.seeMonst(tp) {
|
||||
return &g.Player.Pos
|
||||
}
|
||||
|
||||
for _, obj := range g.Level.Objects {
|
||||
if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster {
|
||||
continue
|
||||
}
|
||||
if g.roomin(obj.Pos) == tp.Room && g.rnd(100) < prob {
|
||||
claimed := false
|
||||
for _, other := range g.Level.Monsters {
|
||||
if other.Dest == &obj.Pos {
|
||||
claimed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !claimed {
|
||||
return &obj.Pos
|
||||
}
|
||||
|
||||
if g.roomIn(obj.Pos) == tp.Room && g.rnd(100) < prob &&
|
||||
!g.objectClaimed(obj) {
|
||||
return &obj.Pos
|
||||
}
|
||||
}
|
||||
|
||||
return &g.Player.Pos
|
||||
}
|
||||
|
||||
// objectClaimed reports whether some monster already runs toward this
|
||||
// object (chase.c find_dest).
|
||||
func (g *RogueGame) objectClaimed(obj *Object) bool {
|
||||
for _, other := range g.Level.Monsters {
|
||||
if other.Dest == &obj.Pos {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
1245
game/command.go
1245
game/command.go
File diff suppressed because it is too large
Load Diff
37
game/command_test.go
Normal file
37
game/command_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRedrawCommandForcesFullRepaint pins CTRL-R to a forced repaint
|
||||
// rather than an ordinary refresh. C's arm is "after = FALSE;
|
||||
// clearok(curscr, TRUE); wrefresh(curscr);" (command.c), and the
|
||||
// clearok is the command: a diffing refresh compares the new frame
|
||||
// against the device's record of the old one and sends nothing when they
|
||||
// agree, which is exactly the situation after some other program has
|
||||
// scribbled on the terminal. Only the terminal can tell the difference,
|
||||
// so the test watches the terminal rather than the window contents.
|
||||
func TestRedrawCommandForcesFullRepaint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
|
||||
term, ok := g.scr.term.(*testTerm)
|
||||
if !ok {
|
||||
t.Fatal("game terminal is not a testTerm")
|
||||
}
|
||||
|
||||
before := term.repaints
|
||||
g.After = true
|
||||
|
||||
g.dispatch(CTRL('R'))
|
||||
|
||||
if term.repaints != before+1 {
|
||||
t.Errorf("terminal repainted %d times, want %d: CTRL-R did not force "+
|
||||
"a full redraw", term.repaints-before, 1)
|
||||
}
|
||||
|
||||
if g.After {
|
||||
t.Error("CTRL-R consumed a turn; C sets after = FALSE")
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,18 @@ package game
|
||||
|
||||
// Creature is the _t arm of the C THING union: the player or a monster.
|
||||
type Creature struct {
|
||||
Pos Coord // position
|
||||
Turn bool // if slowed, is it a turn to move
|
||||
Type byte // what it is: 'A'..'Z' for monsters, '@' for the player
|
||||
Disguise byte // what mimic looks like
|
||||
OldCh byte // character that was where it was
|
||||
Dest *Coord // where it is running to — aliases live coords (hero pos, room gold, another monster's pos)
|
||||
Flags CreatureFlags
|
||||
Stats Stats
|
||||
Room *Room // current room for thing
|
||||
Pack []*Object // what the thing is carrying
|
||||
Pos Coord // position
|
||||
Turn bool // if slowed, is it a turn to move
|
||||
Type byte // what it is: 'A'..'Z' for monsters, '@' for the player
|
||||
Disguise byte // what mimic looks like
|
||||
OldCh byte // character that was where it was
|
||||
// Dest is where it is running to — aliases live coords (hero pos,
|
||||
// room gold, another monster's pos).
|
||||
Dest *Coord
|
||||
Flags CreatureFlags
|
||||
Stats Stats
|
||||
Room *Room // current room for thing
|
||||
Pack []*Object // what the thing is carrying
|
||||
}
|
||||
|
||||
// Monster is a hostile creature on the level.
|
||||
@@ -23,6 +25,7 @@ type Monster struct {
|
||||
// in the C sources (cur_armor, purse, food_left, ...).
|
||||
type Player struct {
|
||||
Creature
|
||||
|
||||
CurArmor *Object // what he is wearing
|
||||
CurWeapon *Object // which weapon he is wielding
|
||||
CurRing [2]*Object // which rings are being worn (Left/Right)
|
||||
@@ -49,6 +52,47 @@ func (p *Player) IsWearing(ring RingKind) bool {
|
||||
return p.IsRing(Left, ring) || p.IsRing(Right, ring)
|
||||
}
|
||||
|
||||
// nextPackChar claims and returns the next unused pack character (pack.c
|
||||
// pack_char).
|
||||
func (p *Player) nextPackChar() byte {
|
||||
for i := range p.PackUsed {
|
||||
if !p.PackUsed[i] {
|
||||
p.PackUsed[i] = true
|
||||
|
||||
return byte(i) + 'a'
|
||||
}
|
||||
}
|
||||
|
||||
return byte(len(p.PackUsed)) + 'a' // C would walk off the array here
|
||||
}
|
||||
|
||||
// removeFromPack takes an item out of the pack: the whole entry, or one
|
||||
// of a stack when all is false (the bookkeeping half of pack.c
|
||||
// leave_pack). It returns the object that left the pack — a copy when
|
||||
// newobj asks for a split.
|
||||
func (p *Player) removeFromPack(obj *Object, newobj, all bool) *Object {
|
||||
p.Inpack--
|
||||
|
||||
nobj := obj
|
||||
if obj.Count > 1 && !all {
|
||||
obj.Count--
|
||||
if obj.Group != 0 {
|
||||
p.Inpack++
|
||||
}
|
||||
|
||||
if newobj {
|
||||
copied := *obj
|
||||
nobj = &copied
|
||||
nobj.Count = 1
|
||||
}
|
||||
} else {
|
||||
p.PackUsed[obj.PackCh-'a'] = false
|
||||
detachObj(&p.Pack, obj)
|
||||
}
|
||||
|
||||
return nobj
|
||||
}
|
||||
|
||||
// attachMon pushes a monster onto the front of a list (list.c attach).
|
||||
func attachMon(list *[]*Monster, item *Monster) {
|
||||
*list = append([]*Monster{item}, *list...)
|
||||
@@ -59,6 +103,7 @@ func detachMon(list *[]*Monster, item *Monster) {
|
||||
for i, m := range *list {
|
||||
if m == item {
|
||||
*list = append((*list)[:i], (*list)[i+1:]...)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ package game
|
||||
// function-pointer-to-int mapping in state.c rs_write_daemons.
|
||||
type DaemonID int
|
||||
|
||||
// Daemon and fuse callback identifiers. The first block's numeric values
|
||||
// match the function-pointer-to-int mapping in state.c rs_write_daemons;
|
||||
// the second block covers fuses state.c never saved (the Go save format
|
||||
// handles them all uniformly).
|
||||
const (
|
||||
DNone DaemonID = 0
|
||||
DRollwand DaemonID = 1
|
||||
@@ -21,12 +25,10 @@ const (
|
||||
DUnconfuse DaemonID = 7
|
||||
DUnsee DaemonID = 8
|
||||
DSight DaemonID = 9
|
||||
// Fuses beyond the C save map (state.c never saved these; the Go save
|
||||
// format handles them uniformly).
|
||||
DVisuals DaemonID = 10
|
||||
DComeDown DaemonID = 11
|
||||
DLand DaemonID = 12
|
||||
DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback
|
||||
DVisuals DaemonID = 10
|
||||
DComeDown DaemonID = 11
|
||||
DLand DaemonID = 12
|
||||
DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback
|
||||
)
|
||||
|
||||
// Scheduling phases and slot states (daemon.c).
|
||||
@@ -59,6 +61,7 @@ func (g *RogueGame) dSlot() *delayedAction {
|
||||
return &g.Daemons.List[i]
|
||||
}
|
||||
}
|
||||
|
||||
panic("ran out of fuse slots") // C: debug message in MASTER, NULL deref otherwise
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ func (g *RogueGame) findSlot(f DaemonID) *delayedAction {
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
168
game/daemons.go
168
game/daemons.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// daemons.c — the daemon and fuse callbacks, dispatched by DaemonID.
|
||||
@@ -5,38 +6,14 @@ package game
|
||||
|
||||
// runDaemon invokes the callback named by id (the call through d_func in C).
|
||||
func (g *RogueGame) runDaemon(id DaemonID, arg int) {
|
||||
switch id {
|
||||
case DRollwand:
|
||||
g.rollwand(arg)
|
||||
case DDoctor:
|
||||
g.doctor(arg)
|
||||
case DStomach:
|
||||
g.stomach(arg)
|
||||
case DRunners:
|
||||
g.runners(arg)
|
||||
case DSwander:
|
||||
g.swander(arg)
|
||||
case DNohaste:
|
||||
g.nohaste(arg)
|
||||
case DUnconfuse:
|
||||
g.unconfuse(arg)
|
||||
case DUnsee:
|
||||
g.unsee(arg)
|
||||
case DSight:
|
||||
g.sight(arg)
|
||||
case DVisuals:
|
||||
g.visuals(arg)
|
||||
case DComeDown:
|
||||
g.comeDown(arg)
|
||||
case DLand:
|
||||
g.land(arg)
|
||||
case DTurnSee:
|
||||
g.turnSee(arg != 0)
|
||||
default:
|
||||
// Callbacks are added to this switch as their subsystems are
|
||||
// ported; reaching one that isn't here is a porting bug.
|
||||
h := g.data.daemonHandlers[id]
|
||||
if h == nil {
|
||||
// Handlers are added to the table as their subsystems are
|
||||
// ported; reaching one that isn't there is a porting bug.
|
||||
panic("daemon not yet ported")
|
||||
}
|
||||
|
||||
h(g, arg)
|
||||
}
|
||||
|
||||
// doctor is the healing daemon that restores hit points after rest
|
||||
@@ -45,6 +22,7 @@ func (g *RogueGame) doctor(int) {
|
||||
p := &g.Player
|
||||
lv := p.Stats.Lvl
|
||||
ohp := p.Stats.HP
|
||||
|
||||
g.Quiet++
|
||||
if lv < 8 {
|
||||
if g.Quiet+(lv<<1) > 20 {
|
||||
@@ -53,16 +31,20 @@ func (g *RogueGame) doctor(int) {
|
||||
} else if g.Quiet >= 3 {
|
||||
p.Stats.HP += g.rnd(lv-7) + 1
|
||||
}
|
||||
|
||||
if p.IsRing(Left, RingRegeneration) {
|
||||
p.Stats.HP++
|
||||
}
|
||||
|
||||
if p.IsRing(Right, RingRegeneration) {
|
||||
p.Stats.HP++
|
||||
}
|
||||
|
||||
if ohp != p.Stats.HP {
|
||||
if p.Stats.HP > p.Stats.MaxHP {
|
||||
p.Stats.HP = p.Stats.MaxHP
|
||||
}
|
||||
|
||||
g.Quiet = 0
|
||||
}
|
||||
}
|
||||
@@ -82,6 +64,7 @@ func (g *RogueGame) rollwand(int) {
|
||||
g.KillDaemon(DRollwand)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), Before)
|
||||
}
|
||||
|
||||
g.Daemons.Between = 0
|
||||
}
|
||||
}
|
||||
@@ -103,6 +86,7 @@ func (g *RogueGame) unsee(int) {
|
||||
g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh)
|
||||
}
|
||||
}
|
||||
|
||||
g.Player.Flags.Clear(CanSeeInvisible)
|
||||
}
|
||||
|
||||
@@ -112,9 +96,11 @@ func (g *RogueGame) sight(int) {
|
||||
if p.On(Blind) {
|
||||
g.Extinguish(DSight)
|
||||
p.Flags.Clear(Blind)
|
||||
|
||||
if !p.Room.Flags.Has(Gone) {
|
||||
g.enterRoom(p.Pos)
|
||||
}
|
||||
|
||||
g.msg("%s", g.chooseStr("far out! Everything is all cosmic again",
|
||||
"the veil of darkness lifts"))
|
||||
}
|
||||
@@ -129,54 +115,78 @@ func (g *RogueGame) nohaste(int) {
|
||||
// stomach digests the hero's food (daemons.c stomach).
|
||||
func (g *RogueGame) stomach(int) {
|
||||
p := &g.Player
|
||||
|
||||
origHungry := p.HungryState
|
||||
if p.FoodLeft <= 0 {
|
||||
if p.FoodLeft--; p.FoodLeft < -StarveTime {
|
||||
g.death('s')
|
||||
}
|
||||
// the hero is fainting
|
||||
if g.NoCommand != 0 || g.rnd(5) != 0 {
|
||||
return
|
||||
}
|
||||
g.NoCommand += g.rnd(8) + 4
|
||||
p.HungryState = 3
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("%s", g.chooseStr(
|
||||
"the munchies overpower your motor capabilities. ",
|
||||
"you feel too weak from lack of food. "))
|
||||
}
|
||||
g.msg("%s", g.chooseStr("You freak out", "You faint"))
|
||||
g.stomachFaint()
|
||||
} else {
|
||||
oldfood := p.FoodLeft
|
||||
amulet := 0
|
||||
if g.HasAmulet {
|
||||
amulet = 1
|
||||
}
|
||||
p.FoodLeft -= g.ringEat(Left) + g.ringEat(Right) + 1 - amulet
|
||||
|
||||
if p.FoodLeft < MoreTime && oldfood >= MoreTime {
|
||||
p.HungryState = 2
|
||||
g.msg("%s", g.chooseStr(
|
||||
"the munchies are interfering with your motor capabilites",
|
||||
"you are starting to feel weak"))
|
||||
} else if p.FoodLeft < 2*MoreTime && oldfood >= 2*MoreTime {
|
||||
p.HungryState = 1
|
||||
if g.Options.Terse {
|
||||
g.msg("%s", g.chooseStr("getting the munchies", "getting hungry"))
|
||||
} else {
|
||||
g.msg("%s", g.chooseStr("you are getting the munchies",
|
||||
"you are starting to get hungry"))
|
||||
}
|
||||
}
|
||||
g.stomachDigest()
|
||||
}
|
||||
|
||||
if p.HungryState != origHungry {
|
||||
p.Flags.Clear(Awake)
|
||||
|
||||
g.Running = false
|
||||
g.ToDeath = false
|
||||
g.Count = 0
|
||||
}
|
||||
}
|
||||
|
||||
// stomachFaint starves and possibly faints an empty-stomached hero (the
|
||||
// no-food arm of daemons.c stomach).
|
||||
func (g *RogueGame) stomachFaint() {
|
||||
p := &g.Player
|
||||
if p.FoodLeft--; p.FoodLeft < -StarveTime {
|
||||
g.death('s')
|
||||
}
|
||||
// the hero is fainting
|
||||
if g.NoCommand != 0 || g.rnd(5) != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
g.NoCommand += g.rnd(8) + 4
|
||||
p.HungryState = 3
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsgf("%s", g.chooseStr(
|
||||
"the munchies overpower your motor capabilities. ",
|
||||
"you feel too weak from lack of food. "))
|
||||
}
|
||||
|
||||
g.msg("%s", g.chooseStr("You freak out", "You faint"))
|
||||
}
|
||||
|
||||
// stomachDigest burns food and reports growing hunger (the fed arm of
|
||||
// daemons.c stomach).
|
||||
func (g *RogueGame) stomachDigest() {
|
||||
p := &g.Player
|
||||
oldfood := p.FoodLeft
|
||||
|
||||
amulet := 0
|
||||
if g.HasAmulet {
|
||||
amulet = 1
|
||||
}
|
||||
|
||||
p.FoodLeft -= g.ringEat(Left) + g.ringEat(Right) + 1 - amulet
|
||||
|
||||
if p.FoodLeft < MoreTime && oldfood >= MoreTime {
|
||||
p.HungryState = 2
|
||||
|
||||
g.msg("%s", g.chooseStr(
|
||||
"the munchies are interfering with your motor capabilities",
|
||||
"you are starting to feel weak"))
|
||||
} else if p.FoodLeft < 2*MoreTime && oldfood >= 2*MoreTime {
|
||||
p.HungryState = 1
|
||||
|
||||
if g.Options.Terse {
|
||||
g.msg("%s", g.chooseStr("getting the munchies", "getting hungry"))
|
||||
} else {
|
||||
g.msg("%s", g.chooseStr("you are getting the munchies",
|
||||
"you are starting to get hungry"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// comeDown takes the hero down off her acid trip (daemons.c come_down).
|
||||
func (g *RogueGame) comeDown(int) {
|
||||
p := &g.Player
|
||||
@@ -193,16 +203,18 @@ func (g *RogueGame) comeDown(int) {
|
||||
|
||||
// undo the things
|
||||
for _, tp := range g.Level.Objects {
|
||||
if g.cansee(tp.Pos.Y, tp.Pos.X) {
|
||||
if g.canSee(tp.Pos.Y, tp.Pos.X) {
|
||||
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.Kind.Glyph())
|
||||
}
|
||||
}
|
||||
|
||||
// undo the monsters
|
||||
seemonst := p.On(SenseMonsters)
|
||||
|
||||
for _, tp := range g.Level.Monsters {
|
||||
g.move(tp.Pos.Y, tp.Pos.X)
|
||||
if g.cansee(tp.Pos.Y, tp.Pos.X) {
|
||||
|
||||
if g.canSee(tp.Pos.Y, tp.Pos.X) {
|
||||
if !tp.On(Invisible) || p.On(CanSeeInvisible) {
|
||||
g.addch(tp.Disguise)
|
||||
} else {
|
||||
@@ -214,41 +226,49 @@ func (g *RogueGame) comeDown(int) {
|
||||
g.standend()
|
||||
}
|
||||
}
|
||||
|
||||
g.msg("Everything looks SO boring now.")
|
||||
}
|
||||
|
||||
// visuals changes the characters for the player while hallucinating
|
||||
// (daemons.c visuals).
|
||||
func (g *RogueGame) visuals(int) {
|
||||
p := &g.Player
|
||||
if !g.After || (g.Running && g.Options.Jump) {
|
||||
return
|
||||
}
|
||||
// change the things
|
||||
for _, tp := range g.Level.Objects {
|
||||
if g.cansee(tp.Pos.Y, tp.Pos.X) {
|
||||
if g.canSee(tp.Pos.Y, tp.Pos.X) {
|
||||
g.mvaddch(tp.Pos.Y, tp.Pos.X, g.rndThing())
|
||||
}
|
||||
}
|
||||
|
||||
// change the stairs
|
||||
if !g.SeenStairs && g.cansee(g.Level.Stairs.Y, g.Level.Stairs.X) {
|
||||
if !g.SeenStairs && g.canSee(g.Level.Stairs.Y, g.Level.Stairs.X) {
|
||||
g.mvaddch(g.Level.Stairs.Y, g.Level.Stairs.X, g.rndThing())
|
||||
}
|
||||
|
||||
// change the monsters
|
||||
seemonst := p.On(SenseMonsters)
|
||||
g.visualMonsters()
|
||||
}
|
||||
|
||||
// visualMonsters redraws the monsters through the hallucination (the
|
||||
// monster loop of daemons.c visuals).
|
||||
func (g *RogueGame) visualMonsters() {
|
||||
seemonst := g.Player.On(SenseMonsters)
|
||||
|
||||
for _, tp := range g.Level.Monsters {
|
||||
g.move(tp.Pos.Y, tp.Pos.X)
|
||||
|
||||
if g.seeMonst(tp) {
|
||||
if tp.Type == 'X' && tp.Disguise != 'X' {
|
||||
g.addch(g.rndThing())
|
||||
} else {
|
||||
g.addch(byte(g.rnd(26) + 'A'))
|
||||
g.addch(g.randomMonsterLetter())
|
||||
}
|
||||
} else if seemonst {
|
||||
g.standout()
|
||||
g.addch(byte(g.rnd(26) + 'A'))
|
||||
g.addch(g.randomMonsterLetter())
|
||||
g.standend()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,20 +25,26 @@ type DiceSpec []DiceRoll
|
||||
// a single 0x0 attack, as it did in C.
|
||||
func ParseDice(s string) DiceSpec {
|
||||
var spec DiceSpec
|
||||
|
||||
for s != "" {
|
||||
count := cAtoi(s)
|
||||
|
||||
xi := strings.IndexByte(s, 'x')
|
||||
if xi < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
s = s[xi+1:]
|
||||
spec = append(spec, DiceRoll{Count: count, Sides: cAtoi(s)})
|
||||
|
||||
si := strings.IndexByte(s, '/')
|
||||
if si < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
s = s[si+1:]
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
@@ -48,11 +54,14 @@ func dice(s string) DiceSpec { return ParseDice(s) }
|
||||
// String renders the spec back in the classic "NxM/NxM" form.
|
||||
func (d DiceSpec) String() string {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, r := range d {
|
||||
if i > 0 {
|
||||
sb.WriteByte('/')
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "%dx%d", r.Count, r.Sides)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
@@ -6,6 +7,8 @@ import "testing"
|
||||
// including its junk-tolerant edges: the bestiary placeholder "%%%x0" and
|
||||
// the flytrap reset "000x0" both mean a single 0x0 attack.
|
||||
func TestParseDice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
@@ -32,14 +35,18 @@ func TestParseDice(t *testing.T) {
|
||||
// The bestiary and weapon tables must parse to at least one attack each so
|
||||
// every creature and weapon actually swings.
|
||||
func TestTablesHaveDice(t *testing.T) {
|
||||
for i, m := range monsterTable {
|
||||
t.Parallel()
|
||||
|
||||
data := newGameData()
|
||||
for i, m := range data.monsterTable {
|
||||
if len(m.Stats.Dmg) == 0 {
|
||||
t.Errorf("monster %c (%s) has no attacks", 'A'+i, m.Name)
|
||||
}
|
||||
}
|
||||
for w, iw := range initWeaps {
|
||||
|
||||
for w, iw := range data.initWeaps {
|
||||
if len(iw.dam) == 0 || len(iw.hrl) == 0 {
|
||||
t.Errorf("weapon %v has empty dice", WeaponKind(w))
|
||||
t.Errorf("weapon %d has empty dice", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
248
game/dispatch_test.go
Normal file
248
game/dispatch_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This file is the standing form of the issue #31 audit: every case
|
||||
// label in C's command switch against this port's dispatch. It exists
|
||||
// because a missing dispatch entry is the one porting error that leaves
|
||||
// no trace at build time. The port is function-by-function, so every C
|
||||
// function has a Go counterpart and a dropped key dangles nothing and
|
||||
// fails to compile nowhere; it simply answers "illegal command" the
|
||||
// first time a player presses it. That is how '+' (issue #11) survived
|
||||
// until PR #30 found it by accident.
|
||||
//
|
||||
// The tables below are transcribed from origin/c-master:command.c, with
|
||||
// the C line numbers alongside. Read them there with rogue.h 52-53 in
|
||||
// hand:
|
||||
//
|
||||
// #define when break;case
|
||||
// #define otherwise break;default
|
||||
//
|
||||
// The labels are therefore written "when 'x':", and a grep for "case "
|
||||
// finds six of the eighty. CTRL is extern.h:113, (c & 037); ESCAPE is
|
||||
// rogue.h:121, 27.
|
||||
//
|
||||
// There are two switches and the split between them is load-bearing. A
|
||||
// key C answers from the main switch (151-427) must be answered here
|
||||
// whether or not wizard mode is on. A key C answers only from the
|
||||
// "if (wizard) switch (ch)" sub-switch (369-423) must not be reachable
|
||||
// outside it. '+' was a divergence in ordinary play, not just in wizard
|
||||
// mode, precisely because it is a main-switch key.
|
||||
//
|
||||
// The whole sub-switch, and '+' with it, is #ifdef MASTER. This port
|
||||
// targets the MASTER build: all four #ifdef MASTER sites in command.c
|
||||
// (67, 128, 317, 368) are ported unconditionally, as is sticks.c 237.
|
||||
|
||||
// cMainSwitchTableKeys are the main-switch labels whose arms are a plain
|
||||
// call, and which this port therefore answers from commandHandlers.
|
||||
func cMainSwitchTableKeys() []byte {
|
||||
return []byte{
|
||||
',', // 153
|
||||
'!', // 180
|
||||
'h', 'j', 'k', 'l', 'y', 'u', 'b', 'n', // 181-188 do_move
|
||||
'H', 'J', 'K', 'L', 'Y', 'U', 'B', 'N', // 189-196 do_run
|
||||
't', // 241
|
||||
'q', 'Q', 'i', 'I', 'd', 'r', 'e', 'w', // 258-269
|
||||
'W', 'T', 'P', 'R', 'o', 'c', // 270-275
|
||||
'>', '<', '?', '/', 's', 'z', 'D', // 276-286
|
||||
CTRL('P'), CTRL('R'), // 287-291
|
||||
'v', // 292
|
||||
'S', // 295
|
||||
'.', // 298 rest
|
||||
' ', // 299 "legal" illegal command
|
||||
'^', // 300
|
||||
'+', // 318 (#ifdef MASTER)
|
||||
Escape, // 339
|
||||
')', ']', '=', // 354-360
|
||||
'@', // 361
|
||||
}
|
||||
}
|
||||
|
||||
// cMainSwitchMultiStepKeys are the main-switch labels whose arms need
|
||||
// more than a call — C's "goto over" re-dispatch, or the F-to-f
|
||||
// fallthrough — and which this port therefore answers from dispatchKey's
|
||||
// own switch rather than from commandHandlers. They are main-switch keys
|
||||
// all the same, and a player reaches them without wizard mode.
|
||||
func cMainSwitchMultiStepKeys() []byte {
|
||||
return []byte{
|
||||
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'), // 197
|
||||
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'), // 198
|
||||
'F', // 214 sets kamikaze, then falls through
|
||||
'f', // 217
|
||||
'a', // 246
|
||||
'm', // 344
|
||||
}
|
||||
}
|
||||
|
||||
// cWizardSwitchKeys are the labels of the "if (wizard) switch (ch)"
|
||||
// sub-switch, which sits inside the main switch's otherwise: arm.
|
||||
func cWizardSwitchKeys() []byte {
|
||||
return []byte{
|
||||
'|', // 371
|
||||
'C', // 372
|
||||
'$', // 373
|
||||
CTRL('G'), CTRL('W'), // 374-375
|
||||
CTRL('D'), CTRL('A'), // 376-377
|
||||
CTRL('F'), CTRL('T'), // 378-379
|
||||
CTRL('E'), CTRL('C'), // 380-381
|
||||
CTRL('X'), // 382
|
||||
CTRL('~'), // 383
|
||||
CTRL('I'), // 390
|
||||
'*', // 419
|
||||
}
|
||||
}
|
||||
|
||||
// assertNotIllegalCommand fails if the top line reports the key as
|
||||
// illegal. illcom is the only thing that writes that message, so it is a
|
||||
// reliable "the dispatch had no arm for this key" probe: it holds
|
||||
// whether the arm printed its own message, printed nothing, or cleared
|
||||
// the line on the way out.
|
||||
func assertNotIllegalCommand(t *testing.T, g *RogueGame, ch byte) {
|
||||
t.Helper()
|
||||
|
||||
// End() upper-cases the first letter, so match from the second.
|
||||
if line := g.scr.Std.Line(0); strings.Contains(line, "llegal command") {
|
||||
t.Errorf("dispatching '%s' reached illcom (top line %q); C answers "+
|
||||
"it from command.c's switch", unctrl(ch), strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandHandlersMatchCMainSwitch pins commandHandlers to exactly the
|
||||
// set of main-switch keys C answers with a plain call. It is checked in
|
||||
// both directions on purpose. A missing key is the '+' bug. An extra key
|
||||
// is the same bug mirrored: the most likely way to acquire one is to
|
||||
// promote a key out of the wizard sub-switch, which would make a MASTER
|
||||
// debug command available in ordinary play.
|
||||
func TestCommandHandlersMatchCMainSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handlers := newGameData().commandHandlers
|
||||
keys := cMainSwitchTableKeys()
|
||||
|
||||
want := make(map[byte]bool, len(keys))
|
||||
|
||||
for _, ch := range keys {
|
||||
want[ch] = true
|
||||
|
||||
if _, ok := handlers[ch]; !ok {
|
||||
t.Errorf("commandHandlers has no entry for '%s'; C answers it "+
|
||||
"from the main command.c switch, so this port says "+
|
||||
"\"illegal command\" where C does not", unctrl(ch))
|
||||
}
|
||||
}
|
||||
|
||||
for ch := range handlers {
|
||||
if !want[ch] {
|
||||
t.Errorf("commandHandlers has an entry for '%s' that C's main "+
|
||||
"switch does not; if C answers it only under if (wizard), "+
|
||||
"it belongs in wizardCommand", unctrl(ch))
|
||||
}
|
||||
}
|
||||
|
||||
if len(want) != len(keys) {
|
||||
t.Errorf("cMainSwitchTableKeys lists %d keys, %d of them distinct; "+
|
||||
"a duplicate hides a missing key", len(keys), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchKeyAnswersCMultiStepKeys covers the main-switch keys that
|
||||
// commandHandlers cannot hold, which the set-equality test above cannot
|
||||
// see. Removing one of these from dispatchKey's switch is just as silent
|
||||
// as removing a map entry: it falls into the default arm and lands on
|
||||
// illcom, so that is what is checked.
|
||||
func TestDispatchKeyAnswersCMultiStepKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ch := range cMainSwitchMultiStepKeys() {
|
||||
t.Run(unctrl(ch), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
// Not a wizard: these are ordinary-play keys, so the default
|
||||
// arm they must not reach is illcom itself.
|
||||
g.Wizard = false
|
||||
g.Options.Terse = false
|
||||
// 'a' replays the last command; give it one to replay so it
|
||||
// takes its re-dispatch arm rather than its complaint arm.
|
||||
g.LastComm = '.'
|
||||
// F, f and m prompt for a direction. Escape backs out of the
|
||||
// prompt, which keeps them from moving the hero or starting a
|
||||
// fight while still proving their arm ran.
|
||||
setInput(t, g, Escape, Escape, Escape)
|
||||
|
||||
next, again := g.dispatchKey(ch)
|
||||
t.Logf("dispatchKey(%s) = %s, again=%v",
|
||||
unctrl(ch), unctrl(next), again)
|
||||
|
||||
assertNotIllegalCommand(t, g, ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchKeyRedispatchesCtrlDirections is the positive half of the
|
||||
// test above for the eight ctrl-directions: C's arm converts the key to
|
||||
// its upper-case run command and does "goto over" (command.c 197-213),
|
||||
// which this port spells as a true second result. Checking the returned
|
||||
// key, and not merely that illcom was missed, is what would catch the
|
||||
// arm being present but wired to the wrong direction.
|
||||
func TestDispatchKeyRedispatchesCtrlDirections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ch := range []byte{
|
||||
CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'),
|
||||
CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'),
|
||||
} {
|
||||
t.Run(unctrl(ch), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
|
||||
// C's "ch += ('A' - CTRL('A'))": ctrl-h becomes 'H'.
|
||||
wantCh := ch + 'A' - CTRL('A')
|
||||
|
||||
next, again := g.dispatchKey(ch)
|
||||
if !again {
|
||||
t.Fatalf("dispatchKey(%s) did not ask to re-dispatch; C's "+
|
||||
"arm ends in goto over", unctrl(ch))
|
||||
}
|
||||
|
||||
if next != wantCh {
|
||||
t.Errorf("dispatchKey(%s) re-dispatched as %q, want %q",
|
||||
unctrl(ch), next, wantCh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWizardDispatchAnswersCWizardSwitch is the same guard for the
|
||||
// MASTER sub-switch, driven through dispatchKey rather than through
|
||||
// wizardCommand directly so that the routing is covered too: these keys
|
||||
// must be answered because wizard mode is on, not because they leaked
|
||||
// into commandHandlers.
|
||||
func TestWizardDispatchAnswersCWizardSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, ch := range cWizardSwitchKeys() {
|
||||
t.Run(unctrl(ch), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
g.Wizard = true
|
||||
// ctrl-a is "level--; new_level()", so start deep enough for
|
||||
// it to have somewhere to go.
|
||||
g.Depth = 5
|
||||
// Escape backs out of the item and type prompts that ctrl-w,
|
||||
// ctrl-~, 'C' and '*' put up. testTerm keeps answering after
|
||||
// the script runs out, so nothing here can block.
|
||||
setInput(t, g, Escape, Escape, Escape, Escape)
|
||||
|
||||
g.dispatchKey(ch)
|
||||
|
||||
assertNotIllegalCommand(t, g, ch)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
|
||||
// mkGameInput builds a headless game whose input plays the given script.
|
||||
func mkGameInput(t *testing.T, seed int32, input string) *RogueGame {
|
||||
// mkGameInput builds a headless game; tests script it via setInput. The
|
||||
// fixed seed keeps the scripted item/monster interactions stable.
|
||||
func mkGameInput(t *testing.T) *RogueGame {
|
||||
t.Helper()
|
||||
g := NewGame(Config{Seed: seed, Term: &testTerm{input: []byte(input)}})
|
||||
|
||||
g := New(Params{Seed: 5, Term: &testTerm{}})
|
||||
g.NewLevel()
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomin(g.Player.Pos)
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
@@ -16,42 +20,65 @@ func mkGameInput(t *testing.T, seed int32, input string) *RogueGame {
|
||||
func give(g *RogueGame, obj *Object) byte {
|
||||
obj.Count = 1
|
||||
g.addPack(obj, true)
|
||||
|
||||
return obj.PackCh
|
||||
}
|
||||
|
||||
// setInput replaces the scripted terminal input.
|
||||
func setInput(t *testing.T, g *RogueGame, input ...byte) {
|
||||
t.Helper()
|
||||
|
||||
tt, ok := g.scr.term.(*testTerm)
|
||||
if !ok {
|
||||
t.Fatal("game terminal is not a testTerm")
|
||||
}
|
||||
|
||||
tt.input = input
|
||||
tt.pos = 0
|
||||
}
|
||||
|
||||
func TestQuaffHealingPotion(t *testing.T) {
|
||||
g := mkGameInput(t, 5, "")
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
pot.Which = int(PotionHealing)
|
||||
ch := give(g, pot)
|
||||
g.scr.term.(*testTerm).input = []byte{ch}
|
||||
setInput(t, g, ch)
|
||||
|
||||
g.Player.Stats.HP = 1
|
||||
g.quaff()
|
||||
|
||||
if g.Player.Stats.HP <= 1 {
|
||||
t.Error("healing potion did not heal")
|
||||
}
|
||||
|
||||
if !g.Items.Potions[PotionHealing].Know {
|
||||
t.Error("healing potion not identified after drinking")
|
||||
}
|
||||
|
||||
if len(g.Player.Pack) != 5 {
|
||||
t.Errorf("potion not consumed: %d items", len(g.Player.Pack))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
|
||||
g := mkGameInput(t, 5, "")
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
pot := newObject()
|
||||
pot.Kind = KindPotion
|
||||
pot.Which = int(PotionConfusion)
|
||||
ch := give(g, pot)
|
||||
g.scr.term.(*testTerm).input = []byte{ch}
|
||||
setInput(t, g, ch)
|
||||
|
||||
g.quaff()
|
||||
|
||||
if !g.Player.On(Confused) {
|
||||
t.Error("confusion potion did not confuse")
|
||||
}
|
||||
|
||||
if g.findSlot(DUnconfuse) == nil {
|
||||
t.Error("no unconfuse fuse pending")
|
||||
}
|
||||
@@ -59,21 +86,25 @@ func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) {
|
||||
for range 30 {
|
||||
g.DoFuses(After)
|
||||
}
|
||||
|
||||
if g.Player.On(Confused) {
|
||||
t.Error("confusion never wore off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEnchantArmor(t *testing.T) {
|
||||
g := mkGameInput(t, 5, "")
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
scr := newObject()
|
||||
scr.Kind = KindScroll
|
||||
scr.Which = int(ScrollEnchantArmor)
|
||||
ch := give(g, scr)
|
||||
g.scr.term.(*testTerm).input = []byte{ch}
|
||||
setInput(t, g, ch)
|
||||
|
||||
before := g.Player.CurArmor.ArmorClass
|
||||
g.readScroll()
|
||||
|
||||
if g.Player.CurArmor.ArmorClass != before-1 {
|
||||
t.Errorf("enchant armor: AC %d -> %d, want %d",
|
||||
before, g.Player.CurArmor.ArmorClass, before-1)
|
||||
@@ -81,22 +112,25 @@ func TestReadEnchantArmor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Note: this must not use a greedy monster ('O' orc, ISGREED): the C
|
||||
// wake_monster gold-guarding check has no ISHELD guard, so the
|
||||
// look(TRUE) at the end of read_scroll immediately re-wakes greedy
|
||||
// monsters. The port reproduces that quirk faithfully — see
|
||||
// TestHoldScrollGreedyMonsterQuirk.
|
||||
g := mkGameInput(t, 5, "")
|
||||
g := mkGameInput(t)
|
||||
tp := spawnAdjacent(g, 'Z')
|
||||
tp.Flags.Set(Awake)
|
||||
|
||||
scr := newObject()
|
||||
scr.Kind = KindScroll
|
||||
scr.Which = int(ScrollHoldMonster)
|
||||
ch := give(g, scr)
|
||||
g.scr.term.(*testTerm).input = []byte{ch}
|
||||
setInput(t, g, ch)
|
||||
|
||||
g.readScroll()
|
||||
t.Logf("after scroll: flags=%o huh=%q", tp.Flags, g.Msgs.Huh)
|
||||
|
||||
if tp.On(Awake) || !tp.On(Held) {
|
||||
t.Error("hold monster scroll did not hold the adjacent monster")
|
||||
}
|
||||
@@ -107,21 +141,26 @@ func TestReadHoldMonsterFreezesAdjacent(t *testing.T) {
|
||||
// (orc) held by a scroll is re-woken by the look(TRUE) that read_scroll
|
||||
// performs, ending up both held and running again.
|
||||
func TestHoldScrollGreedyMonsterQuirk(t *testing.T) {
|
||||
g := mkGameInput(t, 5, "")
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
tp := spawnAdjacent(g, 'O')
|
||||
tp.Flags.Set(Awake)
|
||||
|
||||
scr := newObject()
|
||||
scr.Kind = KindScroll
|
||||
scr.Which = int(ScrollHoldMonster)
|
||||
ch := give(g, scr)
|
||||
g.scr.term.(*testTerm).input = []byte{ch}
|
||||
setInput(t, g, ch)
|
||||
|
||||
g.readScroll()
|
||||
t.Logf("orc after scroll: flags=%o (Awake=%v Held=%v)",
|
||||
tp.Flags, tp.On(Awake), tp.On(Held))
|
||||
|
||||
if !tp.On(Held) {
|
||||
t.Error("orc lost Held entirely")
|
||||
}
|
||||
|
||||
if !tp.On(Awake) {
|
||||
t.Error("quirk changed: greedy monster stayed held; if this is a " +
|
||||
"deliberate fix, update this test and ARCHITECTURE.md")
|
||||
@@ -129,41 +168,122 @@ func TestHoldScrollGreedyMonsterQuirk(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestZapSlowMonster(t *testing.T) {
|
||||
g := mkGameInput(t, 5, "")
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
tp := spawnAdjacent(g, 'Z')
|
||||
stick := newObject()
|
||||
stick.Kind = KindWand
|
||||
stick.Which = int(WandSlowMonster)
|
||||
g.fixStick(stick)
|
||||
ch := give(g, stick)
|
||||
g.scr.term.(*testTerm).input = []byte{ch}
|
||||
setInput(t, g, ch)
|
||||
|
||||
g.Delta = Coord{X: 1, Y: 0} // aim at the monster
|
||||
|
||||
charges := stick.Charges
|
||||
|
||||
g.doZap()
|
||||
|
||||
if !tp.On(Slowed) {
|
||||
t.Error("slow monster wand did not slow")
|
||||
}
|
||||
|
||||
if stick.Charges != charges-1 {
|
||||
t.Error("zap did not use a charge")
|
||||
}
|
||||
}
|
||||
|
||||
// bizarreSchtick is C's message for a zap that matched no case at all
|
||||
// (sticks.c do_zap, the "otherwise" arm). Shared by the pair of tests
|
||||
// below so that the one asserting it appears and the one asserting it
|
||||
// does not can never drift apart.
|
||||
const bizarreSchtick = "what a bizarre schtick!"
|
||||
|
||||
// TestZapUnhandledWandSaysBizarreSchtick pins the closing arm of C's zap
|
||||
// switch. Every WS_ kind has a case, so the arm is reachable only for an
|
||||
// o_which outside the table — here a wand one past the end, the state a
|
||||
// corrupt save file can still describe. C's message is not gated on the
|
||||
// wizard flag, only on the MASTER build this port is, so no test setup
|
||||
// turns it on.
|
||||
func TestZapUnhandledWandSaysBizarreSchtick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
wand := malformed(KindWand)
|
||||
wand.Charges = 3
|
||||
ch := give(g, wand)
|
||||
|
||||
setInput(t, g, ch)
|
||||
g.Msgs.Huh = ""
|
||||
|
||||
g.doZap()
|
||||
|
||||
if g.Msgs.Huh != bizarreSchtick {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, bizarreSchtick)
|
||||
}
|
||||
|
||||
// C falls out of the switch into o_charges-- from the otherwise arm
|
||||
// as much as from any other.
|
||||
if wand.Charges != 2 {
|
||||
t.Errorf("charges = %d after zapping, want 2", wand.Charges)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZapWandOfNothingIsSilent is the other half, and the reason the
|
||||
// message cannot simply be attached to "no handler ran". WS_NOP is a case
|
||||
// of C's switch in its own right — "when WS_NOP: break;" — so the wand
|
||||
// that does nothing does it quietly, and only a kind C had no case for
|
||||
// is bizarre.
|
||||
func TestZapWandOfNothingIsSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
stick := newObject()
|
||||
stick.Kind = KindWand
|
||||
stick.Which = int(WandNothing)
|
||||
g.fixStick(stick)
|
||||
ch := give(g, stick)
|
||||
|
||||
setInput(t, g, ch)
|
||||
|
||||
charges := stick.Charges
|
||||
g.Msgs.Huh = ""
|
||||
|
||||
g.doZap()
|
||||
|
||||
if g.Msgs.Huh == bizarreSchtick {
|
||||
t.Errorf("the wand of nothing said %q; WS_NOP is a case of C's "+
|
||||
"switch, not an unhandled kind", bizarreSchtick)
|
||||
}
|
||||
|
||||
if stick.Charges != charges-1 {
|
||||
t.Error("zap did not use a charge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpts(t *testing.T) {
|
||||
g := NewGame(Config{Seed: 1})
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 1})
|
||||
g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow")
|
||||
|
||||
if !g.Options.Terse {
|
||||
t.Error("terse not set")
|
||||
}
|
||||
|
||||
if g.Options.Jump {
|
||||
t.Error("nojump not honored")
|
||||
}
|
||||
|
||||
if g.Whoami != "Conan" {
|
||||
t.Errorf("name = %q", g.Whoami)
|
||||
}
|
||||
|
||||
if g.Fruit != "mango" {
|
||||
t.Errorf("fruit = %q", g.Fruit)
|
||||
}
|
||||
|
||||
if g.Options.InvType != InvSlow {
|
||||
t.Errorf("inven = %d", g.Options.InvType)
|
||||
}
|
||||
|
||||
801
game/fight.go
801
game/fight.go
@@ -1,46 +1,10 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "strconv"
|
||||
|
||||
// fight.c — all the fighting gets done here.
|
||||
|
||||
// hNames are the strings for hitting; the first four are used when the
|
||||
// player strikes, the second four for monsters (fight.c h_names).
|
||||
var hNames = [8]string{
|
||||
" scored an excellent hit on ",
|
||||
" hit ",
|
||||
" have injured ",
|
||||
" swing and hit ",
|
||||
" scored an excellent hit on ",
|
||||
" hit ",
|
||||
" has injured ",
|
||||
" swings and hits ",
|
||||
}
|
||||
|
||||
// mNames are the strings for missing (fight.c m_names).
|
||||
var mNames = [8]string{
|
||||
" miss",
|
||||
" swing and miss",
|
||||
" barely miss",
|
||||
" don't hit",
|
||||
" misses",
|
||||
" swings and misses",
|
||||
" barely misses",
|
||||
" doesn't hit",
|
||||
}
|
||||
|
||||
// strPlus adjusts hit probabilities due to strength (fight.c str_plus).
|
||||
var strPlus = [32]int{
|
||||
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
|
||||
1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3,
|
||||
}
|
||||
|
||||
// addDam adjusts damage done due to strength (fight.c add_dam).
|
||||
var addDam = [32]int{
|
||||
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 3,
|
||||
3, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6,
|
||||
}
|
||||
|
||||
// setMname returns the monster name for the given monster (fight.c
|
||||
// set_mname).
|
||||
func (g *RogueGame) setMname(tp *Monster) string {
|
||||
@@ -48,20 +12,27 @@ func (g *RogueGame) setMname(tp *Monster) string {
|
||||
if g.Options.Terse {
|
||||
return "it"
|
||||
}
|
||||
|
||||
return "something"
|
||||
}
|
||||
|
||||
var mname string
|
||||
|
||||
if g.Player.On(Hallucinating) {
|
||||
ch := int(g.mvinch(tp.Pos.Y, tp.Pos.X))
|
||||
if !isUpper(byte(ch)) {
|
||||
ch = g.rnd(26)
|
||||
var idx int
|
||||
|
||||
ch := g.mvinch(tp.Pos.Y, tp.Pos.X)
|
||||
if isUpper(ch) {
|
||||
idx = int(ch - 'A')
|
||||
} else {
|
||||
ch -= 'A'
|
||||
idx = g.rnd(26)
|
||||
}
|
||||
mname = g.Monsters[ch].Name
|
||||
|
||||
mname = g.Monsters[idx].Name
|
||||
} else {
|
||||
mname = g.Monsters[tp.Type-'A'].Name
|
||||
}
|
||||
|
||||
return "the " + mname
|
||||
}
|
||||
|
||||
@@ -77,307 +48,468 @@ func (g *RogueGame) fight(mp Coord, weap *Object, thrown bool) bool {
|
||||
// place.
|
||||
g.Count = 0
|
||||
g.Quiet = 0
|
||||
g.runto(mp)
|
||||
// Let him know it was really a xeroc (if it was one).
|
||||
if tp.Type == 'X' && tp.Disguise != 'X' && !p.On(Blind) {
|
||||
tp.Disguise = 'X'
|
||||
if p.On(Hallucinating) {
|
||||
g.mvaddch(tp.Pos.Y, tp.Pos.X, byte(g.rnd(26)+'A'))
|
||||
}
|
||||
g.msg("%s", g.chooseStr("heavy! That's a nasty critter!",
|
||||
"wait! That's a xeroc!"))
|
||||
if !thrown {
|
||||
return false
|
||||
}
|
||||
g.runTo(mp)
|
||||
|
||||
if g.revealXeroc(tp) && !thrown {
|
||||
return false
|
||||
}
|
||||
|
||||
mname := g.setMname(tp)
|
||||
didHit := false
|
||||
|
||||
g.HasHit = g.Options.Terse && !g.ToDeath
|
||||
if g.rollEm(&p.Creature, &tp.Creature, weap, thrown) {
|
||||
didHit = false
|
||||
if thrown {
|
||||
g.thunk(weap, mname, g.Options.Terse)
|
||||
} else {
|
||||
g.hit("", mname, g.Options.Terse)
|
||||
}
|
||||
if p.On(CanConfuse) {
|
||||
didHit = true
|
||||
tp.Flags.Set(Confused)
|
||||
p.Flags.Clear(CanConfuse)
|
||||
g.endmsg()
|
||||
g.HasHit = false
|
||||
g.msg("your hands stop glowing %s", g.pickColor("red"))
|
||||
}
|
||||
if tp.Stats.HP <= 0 {
|
||||
g.killed(tp, true)
|
||||
} else if didHit && !p.On(Blind) {
|
||||
g.msg("%s appears confused", mname)
|
||||
}
|
||||
didHit = true
|
||||
} else {
|
||||
if thrown {
|
||||
g.bounce(weap, mname, g.Options.Terse)
|
||||
} else {
|
||||
g.miss("", mname, g.Options.Terse)
|
||||
}
|
||||
if g.rollAttacks(&p.Creature, &tp.Creature, weap, thrown) {
|
||||
g.heroHits(tp, mname, weap, thrown)
|
||||
|
||||
return true
|
||||
}
|
||||
return didHit
|
||||
|
||||
if thrown {
|
||||
g.bounce(weap, mname, g.Options.Terse)
|
||||
} else {
|
||||
g.miss("", mname, g.Options.Terse)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// attack has the monster attack the player (fight.c attack). Returns -1 if
|
||||
// the monster removed itself from the level during its own attack.
|
||||
func (g *RogueGame) attack(mp *Monster) int {
|
||||
// revealXeroc lets him know it was really a xeroc (if it was one); it
|
||||
// reports whether one was unmasked (the X block of fight.c fight).
|
||||
func (g *RogueGame) revealXeroc(tp *Monster) bool {
|
||||
p := &g.Player
|
||||
if tp.Type != 'X' || tp.Disguise == 'X' || p.On(Blind) {
|
||||
return false
|
||||
}
|
||||
|
||||
tp.Disguise = 'X'
|
||||
if p.On(Hallucinating) {
|
||||
g.mvaddch(tp.Pos.Y, tp.Pos.X, g.randomMonsterLetter())
|
||||
}
|
||||
|
||||
g.msg("%s", g.chooseStr("heavy! That's a nasty critter!",
|
||||
"wait! That's a xeroc!"))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// heroHits lands the hero's blow on a monster: messages, the confusing
|
||||
// touch, and the kill check (the hit arm of fight.c fight).
|
||||
func (g *RogueGame) heroHits(tp *Monster, mname string, weap *Object, thrown bool) {
|
||||
p := &g.Player
|
||||
confused := false
|
||||
|
||||
if thrown {
|
||||
g.thunk(weap, mname, g.Options.Terse)
|
||||
} else {
|
||||
g.hit("", mname, g.Options.Terse)
|
||||
}
|
||||
|
||||
if p.On(CanConfuse) {
|
||||
confused = true
|
||||
|
||||
tp.Flags.Set(Confused)
|
||||
p.Flags.Clear(CanConfuse)
|
||||
g.endmsg()
|
||||
g.HasHit = false
|
||||
g.msg("your hands stop glowing %s", g.pickColor("red"))
|
||||
}
|
||||
|
||||
if tp.Stats.HP <= 0 {
|
||||
g.killed(tp, true)
|
||||
} else if confused && !p.On(Blind) {
|
||||
g.msg("%s appears confused", mname)
|
||||
}
|
||||
}
|
||||
|
||||
// attack has the monster attack the player (fight.c attack). The result
|
||||
// reports that the monster took itself off the level during its own
|
||||
// attack (the C -1 return).
|
||||
func (g *RogueGame) attack(mp *Monster) bool {
|
||||
p := &g.Player
|
||||
// Since this is an attack, stop running and any healing that was
|
||||
// going on at the time.
|
||||
g.Running = false
|
||||
g.Count = 0
|
||||
|
||||
g.Quiet = 0
|
||||
if g.ToDeath && !mp.On(Targeted) {
|
||||
g.ToDeath = false
|
||||
g.Kamikaze = false
|
||||
}
|
||||
|
||||
if mp.Type == 'X' && mp.Disguise != 'X' && !p.On(Blind) {
|
||||
mp.Disguise = 'X'
|
||||
if p.On(Hallucinating) {
|
||||
g.mvaddch(mp.Pos.Y, mp.Pos.X, byte(g.rnd(26)+'A'))
|
||||
g.mvaddch(mp.Pos.Y, mp.Pos.X, g.randomMonsterLetter())
|
||||
}
|
||||
}
|
||||
|
||||
mname := g.setMname(mp)
|
||||
oldhp := p.Stats.HP
|
||||
removed := false
|
||||
if g.rollEm(&mp.Creature, &p.Creature, nil, false) {
|
||||
if mp.Type != 'I' {
|
||||
if g.HasHit {
|
||||
g.addmsg(". ")
|
||||
}
|
||||
g.hit(mname, "", false)
|
||||
} else if g.HasHit {
|
||||
g.endmsg()
|
||||
}
|
||||
g.HasHit = false
|
||||
if p.Stats.HP <= 0 {
|
||||
g.death(mp.Type) // Bye bye life ...
|
||||
} else if !g.Kamikaze {
|
||||
oldhp -= p.Stats.HP
|
||||
if oldhp > g.MaxHit {
|
||||
g.MaxHit = oldhp
|
||||
}
|
||||
if p.Stats.HP <= g.MaxHit {
|
||||
g.ToDeath = false
|
||||
}
|
||||
}
|
||||
if !mp.On(Cancelled) {
|
||||
switch mp.Type {
|
||||
case 'A':
|
||||
// If an aquator hits, you can lose armor class.
|
||||
g.rustArmor(p.CurArmor)
|
||||
case 'I':
|
||||
// The ice monster freezes you
|
||||
p.Flags.Clear(Awake)
|
||||
if g.NoCommand == 0 {
|
||||
g.addmsg("you are frozen")
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(" by the %s", mname)
|
||||
}
|
||||
g.endmsg()
|
||||
}
|
||||
g.NoCommand += g.rnd(2) + 2
|
||||
if g.NoCommand > BoreLevel {
|
||||
g.death('h')
|
||||
}
|
||||
case 'R':
|
||||
// Rattlesnakes have poisonous bites
|
||||
if !g.save(VsPoison) {
|
||||
if !p.IsWearing(RingSustainStrength) {
|
||||
g.chgStr(-1)
|
||||
if !g.Options.Terse {
|
||||
g.msg("you feel a bite in your leg and now feel weaker")
|
||||
} else {
|
||||
g.msg("a bite has weakened you")
|
||||
}
|
||||
} else if !g.ToDeath {
|
||||
if !g.Options.Terse {
|
||||
g.msg("a bite momentarily weakens you")
|
||||
} else {
|
||||
g.msg("bite has no effect")
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'W', 'V':
|
||||
// Wraiths might drain energy levels, and Vampires can
|
||||
// steal max_hp
|
||||
chance := 30
|
||||
if mp.Type == 'W' {
|
||||
chance = 15
|
||||
}
|
||||
if g.rnd(100) < chance {
|
||||
var fewer int
|
||||
if mp.Type == 'W' {
|
||||
if p.Stats.Exp == 0 {
|
||||
g.death('W') // All levels gone
|
||||
}
|
||||
if p.Stats.Lvl--; p.Stats.Lvl == 0 {
|
||||
p.Stats.Exp = 0
|
||||
p.Stats.Lvl = 1
|
||||
} else {
|
||||
p.Stats.Exp = eLevels[p.Stats.Lvl-1] + 1
|
||||
}
|
||||
fewer = g.roll(1, 10)
|
||||
} else {
|
||||
fewer = g.roll(1, 3)
|
||||
}
|
||||
p.Stats.HP -= fewer
|
||||
p.Stats.MaxHP -= fewer
|
||||
if p.Stats.HP <= 0 {
|
||||
p.Stats.HP = 1
|
||||
}
|
||||
if p.Stats.MaxHP <= 0 {
|
||||
g.death(mp.Type)
|
||||
}
|
||||
g.msg("you suddenly feel weaker")
|
||||
}
|
||||
case 'F':
|
||||
// Venus Flytrap stops the poor guy from moving
|
||||
p.Flags.Set(Held)
|
||||
p.VfHit++
|
||||
g.Monsters['F'-'A'].Stats.Dmg = DiceSpec{{Count: p.VfHit, Sides: 1}}
|
||||
if p.Stats.HP--; p.Stats.HP <= 0 {
|
||||
g.death('F')
|
||||
}
|
||||
case 'L':
|
||||
// Leprechaun steals some gold
|
||||
lastpurse := p.Purse
|
||||
p.Purse -= g.goldCalc()
|
||||
if !g.save(VsMagic) {
|
||||
p.Purse -= g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
|
||||
}
|
||||
if p.Purse < 0 {
|
||||
p.Purse = 0
|
||||
}
|
||||
g.removeMon(mp.Pos, mp, false)
|
||||
removed = true
|
||||
if p.Purse != lastpurse {
|
||||
g.msg("your purse feels lighter")
|
||||
}
|
||||
case 'N':
|
||||
// Nymphs steal a magic item; look through the pack and
|
||||
// pick out one we like.
|
||||
var steal *Object
|
||||
nobj := 0
|
||||
for _, obj := range p.Pack {
|
||||
if obj != p.CurArmor && obj != p.CurWeapon &&
|
||||
obj != p.CurRing[Left] && obj != p.CurRing[Right] &&
|
||||
obj.isMagic() {
|
||||
if nobj++; g.rnd(nobj) == 0 {
|
||||
steal = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
if steal != nil {
|
||||
g.removeMon(mp.Pos, g.Level.MonsterAt(mp.Pos.Y, mp.Pos.X), false)
|
||||
removed = true
|
||||
g.leavePack(steal, false, false)
|
||||
g.msg("she stole %s!", g.invName(steal, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if mp.Type != 'I' {
|
||||
if g.HasHit {
|
||||
g.addmsg(". ")
|
||||
g.HasHit = false
|
||||
}
|
||||
if mp.Type == 'F' {
|
||||
p.Stats.HP -= p.VfHit
|
||||
if p.Stats.HP <= 0 {
|
||||
g.death(mp.Type) // Bye bye life ...
|
||||
}
|
||||
}
|
||||
g.miss(mname, "", false)
|
||||
|
||||
if g.rollAttacks(&mp.Creature, &p.Creature, nil, false) {
|
||||
removed = g.monsterHit(mp, mname, oldhp)
|
||||
} else {
|
||||
g.monsterMiss(mp, mname)
|
||||
}
|
||||
|
||||
if g.Options.FightFlush && !g.ToDeath {
|
||||
g.flushType()
|
||||
}
|
||||
|
||||
g.Count = 0
|
||||
g.status()
|
||||
if removed {
|
||||
return -1
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
// monsterHit lands a monster's blow on the hero: messages, death and
|
||||
// to-death bookkeeping, then the monster's special power (the hit arm
|
||||
// of fight.c attack). It reports whether the monster removed itself.
|
||||
func (g *RogueGame) monsterHit(mp *Monster, mname string, oldhp int) bool {
|
||||
p := &g.Player
|
||||
if mp.Type != 'I' {
|
||||
if g.HasHit {
|
||||
g.addmsgf(". ")
|
||||
}
|
||||
|
||||
g.hit(mname, "", false)
|
||||
} else if g.HasHit {
|
||||
g.endmsg()
|
||||
}
|
||||
return 0
|
||||
|
||||
g.HasHit = false
|
||||
if p.Stats.HP <= 0 {
|
||||
g.death(mp.Type) // Bye bye life ...
|
||||
} else if !g.Kamikaze {
|
||||
oldhp -= p.Stats.HP
|
||||
if oldhp > g.MaxHit {
|
||||
g.MaxHit = oldhp
|
||||
}
|
||||
|
||||
if p.Stats.HP <= g.MaxHit {
|
||||
g.ToDeath = false
|
||||
}
|
||||
}
|
||||
|
||||
if !mp.On(Cancelled) {
|
||||
if h := g.data.hitHandlers[mp.Type-'A']; h != nil {
|
||||
return h(g, mp, mname)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// monsterMiss handles a monster's whiffed swing (the miss arm of
|
||||
// fight.c attack); ice monsters miss silently.
|
||||
func (g *RogueGame) monsterMiss(mp *Monster, mname string) {
|
||||
if mp.Type == 'I' {
|
||||
return
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
|
||||
if g.HasHit {
|
||||
g.addmsgf(". ")
|
||||
g.HasHit = false
|
||||
}
|
||||
|
||||
if mp.Type == 'F' {
|
||||
p.Stats.HP -= p.VfHit
|
||||
if p.Stats.HP <= 0 {
|
||||
g.death(mp.Type) // Bye bye life ...
|
||||
}
|
||||
}
|
||||
|
||||
g.miss(mname, "", false)
|
||||
}
|
||||
|
||||
// The monster special-power handlers, dispatched through
|
||||
// gameData.hitHandlers when an uncancelled monster's hit lands. Each is
|
||||
// one case of the C attack switch; a true return means the monster
|
||||
// removed itself from the level.
|
||||
|
||||
func (g *RogueGame) hitAquator(*Monster, string) bool {
|
||||
// If an aquator hits, you can lose armor class.
|
||||
g.rustArmor(g.Player.CurArmor)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *RogueGame) hitIceMonster(_ *Monster, mname string) bool {
|
||||
// The ice monster freezes you
|
||||
g.Player.Flags.Clear(Awake)
|
||||
|
||||
if g.NoCommand == 0 {
|
||||
g.addmsgf("you are frozen")
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsgf(" by the %s", mname)
|
||||
}
|
||||
|
||||
g.endmsg()
|
||||
}
|
||||
|
||||
g.NoCommand += g.rnd(2) + 2
|
||||
if g.NoCommand > BoreLevel {
|
||||
g.death('h')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *RogueGame) hitRattlesnake(*Monster, string) bool {
|
||||
// Rattlesnakes have poisonous bites
|
||||
if g.save(VsPoison) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !g.Player.IsWearing(RingSustainStrength) {
|
||||
g.changeStrength(-1)
|
||||
g.msg("%s", g.chooseTerse("a bite has weakened you",
|
||||
"you feel a bite in your leg and now feel weaker"))
|
||||
} else if !g.ToDeath {
|
||||
g.msg("%s", g.chooseTerse("bite has no effect",
|
||||
"a bite momentarily weakens you"))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *RogueGame) hitLifeDrainer(mp *Monster, _ string) bool {
|
||||
// Wraiths might drain energy levels, and Vampires can steal max_hp
|
||||
p := &g.Player
|
||||
|
||||
chance := 30
|
||||
if mp.Type == 'W' {
|
||||
chance = 15
|
||||
}
|
||||
|
||||
if g.rnd(100) >= chance {
|
||||
return false
|
||||
}
|
||||
|
||||
var fewer int
|
||||
|
||||
if mp.Type == 'W' {
|
||||
if p.Stats.Exp == 0 {
|
||||
g.death('W') // All levels gone
|
||||
}
|
||||
|
||||
if p.Stats.Lvl--; p.Stats.Lvl == 0 {
|
||||
p.Stats.Exp = 0
|
||||
p.Stats.Lvl = 1
|
||||
} else {
|
||||
p.Stats.Exp = g.data.eLevels[p.Stats.Lvl-1] + 1
|
||||
}
|
||||
|
||||
fewer = g.roll(1, 10)
|
||||
} else {
|
||||
fewer = g.roll(1, 3)
|
||||
}
|
||||
|
||||
p.Stats.HP -= fewer
|
||||
|
||||
p.Stats.MaxHP -= fewer
|
||||
if p.Stats.HP <= 0 {
|
||||
p.Stats.HP = 1
|
||||
}
|
||||
|
||||
if p.Stats.MaxHP <= 0 {
|
||||
g.death(mp.Type)
|
||||
}
|
||||
|
||||
g.msg("you suddenly feel weaker")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *RogueGame) hitFlytrap(*Monster, string) bool {
|
||||
// Venus Flytrap stops the poor guy from moving
|
||||
p := &g.Player
|
||||
p.Flags.Set(Held)
|
||||
p.VfHit++
|
||||
|
||||
g.Monsters['F'-'A'].Stats.Dmg = DiceSpec{{Count: p.VfHit, Sides: 1}}
|
||||
if p.Stats.HP--; p.Stats.HP <= 0 {
|
||||
g.death('F')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *RogueGame) hitLeprechaun(mp *Monster, _ string) bool {
|
||||
// Leprechaun steals some gold
|
||||
p := &g.Player
|
||||
lastpurse := p.Purse
|
||||
|
||||
p.Purse -= g.goldCalc()
|
||||
if !g.save(VsMagic) {
|
||||
p.Purse -= g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
|
||||
}
|
||||
|
||||
if p.Purse < 0 {
|
||||
p.Purse = 0
|
||||
}
|
||||
|
||||
g.removeMon(mp.Pos, mp, false)
|
||||
|
||||
if p.Purse != lastpurse {
|
||||
g.msg("your purse feels lighter")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) hitNymph(mp *Monster, _ string) bool {
|
||||
// Nymphs steal a magic item; look through the pack and pick out one
|
||||
// we like.
|
||||
p := &g.Player
|
||||
|
||||
var steal *Object
|
||||
|
||||
nobj := 0
|
||||
|
||||
for _, obj := range p.Pack {
|
||||
if obj != p.CurArmor && obj != p.CurWeapon &&
|
||||
obj != p.CurRing[Left] && obj != p.CurRing[Right] &&
|
||||
g.isMagic(obj) {
|
||||
if nobj++; g.rnd(nobj) == 0 {
|
||||
steal = obj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if steal == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
g.removeMon(mp.Pos, g.Level.MonsterAt(mp.Pos.Y, mp.Pos.X), false)
|
||||
g.leavePack(steal, false, false)
|
||||
g.msg("she stole %s!", g.inventoryName(steal, true))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// swing returns true if the swing hits (fight.c swing).
|
||||
func (g *RogueGame) swing(atLvl, opArm, wplus int) bool {
|
||||
res := g.rnd(20)
|
||||
need := (20 - atLvl) - opArm
|
||||
|
||||
return res+wplus >= need
|
||||
}
|
||||
|
||||
// rollEm rolls several attacks (fight.c roll_em).
|
||||
func (g *RogueGame) rollEm(thatt, thdef *Creature, weap *Object, hurl bool) bool {
|
||||
p := &g.Player
|
||||
// rollAttacks rolls several attacks (fight.c roll_em).
|
||||
func (g *RogueGame) rollAttacks(thatt, thdef *Creature, weap *Object, hurl bool) bool {
|
||||
att := &thatt.Stats
|
||||
def := &thdef.Stats
|
||||
var attacks DiceSpec
|
||||
var hplus, dplus int
|
||||
|
||||
var (
|
||||
attacks DiceSpec
|
||||
hplus, dplus int
|
||||
)
|
||||
|
||||
if weap == nil {
|
||||
attacks = att.Dmg
|
||||
} else {
|
||||
hplus = weap.HPlus
|
||||
dplus = weap.DPlus
|
||||
if weap == p.CurWeapon {
|
||||
if p.IsRing(Left, RingIncreaseDamage) {
|
||||
dplus += p.CurRing[Left].Bonus
|
||||
} else if p.IsRing(Left, RingDexterity) {
|
||||
hplus += p.CurRing[Left].Bonus
|
||||
}
|
||||
if p.IsRing(Right, RingIncreaseDamage) {
|
||||
dplus += p.CurRing[Right].Bonus
|
||||
} else if p.IsRing(Right, RingDexterity) {
|
||||
hplus += p.CurRing[Right].Bonus
|
||||
}
|
||||
}
|
||||
attacks = weap.Damage
|
||||
if hurl {
|
||||
if weap.Flags.Has(Missile) && p.CurWeapon != nil &&
|
||||
WeaponKind(p.CurWeapon.Which) == weap.Launch {
|
||||
attacks = weap.HurlDmg
|
||||
hplus += p.CurWeapon.HPlus
|
||||
dplus += p.CurWeapon.DPlus
|
||||
} else if weap.Launch < 0 {
|
||||
attacks = weap.HurlDmg
|
||||
}
|
||||
}
|
||||
attacks, hplus, dplus = g.weaponAttack(weap, hurl)
|
||||
}
|
||||
// If the creature being attacked is not running (asleep or held) then
|
||||
// the attacker gets a plus four bonus to hit.
|
||||
if !thdef.Flags.Has(Awake) {
|
||||
hplus += 4
|
||||
}
|
||||
|
||||
defArm := g.defenderArmor(thdef)
|
||||
didHit := false
|
||||
|
||||
for _, atk := range attacks {
|
||||
if g.swing(att.Lvl, defArm, hplus+g.data.strPlus[att.Str]) {
|
||||
proll := g.roll(atk.Count, atk.Sides)
|
||||
|
||||
damage := dplus + proll + g.data.addDam[att.Str]
|
||||
if damage > 0 {
|
||||
def.HP -= damage
|
||||
}
|
||||
|
||||
didHit = true
|
||||
}
|
||||
}
|
||||
|
||||
return didHit
|
||||
}
|
||||
|
||||
// weaponAttack picks the dice and to-hit/damage bonuses a weapon swings
|
||||
// with: ring bonuses when wielded, and launcher pairing for hurled
|
||||
// missiles (the weapon preamble of fight.c roll_em).
|
||||
func (g *RogueGame) weaponAttack(weap *Object, hurl bool) (DiceSpec, int, int) {
|
||||
p := &g.Player
|
||||
|
||||
hplus := weap.HPlus
|
||||
|
||||
dplus := weap.DPlus
|
||||
if weap == p.CurWeapon {
|
||||
hplus, dplus = g.wieldedRingBonus(hplus, dplus)
|
||||
}
|
||||
|
||||
attacks := weap.Damage
|
||||
if hurl {
|
||||
if weap.Flags.Has(Missile) && p.CurWeapon != nil &&
|
||||
WeaponKind(p.CurWeapon.Which) == weap.Launch {
|
||||
attacks = weap.HurlDmg
|
||||
hplus += p.CurWeapon.HPlus
|
||||
dplus += p.CurWeapon.DPlus
|
||||
} else if weap.Launch < 0 {
|
||||
attacks = weap.HurlDmg
|
||||
}
|
||||
}
|
||||
|
||||
return attacks, hplus, dplus
|
||||
}
|
||||
|
||||
// wieldedRingBonus folds damage and dexterity ring bonuses into the
|
||||
// wielded weapon's to-hit/damage pluses (fight.c roll_em).
|
||||
func (g *RogueGame) wieldedRingBonus(hplus, dplus int) (int, int) {
|
||||
p := &g.Player
|
||||
if p.IsRing(Left, RingIncreaseDamage) {
|
||||
dplus += p.CurRing[Left].Bonus
|
||||
} else if p.IsRing(Left, RingDexterity) {
|
||||
hplus += p.CurRing[Left].Bonus
|
||||
}
|
||||
|
||||
if p.IsRing(Right, RingIncreaseDamage) {
|
||||
dplus += p.CurRing[Right].Bonus
|
||||
} else if p.IsRing(Right, RingDexterity) {
|
||||
hplus += p.CurRing[Right].Bonus
|
||||
}
|
||||
|
||||
return hplus, dplus
|
||||
}
|
||||
|
||||
// defenderArmor computes the defender's effective armor class: worn
|
||||
// armor and protection rings when the hero defends (the def_arm
|
||||
// computation of fight.c roll_em).
|
||||
func (g *RogueGame) defenderArmor(thdef *Creature) int {
|
||||
p := &g.Player
|
||||
def := &thdef.Stats
|
||||
|
||||
defArm := def.ArmorClass
|
||||
if def == &p.Stats {
|
||||
if p.CurArmor != nil {
|
||||
defArm = p.CurArmor.ArmorClass
|
||||
}
|
||||
|
||||
if p.IsRing(Left, RingProtection) {
|
||||
defArm -= p.CurRing[Left].Bonus
|
||||
}
|
||||
|
||||
if p.IsRing(Right, RingProtection) {
|
||||
defArm -= p.CurRing[Right].Bonus
|
||||
}
|
||||
}
|
||||
didHit := false
|
||||
for _, atk := range attacks {
|
||||
if g.swing(att.Lvl, defArm, hplus+strPlus[att.Str]) {
|
||||
proll := g.roll(atk.Count, atk.Sides)
|
||||
damage := dplus + proll + addDam[att.Str]
|
||||
if damage > 0 {
|
||||
def.HP -= damage
|
||||
}
|
||||
didHit = true
|
||||
}
|
||||
}
|
||||
return didHit
|
||||
|
||||
return defArm
|
||||
}
|
||||
|
||||
// cAtoi parses a leading integer like C atoi: trailing non-digits are
|
||||
@@ -387,7 +519,9 @@ func cAtoi(s string) int {
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
|
||||
n, _ := strconv.Atoi(s[:i])
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -398,9 +532,11 @@ func prname(mname string, upper bool) string {
|
||||
if out == "" {
|
||||
out = "you"
|
||||
}
|
||||
|
||||
if upper {
|
||||
out = string(toUpper(out[0])) + out[1:]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -409,12 +545,15 @@ func (g *RogueGame) thunk(weap *Object, mname string, noend bool) {
|
||||
if g.ToDeath {
|
||||
return
|
||||
}
|
||||
|
||||
if weap.Kind == KindWeapon {
|
||||
g.addmsg("the %s hits ", g.Items.Weapons[weap.Which].Name)
|
||||
g.addmsgf("the %s hits ", g.Items.Weapons[weap.Which].Name)
|
||||
} else {
|
||||
g.addmsg("you hit ")
|
||||
g.addmsgf("you hit ")
|
||||
}
|
||||
g.addmsg("%s", mname)
|
||||
|
||||
g.addmsgf("%s", mname)
|
||||
|
||||
if !noend {
|
||||
g.endmsg()
|
||||
}
|
||||
@@ -425,7 +564,9 @@ func (g *RogueGame) hit(er, ee string, noend bool) {
|
||||
if g.ToDeath {
|
||||
return
|
||||
}
|
||||
g.addmsg("%s", prname(er, true))
|
||||
|
||||
g.addmsgf("%s", prname(er, true))
|
||||
|
||||
var s string
|
||||
if g.Options.Terse {
|
||||
s = " hit"
|
||||
@@ -434,12 +575,16 @@ func (g *RogueGame) hit(er, ee string, noend bool) {
|
||||
if er != "" {
|
||||
i += 4
|
||||
}
|
||||
s = hNames[i]
|
||||
|
||||
s = g.data.hNames[i]
|
||||
}
|
||||
g.addmsg("%s", s)
|
||||
|
||||
g.addmsgf("%s", s)
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("%s", prname(ee, false))
|
||||
g.addmsgf("%s", prname(ee, false))
|
||||
}
|
||||
|
||||
if !noend {
|
||||
g.endmsg()
|
||||
}
|
||||
@@ -450,18 +595,24 @@ func (g *RogueGame) miss(er, ee string, noend bool) {
|
||||
if g.ToDeath {
|
||||
return
|
||||
}
|
||||
g.addmsg("%s", prname(er, true))
|
||||
|
||||
g.addmsgf("%s", prname(er, true))
|
||||
|
||||
i := 0
|
||||
if !g.Options.Terse {
|
||||
i = g.rnd(4)
|
||||
}
|
||||
|
||||
if er != "" {
|
||||
i += 4
|
||||
}
|
||||
g.addmsg("%s", mNames[i])
|
||||
|
||||
g.addmsgf("%s", g.data.mNames[i])
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(" %s", prname(ee, false))
|
||||
g.addmsgf(" %s", prname(ee, false))
|
||||
}
|
||||
|
||||
if !noend {
|
||||
g.endmsg()
|
||||
}
|
||||
@@ -472,12 +623,15 @@ func (g *RogueGame) bounce(weap *Object, mname string, noend bool) {
|
||||
if g.ToDeath {
|
||||
return
|
||||
}
|
||||
|
||||
if weap.Kind == KindWeapon {
|
||||
g.addmsg("the %s misses ", g.Items.Weapons[weap.Which].Name)
|
||||
g.addmsgf("the %s misses ", g.Items.Weapons[weap.Which].Name)
|
||||
} else {
|
||||
g.addmsg("you missed ")
|
||||
g.addmsgf("you missed ")
|
||||
}
|
||||
g.addmsg("%s", mname)
|
||||
|
||||
g.addmsgf("%s", mname)
|
||||
|
||||
if !noend {
|
||||
g.endmsg()
|
||||
}
|
||||
@@ -489,15 +643,19 @@ func (g *RogueGame) removeMon(mp Coord, tp *Monster, waskill bool) {
|
||||
for _, obj := range pack {
|
||||
obj.Pos = tp.Pos
|
||||
detachObj(&tp.Pack, obj)
|
||||
|
||||
if waskill {
|
||||
g.fall(obj, false)
|
||||
}
|
||||
}
|
||||
|
||||
g.Level.SetMonsterAt(mp.Y, mp.X, nil)
|
||||
g.mvaddch(mp.Y, mp.X, tp.OldCh)
|
||||
detachMon(&g.Level.Monsters, tp)
|
||||
g.Level.RemoveMonster(tp)
|
||||
|
||||
if tp.On(Targeted) {
|
||||
g.Kamikaze = false
|
||||
|
||||
g.ToDeath = false
|
||||
if g.Options.FightFlush {
|
||||
g.flushType()
|
||||
@@ -510,6 +668,38 @@ func (g *RogueGame) killed(tp *Monster, pr bool) {
|
||||
p := &g.Player
|
||||
p.Stats.Exp += tp.Stats.Exp
|
||||
|
||||
g.killedSpecial(tp)
|
||||
// Get rid of the monster.
|
||||
mname := g.setMname(tp)
|
||||
g.removeMon(tp.Pos, tp, true)
|
||||
|
||||
if pr {
|
||||
if g.HasHit {
|
||||
g.addmsgf(". Defeated ")
|
||||
g.HasHit = false
|
||||
} else {
|
||||
if !g.Options.Terse {
|
||||
g.addmsgf("you have ")
|
||||
}
|
||||
|
||||
g.addmsgf("defeated ")
|
||||
}
|
||||
|
||||
g.msg("%s", mname)
|
||||
}
|
||||
// Do adjustments if he went up a level
|
||||
g.checkLevel()
|
||||
|
||||
if g.Options.FightFlush {
|
||||
g.flushType()
|
||||
}
|
||||
}
|
||||
|
||||
// killedSpecial handles deaths with side effects: a flytrap releases its
|
||||
// grip and a leprechaun drops its gold (the switch of fight.c killed).
|
||||
func (g *RogueGame) killedSpecial(tp *Monster) {
|
||||
p := &g.Player
|
||||
|
||||
// If the monster was a venus flytrap, un-hold him
|
||||
switch tp.Type {
|
||||
case 'F':
|
||||
@@ -521,36 +711,19 @@ func (g *RogueGame) killed(tp *Monster, pr bool) {
|
||||
if ok {
|
||||
tp.Room.Gold = pos
|
||||
}
|
||||
|
||||
if ok && g.Depth >= g.MaxDepth {
|
||||
gold := newObject()
|
||||
gold.Kind = KindGold
|
||||
|
||||
gold.GoldValue = g.goldCalc()
|
||||
if g.save(VsMagic) {
|
||||
gold.GoldValue += g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
|
||||
}
|
||||
|
||||
attachObj(&tp.Pack, gold)
|
||||
}
|
||||
}
|
||||
// Get rid of the monster.
|
||||
mname := g.setMname(tp)
|
||||
g.removeMon(tp.Pos, tp, true)
|
||||
if pr {
|
||||
if g.HasHit {
|
||||
g.addmsg(". Defeated ")
|
||||
g.HasHit = false
|
||||
} else {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("you have ")
|
||||
}
|
||||
g.addmsg("defeated ")
|
||||
}
|
||||
g.msg("%s", mname)
|
||||
}
|
||||
// Do adjustments if he went up a level
|
||||
g.checkLevel()
|
||||
if g.Options.FightFlush {
|
||||
g.flushType()
|
||||
}
|
||||
}
|
||||
|
||||
// flushType flushes typeahead for the fight_flush option (mach_dep.c
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
@@ -6,10 +7,12 @@ import "testing"
|
||||
// look() state the way playit() does before the first command.
|
||||
func mkGame(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
g := NewGame(Config{Seed: seed, Term: &testTerm{}})
|
||||
|
||||
g := New(Params{Seed: seed, Term: &testTerm{}})
|
||||
g.NewLevel()
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomin(g.Player.Pos)
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
@@ -18,10 +21,13 @@ func spawnAdjacent(g *RogueGame, typ byte) *Monster {
|
||||
pos := Coord{X: g.Player.Pos.X + 1, Y: g.Player.Pos.Y}
|
||||
tp := &Monster{}
|
||||
g.newMonster(tp, typ, pos)
|
||||
|
||||
return tp
|
||||
}
|
||||
|
||||
func TestRollEmParsesMultiAttackDice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 42)
|
||||
att := &Creature{Stats: Stats{Str: 16, Lvl: 20, Dmg: dice("1x4/1x4/1x4")}}
|
||||
def := &Creature{Stats: Stats{ArmorClass: 10, HP: 1000}}
|
||||
@@ -29,9 +35,10 @@ func TestRollEmParsesMultiAttackDice(t *testing.T) {
|
||||
// With attacker level 20 vs armor 10, swing always hits
|
||||
// (rnd(20)+wplus >= (20-20)-10 is always true), so three attacks of
|
||||
// 1x4 + str bonus 1 each must deal between 6 and 15 damage.
|
||||
if !g.rollEm(att, def, nil, false) {
|
||||
if !g.rollAttacks(att, def, nil, false) {
|
||||
t.Fatal("attack with guaranteed swing missed")
|
||||
}
|
||||
|
||||
dmg := 1000 - def.Stats.HP
|
||||
if dmg < 6 || dmg > 15 {
|
||||
t.Errorf("three 1x4+1 attacks dealt %d damage, want 6..15", dmg)
|
||||
@@ -39,73 +46,75 @@ func TestRollEmParsesMultiAttackDice(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFightKillsMonster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 7)
|
||||
tp := spawnAdjacent(g, 'B') // bat: 1 hit die
|
||||
tp.Stats.HP = 1
|
||||
g.Player.Stats.Lvl = 20 // always hits
|
||||
before := len(g.Level.Monsters)
|
||||
g.fight(tp.Pos, g.Player.CurWeapon, false)
|
||||
|
||||
if len(g.Level.Monsters) != before-1 {
|
||||
t.Error("monster not removed after fatal fight")
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(tp.Pos.Y, tp.Pos.X) != nil {
|
||||
t.Error("map still records dead monster")
|
||||
}
|
||||
|
||||
if g.Player.Stats.Exp == 0 {
|
||||
t.Error("no experience for the kill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackHurtsPlayer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 9)
|
||||
tp := spawnAdjacent(g, 'T') // troll: 1x8/1x8/2x6
|
||||
tp.Stats.Lvl = 20 // always hits
|
||||
tp.Flags.Clear(Cancelled)
|
||||
|
||||
hpBefore := g.Player.Stats.HP
|
||||
g.Player.Stats.HP = 500
|
||||
g.Player.Stats.MaxHP = 500
|
||||
g.attack(tp)
|
||||
|
||||
if g.Player.Stats.HP >= 500 {
|
||||
t.Errorf("player HP unchanged (%d -> %d)", hpBefore, g.Player.Stats.HP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeathUnwindsWithGameEnd(t *testing.T) {
|
||||
g := mkGame(t, 11)
|
||||
defer func() {
|
||||
r := recover()
|
||||
if _, ok := r.(gameEnd); !ok {
|
||||
t.Fatalf("death did not unwind with gameEnd, got %v", r)
|
||||
}
|
||||
if g.Playing {
|
||||
t.Error("still playing after death")
|
||||
}
|
||||
}()
|
||||
g.Options.Tombstone = false
|
||||
g.death('K')
|
||||
}
|
||||
|
||||
func TestRunnersChaseHero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 3)
|
||||
// Place a hobgoblin a few squares away in the hero's room and set it
|
||||
// running at the hero.
|
||||
p := &g.Player
|
||||
|
||||
pos := Coord{X: p.Pos.X + 3, Y: p.Pos.Y}
|
||||
if !stepOk(g.Level.Char(pos.Y, pos.X)) || g.Level.MonsterAt(pos.Y, pos.X) != nil {
|
||||
t.Skip("no clear lane on this seed")
|
||||
}
|
||||
|
||||
tp := &Monster{}
|
||||
g.newMonster(tp, 'H', pos)
|
||||
tp.Flags.Set(Awake)
|
||||
tp.Dest = &p.Pos
|
||||
d0 := distCp(tp.Pos, p.Pos)
|
||||
|
||||
g.runners(0)
|
||||
|
||||
if d1 := distCp(tp.Pos, p.Pos); d1 >= d0 {
|
||||
t.Errorf("monster did not close distance: %d -> %d", d0, d1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKilledLeprechaunDropsGoldViaFall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 21)
|
||||
tp := spawnAdjacent(g, 'L')
|
||||
tp.Stats.HP = 0
|
||||
|
||||
179
game/game.go
179
game/game.go
@@ -1,5 +1,8 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ItemLore is the per-game item identity state: the randomized appearance
|
||||
// names and the seven mutable ObjInfo tables (extern.c/init.c).
|
||||
type ItemLore struct {
|
||||
@@ -31,9 +34,9 @@ type Options struct {
|
||||
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
|
||||
}
|
||||
|
||||
// Config carries everything needed to construct a game.
|
||||
type Config struct {
|
||||
Seed int32 // dungeon number; the caller derives it (time+pid or SEED env)
|
||||
// Params carries everything needed to construct a game.
|
||||
type Params struct {
|
||||
Seed int32 // dungeon number; caller derives it (time+pid or SEED)
|
||||
Name string // player name (overridden by ROGUEOPTS name=)
|
||||
RogueOpts string // the ROGUEOPTS environment string
|
||||
Home string // home directory (save file default location)
|
||||
@@ -44,7 +47,7 @@ type Config struct {
|
||||
|
||||
// RogueGame is one complete game of Rogue: every piece of state that was a
|
||||
// global (or file-scope static) in the C sources, plus the terminal it is
|
||||
// played on. Construct with NewGame, then call Run.
|
||||
// played on. Construct with New, then call Run.
|
||||
//
|
||||
// The struct grows with the port; fields appear in the phase that ports the
|
||||
// code owning them.
|
||||
@@ -110,7 +113,7 @@ type RogueGame struct {
|
||||
|
||||
// screen / messages
|
||||
scr *Screen
|
||||
Msgs MsgLine
|
||||
Msgs MessageLine
|
||||
statusCache statusCache
|
||||
invPage invPage // things.c discovery-list pagination statics
|
||||
|
||||
@@ -138,24 +141,84 @@ type RogueGame struct {
|
||||
|
||||
rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C
|
||||
restored bool // game came from a save file; Run skips setup
|
||||
|
||||
// sigSave carries signal-triggered autosave requests from the signal
|
||||
// goroutine to the game goroutine, which is the only one allowed to
|
||||
// touch the state above (issue #24). Buffered by one: the handler
|
||||
// reads exactly one signal, so there is never more than one request.
|
||||
// See AutoSaveOnSignal and serviceAutoSaveRequest in save.go.
|
||||
sigSave chan *autoSaveRequest
|
||||
|
||||
// data is the game's copy of the static tables (extern.c and friends).
|
||||
data *gameData
|
||||
}
|
||||
|
||||
// NewGame builds a game from cfg, seeds the RNG, and randomizes the item
|
||||
// Greeting is the line C printed on stdout while the player waited for
|
||||
// the dungeon to be dug, immediately before initscr() (main.c main). The
|
||||
// caller prints it before the terminal package takes the screen, which is
|
||||
// where initscr() sat; there is no trailing newline in either wording,
|
||||
// because C followed the printf with fflush and let curses have the
|
||||
// display.
|
||||
//
|
||||
// Only the wizard wording is #ifdef MASTER in C, and it carries the
|
||||
// dungeon number, which is the seed (main.c assigns seed = dnum right
|
||||
// after choosing dnum). The other wording is unconditional.
|
||||
//
|
||||
// The name is C's whoami, resolved the way main.c resolves it: parse_opts
|
||||
// runs before the printf, so a ROGUEOPTS "name=" setting is what the
|
||||
// player is greeted by, and the account name is only the fallback. New
|
||||
// does the same parse a moment later; doing it here too is safe because
|
||||
// ParseOpts does nothing but assign into the fields it is handed — no
|
||||
// RNG, no screen — so it cannot disturb the item tables the seed-compat
|
||||
// golden pins.
|
||||
//
|
||||
// The game it parses into is a throwaway, but it is built the way New
|
||||
// builds the real one, because ParseOpts handles every option and not
|
||||
// just the one this function reads: "inven=" is matched against the
|
||||
// inv_t_name[] table and "file=~/..." against the home directory, both
|
||||
// of which live on the game. A greeting that skimped on them faulted on
|
||||
// a perfectly legal ROGUEOPTS before the player saw a single character.
|
||||
func Greeting(params Params) string {
|
||||
whoami := params.Name
|
||||
|
||||
if params.RogueOpts != "" {
|
||||
opts := &RogueGame{
|
||||
data: newGameData(),
|
||||
Whoami: params.Name,
|
||||
Home: params.Home,
|
||||
}
|
||||
opts.ParseOpts(params.RogueOpts)
|
||||
|
||||
whoami = opts.Whoami
|
||||
}
|
||||
|
||||
if params.Wizard {
|
||||
return fmt.Sprintf("Hello %s, welcome to dungeon #%d",
|
||||
whoami, params.Seed)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Hello %s, just a moment while I dig the dungeon...", whoami)
|
||||
}
|
||||
|
||||
// New builds a game from params, seeds the RNG, and randomizes the item
|
||||
// appearance tables (the front half of main.c main(); the player roll-up
|
||||
// and first level arrive with later porting phases).
|
||||
func NewGame(cfg Config) *RogueGame {
|
||||
func New(params Params) *RogueGame {
|
||||
g := &RogueGame{
|
||||
Rng: &Rng{Seed: cfg.Seed},
|
||||
Dnum: int(cfg.Seed),
|
||||
Whoami: cfg.Name,
|
||||
data: newGameData(),
|
||||
Rng: &Rng{Seed: params.Seed},
|
||||
Dnum: int(params.Seed),
|
||||
Whoami: params.Name,
|
||||
Fruit: "slime-mold",
|
||||
Home: cfg.Home,
|
||||
Wizard: cfg.Wizard,
|
||||
NoScore: cfg.Wizard,
|
||||
Home: params.Home,
|
||||
Wizard: params.Wizard,
|
||||
NoScore: params.Wizard,
|
||||
Playing: true,
|
||||
Depth: 1,
|
||||
ScorePath: cfg.ScorePath,
|
||||
ScorePath: params.ScorePath,
|
||||
LastScore: -1,
|
||||
sigSave: make(chan *autoSaveRequest, 1),
|
||||
}
|
||||
g.Options = Options{
|
||||
SeeFloor: true,
|
||||
@@ -164,17 +227,21 @@ func NewGame(cfg Config) *RogueGame {
|
||||
}
|
||||
g.InvDescribe = true
|
||||
g.Msgs.SaveMsg = true
|
||||
g.scr = NewScreen(cfg.Term)
|
||||
g.FileName = cfg.Home + "/rogue.save"
|
||||
g.rogueOpts = cfg.RogueOpts
|
||||
if cfg.Wizard {
|
||||
g.scr = NewScreen(params.Term)
|
||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||
g.FileName = params.Home + "/rogue.save"
|
||||
|
||||
g.rogueOpts = params.RogueOpts
|
||||
if params.Wizard {
|
||||
g.Player.Flags.Set(SenseMonsters)
|
||||
}
|
||||
if cfg.RogueOpts != "" {
|
||||
g.ParseOpts(cfg.RogueOpts)
|
||||
|
||||
if params.RogueOpts != "" {
|
||||
g.ParseOpts(params.RogueOpts)
|
||||
}
|
||||
|
||||
g.Monsters = monsterTable
|
||||
g.Monsters = g.data.monsterTable
|
||||
|
||||
g.Items.Group = 2 // weapons.c: int group = 2
|
||||
for i := range g.Level.Passages {
|
||||
g.Level.Passages[i].Flags = Gone | Dark
|
||||
@@ -186,34 +253,50 @@ func NewGame(cfg Config) *RogueGame {
|
||||
g.initColors() // set up colors of potions
|
||||
g.initStones() // set up stone settings of rings
|
||||
g.initMaterials() // set up materials of wands
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// Run plays the game to its end: the back half of main.c main() plus
|
||||
// playit(). It returns after death, victory, quitting, or saving.
|
||||
func (g *RogueGame) Run() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(gameEnd); ok {
|
||||
return // normal game over / save exit
|
||||
}
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
if !g.restored {
|
||||
g.NewLevel() // draw current level
|
||||
// Start up daemons and fuses
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
}
|
||||
// playit(). It does not return — the game ends by exiting the process
|
||||
// (see myExit); one game run is one process.
|
||||
func (g *RogueGame) Run() {
|
||||
g.startLevel()
|
||||
g.playit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// startLevel draws the first level and starts the standing daemons and
|
||||
// fuses for a fresh game; a restored game brings its own (the back half
|
||||
// of main.c main()).
|
||||
func (g *RogueGame) startLevel() {
|
||||
if g.restored {
|
||||
return
|
||||
}
|
||||
|
||||
g.NewLevel() // draw current level
|
||||
// Start up daemons and fuses
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
}
|
||||
|
||||
// playit is the main loop of the program (main.c playit).
|
||||
func (g *RogueGame) playit() {
|
||||
g.prePlay()
|
||||
|
||||
for g.Playing {
|
||||
g.command() // command execution
|
||||
}
|
||||
|
||||
g.endit()
|
||||
}
|
||||
|
||||
// prePlay does the option and position setup at the top of playit,
|
||||
// before the command loop (main.c playit). It is split out so tests can
|
||||
// drive a bounded number of turns; the loop itself never returns,
|
||||
// because game-over exits the process.
|
||||
func (g *RogueGame) prePlay() {
|
||||
// set up defaults for modern terminals: curses' md_hasclreol() is
|
||||
// always true, so the C default inventory style applies
|
||||
if !g.restored {
|
||||
@@ -226,11 +309,7 @@ func (g *RogueGame) playit() {
|
||||
}
|
||||
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomin(g.Player.Pos)
|
||||
for g.Playing {
|
||||
g.command() // command execution
|
||||
}
|
||||
g.endit()
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
}
|
||||
|
||||
// endit exits the game (main.c endit).
|
||||
@@ -242,7 +321,7 @@ func (g *RogueGame) endit() {
|
||||
func (g *RogueGame) fatal(s string) {
|
||||
g.mvaddstr(NumLines-2, 0, s)
|
||||
g.refresh()
|
||||
g.myExit(0)
|
||||
g.myExit()
|
||||
}
|
||||
|
||||
// quit has the player make certain, then exits (main.c quit). The final
|
||||
@@ -252,17 +331,21 @@ func (g *RogueGame) quit(int) {
|
||||
if !g.QComm {
|
||||
g.Msgs.Mpos = 0
|
||||
}
|
||||
|
||||
oy, ox := g.scr.Std.GetYX()
|
||||
g.msg("really quit?")
|
||||
|
||||
if g.readchar() == 'y' {
|
||||
g.clear()
|
||||
g.scr.Std.MvPrintw(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse)
|
||||
g.scr.Std.MvPrintwf(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse)
|
||||
g.move(NumLines-1, 0)
|
||||
g.refresh()
|
||||
g.score(g.Player.Purse, 1, 0)
|
||||
g.myExit(0)
|
||||
g.myExit()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
g.move(0, 0)
|
||||
g.clrtoeol()
|
||||
g.status()
|
||||
|
||||
80
game/greeting_test.go
Normal file
80
game/greeting_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGreeting pins both wordings of main.c's pre-initscr printf byte for
|
||||
// byte. The wizard one carries dnum, which main.c has just assigned to
|
||||
// seed, so it is the seed the player sees. Neither ends in a newline: C
|
||||
// printed, flushed, and handed the display to curses.
|
||||
func TestGreeting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The account name main.c copies into whoami when ROGUEOPTS does not
|
||||
// name the player itself.
|
||||
const account = "conan"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
params Params
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
params: Params{Name: account, Seed: 4242},
|
||||
want: "Hello conan, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
name: "wizard names the dungeon",
|
||||
params: Params{Name: account, Seed: 4242, Wizard: true},
|
||||
want: "Hello conan, welcome to dungeon #4242",
|
||||
},
|
||||
{
|
||||
// parse_opts runs before the printf in main.c, and whoami
|
||||
// falls back to the account name only when ROGUEOPTS left it
|
||||
// empty, so the option is what the player is greeted by.
|
||||
name: "ROGUEOPTS name wins over the account name",
|
||||
params: Params{
|
||||
Name: account, Seed: 7, RogueOpts: "name=Rodney",
|
||||
},
|
||||
want: "Hello Rodney, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
name: "ROGUEOPTS without a name keeps the account name",
|
||||
params: Params{
|
||||
Name: account, Seed: 7, RogueOpts: "terse,fruit=mango",
|
||||
},
|
||||
want: "Hello conan, just a moment while I dig the dungeon...",
|
||||
},
|
||||
{
|
||||
// ParseOpts reaches every option, not just name=, and the
|
||||
// inventory style is matched against a table (options.c
|
||||
// parse_opts, inv_t_name[]) that lives in the game data. A
|
||||
// greeting parsed on a game without those tables faulted on
|
||||
// this ROGUEOPTS before it could print anything at all.
|
||||
name: "ROGUEOPTS inventory style parses without a fault",
|
||||
params: Params{
|
||||
Name: account, Seed: 7, RogueOpts: "inven=slow,name=Rodney",
|
||||
},
|
||||
want: "Hello Rodney, just a moment while I dig the dungeon...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Greeting(tc.params)
|
||||
if got != tc.want {
|
||||
t.Errorf("Greeting() = %q, want %q", got, tc.want)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(got, "\n") {
|
||||
t.Error("greeting ends in a newline; C's printf did not")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
78
game/init.go
78
game/init.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "strings"
|
||||
@@ -8,7 +9,7 @@ import "strings"
|
||||
// initPlayer rolls her up (init.c init_player).
|
||||
func (g *RogueGame) initPlayer() {
|
||||
p := &g.Player
|
||||
p.MaxStats = initStats
|
||||
p.MaxStats = g.data.initStats
|
||||
p.Stats = p.MaxStats
|
||||
p.FoodLeft = HungerTime
|
||||
// Give him some food
|
||||
@@ -20,7 +21,7 @@ func (g *RogueGame) initPlayer() {
|
||||
obj = newObject()
|
||||
obj.Kind = KindArmor
|
||||
obj.Which = int(ArmorRingMail)
|
||||
obj.ArmorClass = aClass[ArmorRingMail] - 1
|
||||
obj.ArmorClass = g.data.aClass[ArmorRingMail] - 1
|
||||
obj.Flags.Set(Known)
|
||||
obj.Count = 1
|
||||
p.CurArmor = obj
|
||||
@@ -50,36 +51,42 @@ func (g *RogueGame) initPlayer() {
|
||||
// initColors initializes the potion color scheme for this game
|
||||
// (init.c init_colors).
|
||||
func (g *RogueGame) initColors() {
|
||||
used := make([]bool, len(rainbow))
|
||||
for i := PotionKind(0); i < NumPotionTypes; i++ {
|
||||
used := make([]bool, len(g.data.rainbow))
|
||||
|
||||
for i := range NumPotionTypes {
|
||||
var j int
|
||||
for {
|
||||
j = g.rnd(len(rainbow))
|
||||
j = g.rnd(len(g.data.rainbow))
|
||||
if !used[j] {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
used[j] = true
|
||||
g.Items.PotColors[i] = rainbow[j]
|
||||
g.Items.PotColors[i] = g.data.rainbow[j]
|
||||
}
|
||||
}
|
||||
|
||||
// initNames generates the names of the various scrolls (init.c init_names).
|
||||
func (g *RogueGame) initNames() {
|
||||
for i := ScrollKind(0); i < NumScrollTypes; i++ {
|
||||
for i := range NumScrollTypes {
|
||||
var cp strings.Builder
|
||||
|
||||
nwords := g.rnd(3) + 2
|
||||
for ; nwords > 0; nwords-- {
|
||||
nsyl := g.rnd(3) + 1
|
||||
for ; nsyl > 0; nsyl-- {
|
||||
sp := sylls[g.rnd(len(sylls))]
|
||||
sp := g.data.sylls[g.rnd(len(g.data.sylls))]
|
||||
if cp.Len()+len(sp) > MaxNameLen {
|
||||
break
|
||||
}
|
||||
|
||||
cp.WriteString(sp)
|
||||
}
|
||||
|
||||
cp.WriteByte(' ')
|
||||
}
|
||||
|
||||
g.Items.ScrNames[i] = strings.TrimSuffix(cp.String(), " ")
|
||||
}
|
||||
}
|
||||
@@ -87,47 +94,54 @@ func (g *RogueGame) initNames() {
|
||||
// initStones initializes the ring stone setting scheme for this game
|
||||
// (init.c init_stones).
|
||||
func (g *RogueGame) initStones() {
|
||||
used := make([]bool, len(stoneTable))
|
||||
for i := RingKind(0); i < NumRingTypes; i++ {
|
||||
used := make([]bool, len(g.data.stoneTable))
|
||||
|
||||
for i := range NumRingTypes {
|
||||
var j int
|
||||
for {
|
||||
j = g.rnd(len(stoneTable))
|
||||
j = g.rnd(len(g.data.stoneTable))
|
||||
if !used[j] {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
used[j] = true
|
||||
g.Items.RingStones[i] = stoneTable[j].Name
|
||||
g.Items.Rings[i].Worth += stoneTable[j].Value
|
||||
g.Items.RingStones[i] = g.data.stoneTable[j].Name
|
||||
g.Items.Rings[i].Worth += g.data.stoneTable[j].Value
|
||||
}
|
||||
}
|
||||
|
||||
// initMaterials initializes the construction materials for wands and staffs
|
||||
// (init.c init_materials).
|
||||
func (g *RogueGame) initMaterials() {
|
||||
used := make([]bool, len(woods))
|
||||
metused := make([]bool, len(metals))
|
||||
for i := WandKind(0); i < NumWandTypes; i++ {
|
||||
used := make([]bool, len(g.data.woods))
|
||||
metused := make([]bool, len(g.data.metals))
|
||||
|
||||
for i := range NumWandTypes {
|
||||
var str string
|
||||
|
||||
for {
|
||||
if g.rnd(2) == 0 {
|
||||
j := g.rnd(len(metals))
|
||||
j := g.rnd(len(g.data.metals))
|
||||
if !metused[j] {
|
||||
g.Items.WandType[i] = "wand"
|
||||
str = metals[j]
|
||||
g.Items.WandType[i] = wandName
|
||||
str = g.data.metals[j]
|
||||
metused[j] = true
|
||||
|
||||
break
|
||||
}
|
||||
} else {
|
||||
j := g.rnd(len(woods))
|
||||
j := g.rnd(len(g.data.woods))
|
||||
if !used[j] {
|
||||
g.Items.WandType[i] = "staff"
|
||||
str = woods[j]
|
||||
g.Items.WandType[i] = staffName
|
||||
str = g.data.woods[j]
|
||||
used[j] = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.Items.WandMade[i] = str
|
||||
}
|
||||
}
|
||||
@@ -143,20 +157,21 @@ func sumProbs(info []ObjInfo) {
|
||||
// initProbs copies the base tables into the game and initializes the
|
||||
// probabilities for the various items (init.c init_probs).
|
||||
func (g *RogueGame) initProbs() {
|
||||
g.Items.Things = baseThings
|
||||
g.Items.Potions = basePotInfo
|
||||
g.Items.Scrolls = baseScrInfo
|
||||
g.Items.Rings = baseRingInfo
|
||||
g.Items.Sticks = baseWsInfo
|
||||
g.Items.Weapons = baseWeapInfo
|
||||
g.Items.Armors = baseArmInfo
|
||||
g.Items.Things = g.data.baseThings
|
||||
g.Items.Potions = g.data.basePotInfo
|
||||
g.Items.Scrolls = g.data.baseScrInfo
|
||||
g.Items.Rings = g.data.baseRingInfo
|
||||
g.Items.Sticks = g.data.baseWsInfo
|
||||
g.Items.Weapons = g.data.baseWeapInfo
|
||||
g.Items.Armors = g.data.baseArmInfo
|
||||
|
||||
sumProbs(g.Items.Things[:])
|
||||
sumProbs(g.Items.Potions[:])
|
||||
sumProbs(g.Items.Scrolls[:])
|
||||
sumProbs(g.Items.Rings[:])
|
||||
sumProbs(g.Items.Sticks[:])
|
||||
sumProbs(g.Items.Weapons[:NumWeaponTypes]) // C sums MAXWEAPONS, excluding the flame entry
|
||||
// C sums MAXWEAPONS, excluding the flame entry.
|
||||
sumProbs(g.Items.Weapons[:NumWeaponTypes])
|
||||
sumProbs(g.Items.Armors[:])
|
||||
}
|
||||
|
||||
@@ -164,7 +179,8 @@ func (g *RogueGame) initProbs() {
|
||||
// hallucinating (init.c pick_color).
|
||||
func (g *RogueGame) pickColor(col string) string {
|
||||
if g.Player.On(Hallucinating) {
|
||||
return rainbow[g.rnd(len(rainbow))]
|
||||
return g.data.rainbow[g.rnd(len(g.data.rainbow))]
|
||||
}
|
||||
|
||||
return col
|
||||
}
|
||||
|
||||
215
game/io.go
215
game/io.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -10,9 +11,11 @@ import (
|
||||
// maxMsg is io.c MAXMSG: how much message fits before --More--.
|
||||
const maxMsg = NumCols - len("--More--") - 1
|
||||
|
||||
// MsgLine is the io.c message machinery: the static msgbuf/newpos pair plus
|
||||
// the related globals (mpos, huh, and the message-behavior flags).
|
||||
type MsgLine struct {
|
||||
// MessageLine is the io.c message machinery: the static msgbuf/newpos
|
||||
// pair plus the related globals (mpos, huh, and the message-behavior
|
||||
// flags). It owns the top line of the screen; attach wires in the
|
||||
// display and input it needs.
|
||||
type MessageLine struct {
|
||||
buf strings.Builder // msgbuf
|
||||
newpos int
|
||||
Mpos int // where cursor is on top line
|
||||
@@ -20,84 +23,137 @@ type MsgLine struct {
|
||||
SaveMsg bool // remember last msg
|
||||
LowerMsg bool // messages should start w/lower case
|
||||
MsgEsc bool // check for ESC from msg's --More--
|
||||
|
||||
scr *Screen // the top line lives on scr.Std
|
||||
look func(wakeup bool) // redraw before a --More-- (misc.c look)
|
||||
readChar func() byte // input for --More-- prompts
|
||||
}
|
||||
|
||||
// Msg displays a message at the top of the screen (io.c msg). It returns
|
||||
// Escape if the player escaped out of a --More--, ^Escape otherwise (the C
|
||||
// convention: callers compare against ESCAPE).
|
||||
func (g *RogueGame) msg(format string, a ...any) int {
|
||||
// Escape if the player escaped out of a --More--, ^Escape otherwise (the
|
||||
// C convention: callers compare against ESCAPE).
|
||||
func (m *MessageLine) Msg(format string, a ...any) int {
|
||||
// if the string is "", just clear the line
|
||||
if format == "" {
|
||||
g.move(0, 0)
|
||||
g.clrtoeol()
|
||||
g.Msgs.Mpos = 0
|
||||
m.scr.Std.Move(0, 0)
|
||||
m.scr.Std.Clrtoeol()
|
||||
m.Mpos = 0
|
||||
|
||||
return ^Escape
|
||||
}
|
||||
// otherwise add to the message and flush it out
|
||||
g.doadd(format, a...)
|
||||
return g.endmsg()
|
||||
m.doaddf(format, a...)
|
||||
|
||||
return m.End()
|
||||
}
|
||||
|
||||
// addmsg adds things to the current message (io.c addmsg).
|
||||
func (g *RogueGame) addmsg(format string, a ...any) {
|
||||
g.doadd(format, a...)
|
||||
// Addf adds things to the current message (io.c addmsg).
|
||||
func (m *MessageLine) Addf(format string, a ...any) {
|
||||
m.doaddf(format, a...)
|
||||
}
|
||||
|
||||
// endmsg displays a new msg, giving the player a chance to see the previous
|
||||
// End displays a new msg, giving the player a chance to see the previous
|
||||
// one if it is up there with the --More-- (io.c endmsg).
|
||||
func (g *RogueGame) endmsg() int {
|
||||
m := &g.Msgs
|
||||
func (m *MessageLine) End() int {
|
||||
if m.SaveMsg {
|
||||
m.Huh = m.buf.String()
|
||||
}
|
||||
if m.Mpos != 0 {
|
||||
g.look(false)
|
||||
g.mvaddstr(0, m.Mpos, "--More--")
|
||||
g.refresh()
|
||||
if !m.MsgEsc {
|
||||
g.waitFor(' ')
|
||||
} else {
|
||||
for {
|
||||
ch := g.readchar()
|
||||
if ch == ' ' {
|
||||
break
|
||||
}
|
||||
if ch == Escape {
|
||||
m.buf.Reset()
|
||||
m.Mpos = 0
|
||||
m.newpos = 0
|
||||
return Escape
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.Mpos != 0 && m.promptMore() == Escape {
|
||||
return Escape
|
||||
}
|
||||
// All messages should start with uppercase, except ones that start
|
||||
// with a pack addressing character
|
||||
out := m.buf.String()
|
||||
if len(out) > 0 && isLower(out[0]) && !m.LowerMsg &&
|
||||
!(len(out) > 1 && out[1] == ')') {
|
||||
(len(out) <= 1 || out[1] != ')') {
|
||||
out = string(toUpper(out[0])) + out[1:]
|
||||
}
|
||||
g.mvaddstr(0, 0, out)
|
||||
g.clrtoeol()
|
||||
|
||||
m.scr.Std.MvAddStr(0, 0, out)
|
||||
m.scr.Std.Clrtoeol()
|
||||
|
||||
m.Mpos = m.newpos
|
||||
m.newpos = 0
|
||||
m.buf.Reset()
|
||||
g.refresh()
|
||||
m.scr.Refresh()
|
||||
|
||||
return ^Escape
|
||||
}
|
||||
|
||||
// doadd performs an add onto the message buffer (io.c doadd).
|
||||
func (g *RogueGame) doadd(format string, a ...any) {
|
||||
m := &g.Msgs
|
||||
// promptMore shows the --More-- prompt and waits for the reader to
|
||||
// acknowledge; Escape means the player bailed out (the Mpos block of
|
||||
// io.c endmsg).
|
||||
func (m *MessageLine) promptMore() int {
|
||||
m.look(false)
|
||||
m.scr.Std.MvAddStr(0, m.Mpos, "--More--")
|
||||
m.scr.Refresh()
|
||||
|
||||
if !m.MsgEsc {
|
||||
m.waitForSpace()
|
||||
|
||||
return ^Escape
|
||||
}
|
||||
|
||||
for {
|
||||
ch := m.readChar()
|
||||
if ch == ' ' {
|
||||
return ^Escape
|
||||
}
|
||||
|
||||
if ch == Escape {
|
||||
m.buf.Reset()
|
||||
m.Mpos = 0
|
||||
m.newpos = 0
|
||||
|
||||
return Escape
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attach wires the message line to its display and input; NewGame and
|
||||
// Restore call it once the screen and game exist.
|
||||
func (m *MessageLine) attach(scr *Screen, look func(bool), readChar func() byte) {
|
||||
m.scr = scr
|
||||
m.look = look
|
||||
m.readChar = readChar
|
||||
}
|
||||
|
||||
// waitForSpace absorbs input until the player types a space: the
|
||||
// --More-- acknowledgement (io.c wait_for).
|
||||
func (m *MessageLine) waitForSpace() {
|
||||
for {
|
||||
if m.readChar() == ' ' {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// doaddf performs an add onto the message buffer (io.c doadd).
|
||||
func (m *MessageLine) doaddf(format string, a ...any) {
|
||||
s := fmt.Sprintf(format, a...)
|
||||
if len(s)+m.newpos >= maxMsg {
|
||||
g.endmsg()
|
||||
m.End()
|
||||
}
|
||||
|
||||
m.buf.WriteString(s)
|
||||
m.newpos = m.buf.Len()
|
||||
}
|
||||
|
||||
// msg, addmsgf, and endmsg are the game-side shorthands for the message
|
||||
// line; the machinery lives on MessageLine.
|
||||
func (g *RogueGame) msg(format string, a ...any) int {
|
||||
return g.Msgs.Msg(format, a...)
|
||||
}
|
||||
|
||||
func (g *RogueGame) addmsgf(format string, a ...any) {
|
||||
g.Msgs.Addf(format, a...)
|
||||
}
|
||||
|
||||
func (g *RogueGame) endmsg() {
|
||||
g.Msgs.End()
|
||||
}
|
||||
|
||||
// stepOk returns true if it is ok to step on ch (io.c step_ok).
|
||||
func stepOk(ch byte) bool {
|
||||
switch ch {
|
||||
@@ -110,13 +166,39 @@ func stepOk(ch byte) bool {
|
||||
|
||||
// readchar reads and returns a character, checking for gross input errors
|
||||
// (io.c readchar).
|
||||
//
|
||||
// Waiting for a key is where the game spends nearly all of its wall
|
||||
// clock, so it is also where a signal-triggered autosave usually finds
|
||||
// it: a dropped connection lands while the player is thinking, not
|
||||
// mid-turn. Terminal.Interrupt wakes the read for exactly that, and the
|
||||
// save runs here, on the game goroutine, before reading again.
|
||||
//
|
||||
// What that buys is a snapshot taken by the goroutine that owns the
|
||||
// state, so it is internally consistent and restorable. It is never a
|
||||
// between-commands snapshot: readchar is reached from readCommand at the
|
||||
// top of a turn that has already run its BEFORE daemons and turnUpkeep,
|
||||
// and from prompts raised part-way through a command — --More--,
|
||||
// askOverwrite, getStr, the direction and pack prompts — by which point
|
||||
// the command has mutated state as well. See serviceAutoSaveRequest
|
||||
// (save.go) for the full statement of what the handoff guarantees and
|
||||
// what it costs the player.
|
||||
func (g *RogueGame) readchar() byte {
|
||||
ch := g.scr.term.ReadChar()
|
||||
if ch == 3 { // ^C
|
||||
g.quit(0)
|
||||
return 27
|
||||
for {
|
||||
ch, ok := g.scr.term.ReadChar()
|
||||
if !ok {
|
||||
g.serviceAutoSaveRequest()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == 3 { // ^C
|
||||
g.quit(0)
|
||||
|
||||
return 27
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// statusCache is the set of static shadow variables in io.c status() that
|
||||
@@ -133,8 +215,6 @@ type statusCache struct {
|
||||
init bool
|
||||
}
|
||||
|
||||
var hungerStateName = [...]string{"", "Hungry", "Weak", "Faint"}
|
||||
|
||||
// status displays the important stats line, keeping the cursor where it was
|
||||
// (io.c status).
|
||||
func (g *RogueGame) status() {
|
||||
@@ -146,17 +226,19 @@ func (g *RogueGame) status() {
|
||||
if p.CurArmor != nil {
|
||||
temp = p.CurArmor.ArmorClass
|
||||
}
|
||||
if s.init && s.hp == p.Stats.HP && s.exp == p.Stats.Exp &&
|
||||
s.pur == p.Purse && s.arm == temp && s.str == p.Stats.Str &&
|
||||
s.lvl == g.Depth && s.hungry == p.HungryState && !g.StatMsg {
|
||||
|
||||
if g.statusUnchanged(temp) {
|
||||
return
|
||||
}
|
||||
|
||||
s.init = true
|
||||
s.arm = temp
|
||||
|
||||
oy, ox := g.scr.Std.GetYX()
|
||||
|
||||
if s.hp != p.Stats.MaxHP {
|
||||
s.hp = p.Stats.MaxHP
|
||||
|
||||
s.hpwidth = 0
|
||||
for t := p.Stats.MaxHP; t != 0; t /= 10 {
|
||||
s.hpwidth++
|
||||
@@ -175,7 +257,7 @@ func (g *RogueGame) status() {
|
||||
"Level: %d Gold: %-5d Hp: %*d(%*d) Str: %2d(%d) Arm: %-2d Exp: %d/%d %s",
|
||||
g.Depth, p.Purse, s.hpwidth, p.Stats.HP, s.hpwidth, p.Stats.MaxHP,
|
||||
p.Stats.Str, p.MaxStats.Str, 10-s.arm, p.Stats.Lvl, p.Stats.Exp,
|
||||
hungerStateName[p.HungryState])
|
||||
g.data.hungerStateName[p.HungryState])
|
||||
if g.StatMsg {
|
||||
g.move(0, 0)
|
||||
g.msg("%s", line)
|
||||
@@ -183,10 +265,23 @@ func (g *RogueGame) status() {
|
||||
g.move(StatLine, 0)
|
||||
g.addstr(line)
|
||||
}
|
||||
|
||||
g.clrtoeol()
|
||||
g.move(oy, ox)
|
||||
}
|
||||
|
||||
// statusUnchanged reports whether the status line still shows current
|
||||
// values, so it need not be redrawn (the shadow-variable check of io.c
|
||||
// status). temp is the effective armor class.
|
||||
func (g *RogueGame) statusUnchanged(temp int) bool {
|
||||
s := &g.statusCache
|
||||
p := &g.Player
|
||||
|
||||
return s.init && s.hp == p.Stats.HP && s.exp == p.Stats.Exp &&
|
||||
s.pur == p.Purse && s.arm == temp && s.str == p.Stats.Str &&
|
||||
s.lvl == g.Depth && s.hungry == p.HungryState && !g.StatMsg
|
||||
}
|
||||
|
||||
// waitFor sits around until the guy types the right key (io.c wait_for).
|
||||
func (g *RogueGame) waitFor(ch byte) {
|
||||
if ch == '\n' {
|
||||
@@ -197,7 +292,11 @@ func (g *RogueGame) waitFor(ch byte) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for g.readchar() != ch {
|
||||
|
||||
for {
|
||||
if g.readchar() == ch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,11 +320,13 @@ func toUpper(c byte) byte {
|
||||
if isLower(c) {
|
||||
return c - 'a' + 'A'
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
func toLower(c byte) byte {
|
||||
if isUpper(c) {
|
||||
return c - 'A' + 'a'
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// Place describes a spot on the level map (rogue.h PLACE).
|
||||
@@ -47,9 +48,33 @@ func (l *Level) VisibleChar(y, x int) byte {
|
||||
if m := l.MonsterAt(y, x); m != nil {
|
||||
return m.Disguise
|
||||
}
|
||||
|
||||
return l.Char(y, x)
|
||||
}
|
||||
|
||||
// ObjectAt finds the unclaimed object at (y, x) (misc.c find_obj).
|
||||
func (l *Level) ObjectAt(y, x int) *Object {
|
||||
for _, obj := range l.Objects {
|
||||
if obj.Pos.Y == y && obj.Pos.X == x {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddObject puts an object on the level (list.c attach on lvl_obj).
|
||||
func (l *Level) AddObject(obj *Object) { attachObj(&l.Objects, obj) }
|
||||
|
||||
// RemoveObject takes an object off the level (list.c detach on lvl_obj).
|
||||
func (l *Level) RemoveObject(obj *Object) { detachObj(&l.Objects, obj) }
|
||||
|
||||
// AddMonster puts a monster on the level (list.c attach on mlist).
|
||||
func (l *Level) AddMonster(m *Monster) { attachMon(&l.Monsters, m) }
|
||||
|
||||
// RemoveMonster takes a monster off the level (list.c detach on mlist).
|
||||
func (l *Level) RemoveMonster(m *Monster) { detachMon(&l.Monsters, m) }
|
||||
|
||||
// goldCalc is the GOLDCALC macro: how much a gold pile is worth at depth.
|
||||
func (g *RogueGame) goldCalc() int {
|
||||
return g.rnd(50+10*g.Depth) + 2
|
||||
|
||||
535
game/misc.go
535
game/misc.go
@@ -1,158 +1,255 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// misc.c — look() display maintenance, direction input, eating, level-ups,
|
||||
// and small utilities. call_it arrives with the scroll/potion phase (it
|
||||
// needs the get_str line editor).
|
||||
|
||||
// lookScan carries the state of one look() glance while it examines the
|
||||
// nine squares around the hero.
|
||||
type lookScan struct {
|
||||
hero Coord
|
||||
pch byte // map character under the hero
|
||||
pfl PlaceFlags // map flags under the hero
|
||||
wakeup bool
|
||||
doorStop bool // door-stop checking applies (mid-run)
|
||||
sy, sx, ey, ex int
|
||||
sumhero, diffhero int
|
||||
passcount int
|
||||
}
|
||||
|
||||
// look takes a quick glance all around the player (misc.c look).
|
||||
func (g *RogueGame) look(wakeup bool) {
|
||||
p := &g.Player
|
||||
hero := p.Pos
|
||||
passcount := 0
|
||||
rp := p.Room
|
||||
|
||||
if g.Oldpos != hero {
|
||||
g.eraseLamp(g.Oldpos, g.Oldrp)
|
||||
g.Oldpos = hero
|
||||
g.Oldrp = rp
|
||||
}
|
||||
ey := hero.Y + 1
|
||||
ex := hero.X + 1
|
||||
sx := hero.X - 1
|
||||
sy := hero.Y - 1
|
||||
sumhero, diffhero := 0, 0
|
||||
if g.DoorStop && !g.Firstmove && g.Running {
|
||||
sumhero = hero.Y + hero.X
|
||||
diffhero = hero.Y - hero.X
|
||||
|
||||
s := lookScan{
|
||||
hero: hero,
|
||||
wakeup: wakeup,
|
||||
sy: hero.Y - 1,
|
||||
sx: hero.X - 1,
|
||||
ey: hero.Y + 1,
|
||||
ex: hero.X + 1,
|
||||
}
|
||||
|
||||
s.doorStop = g.DoorStop && !g.Firstmove
|
||||
if s.doorStop && g.Running {
|
||||
s.sumhero = hero.Y + hero.X
|
||||
s.diffhero = hero.Y - hero.X
|
||||
}
|
||||
|
||||
pp := g.Level.At(hero.Y, hero.X)
|
||||
pch := pp.Ch
|
||||
pfl := pp.Flags
|
||||
s.pch = pp.Ch
|
||||
s.pfl = pp.Flags
|
||||
|
||||
for y := sy; y <= ey; y++ {
|
||||
if y <= 0 || y >= NumLines-1 {
|
||||
continue
|
||||
}
|
||||
for x := sx; x <= ex; x++ {
|
||||
if x < 0 || x >= NumCols {
|
||||
continue
|
||||
}
|
||||
if !p.On(Blind) {
|
||||
if y == hero.Y && x == hero.X {
|
||||
continue
|
||||
}
|
||||
}
|
||||
g.lookAround(&s)
|
||||
|
||||
pp := g.Level.At(y, x)
|
||||
ch := pp.Ch
|
||||
if ch == ' ' { // nothing need be done with a ' '
|
||||
continue
|
||||
}
|
||||
fp := &pp.Flags
|
||||
if pch != Door && ch != Door {
|
||||
if (pfl & FPassage) != (*fp & FPassage) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (fp.Has(FPassage) || ch == Door) && (pfl.Has(FPassage) || pch == Door) {
|
||||
if hero.X != x && hero.Y != y &&
|
||||
!stepOk(g.Level.Char(y, hero.X)) && !stepOk(g.Level.Char(hero.Y, x)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
tp := pp.Monst
|
||||
if tp == nil {
|
||||
ch = g.tripCh(y, x, ch)
|
||||
} else if p.On(SenseMonsters) && tp.On(Invisible) {
|
||||
if g.DoorStop && !g.Firstmove {
|
||||
g.Running = false
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
if wakeup {
|
||||
g.wakeMonster(y, x)
|
||||
}
|
||||
if g.seeMonst(tp) {
|
||||
if p.On(Hallucinating) {
|
||||
ch = byte(g.rnd(26) + 'A')
|
||||
} else {
|
||||
ch = tp.Disguise
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.On(Blind) && (y != hero.Y || x != hero.X) {
|
||||
continue
|
||||
}
|
||||
|
||||
g.move(y, x)
|
||||
|
||||
if p.Room.Flags.Has(Dark) && !g.Options.SeeFloor && ch == Floor {
|
||||
ch = ' '
|
||||
}
|
||||
|
||||
if tp != nil || ch != g.inch() {
|
||||
g.addch(ch)
|
||||
}
|
||||
|
||||
if g.DoorStop && !g.Firstmove && g.Running {
|
||||
switch g.RunCh {
|
||||
case 'h':
|
||||
if x == ex {
|
||||
continue
|
||||
}
|
||||
case 'j':
|
||||
if y == sy {
|
||||
continue
|
||||
}
|
||||
case 'k':
|
||||
if y == ey {
|
||||
continue
|
||||
}
|
||||
case 'l':
|
||||
if x == sx {
|
||||
continue
|
||||
}
|
||||
case 'y':
|
||||
if (y+x)-sumhero >= 1 {
|
||||
continue
|
||||
}
|
||||
case 'u':
|
||||
if (y-x)-diffhero >= 1 {
|
||||
continue
|
||||
}
|
||||
case 'n':
|
||||
if (y+x)-sumhero <= -1 {
|
||||
continue
|
||||
}
|
||||
case 'b':
|
||||
if (y-x)-diffhero <= -1 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch ch {
|
||||
case Door:
|
||||
if x == hero.X || y == hero.Y {
|
||||
g.Running = false
|
||||
}
|
||||
case Passage:
|
||||
if x == hero.X || y == hero.Y {
|
||||
passcount++
|
||||
}
|
||||
case Floor, '|', '-', ' ':
|
||||
default:
|
||||
g.Running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.DoorStop && !g.Firstmove && passcount > 1 {
|
||||
if s.doorStop && s.passcount > 1 {
|
||||
g.Running = false
|
||||
}
|
||||
|
||||
if !g.Running || !g.Options.Jump {
|
||||
g.mvaddch(hero.Y, hero.X, PlayerCh)
|
||||
}
|
||||
}
|
||||
|
||||
// lookAround runs the nine-square scan of look().
|
||||
func (g *RogueGame) lookAround(s *lookScan) {
|
||||
for y := s.sy; y <= s.ey; y++ {
|
||||
if y <= 0 || y >= NumLines-1 {
|
||||
continue
|
||||
}
|
||||
|
||||
for x := s.sx; x <= s.ex; x++ {
|
||||
if x < 0 || x >= NumCols {
|
||||
continue
|
||||
}
|
||||
|
||||
g.lookCell(s, y, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lookCell examines one square around the hero: visibility rules, trip
|
||||
// and monster rendering, drawing, and run-stop checks (the loop body of
|
||||
// misc.c look).
|
||||
func (g *RogueGame) lookCell(s *lookScan, y, x int) {
|
||||
pp := g.Level.At(y, x)
|
||||
if g.lookSkips(s, pp, y, x) {
|
||||
return
|
||||
}
|
||||
|
||||
tp := pp.Monst
|
||||
|
||||
ch, skip := g.lookCellChar(s, tp, y, x, pp.Ch)
|
||||
if skip {
|
||||
return
|
||||
}
|
||||
|
||||
if !g.lookShow(s, tp, ch, y, x) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.doorStop && g.Running {
|
||||
g.lookRunCheck(s, ch, y, x)
|
||||
}
|
||||
}
|
||||
|
||||
// lookSkips reports whether look ignores this square entirely: the
|
||||
// hero's own square when sighted, blank rock, passage squares of
|
||||
// another network, and diagonals the hero could not step to (the guard
|
||||
// chain of the misc.c look loop).
|
||||
func (g *RogueGame) lookSkips(s *lookScan, pp *Place, y, x int) bool {
|
||||
if !g.Player.On(Blind) && y == s.hero.Y && x == s.hero.X {
|
||||
return true
|
||||
}
|
||||
|
||||
if pp.Ch == ' ' { // nothing need be done with a ' '
|
||||
return true
|
||||
}
|
||||
|
||||
return lookForeignPassage(s, pp.Flags, pp.Ch) ||
|
||||
g.lookDiagonalBlocked(s, pp.Flags, pp.Ch, y, x)
|
||||
}
|
||||
|
||||
// lookForeignPassage hides passage squares belonging to a different
|
||||
// passage network than the hero's (misc.c look).
|
||||
func lookForeignPassage(s *lookScan, fp PlaceFlags, ch byte) bool {
|
||||
if s.pch != Door && ch != Door {
|
||||
return (s.pfl & FPassage) != (fp & FPassage)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// lookDiagonalBlocked hides diagonal door/passage squares the hero could
|
||||
// not actually step to (misc.c look).
|
||||
func (g *RogueGame) lookDiagonalBlocked(
|
||||
s *lookScan, fp PlaceFlags, ch byte, y, x int,
|
||||
) bool {
|
||||
if !fp.Has(FPassage) && ch != Door {
|
||||
return false
|
||||
}
|
||||
|
||||
if !s.pfl.Has(FPassage) && s.pch != Door {
|
||||
return false
|
||||
}
|
||||
|
||||
return s.hero.X != x && s.hero.Y != y &&
|
||||
!stepOk(g.Level.Char(y, s.hero.X)) && !stepOk(g.Level.Char(s.hero.Y, x))
|
||||
}
|
||||
|
||||
// lookShow draws the square if it changed; it reports false when a
|
||||
// blind hero cannot see it at all (the draw part of the look loop).
|
||||
func (g *RogueGame) lookShow(s *lookScan, tp *Monster, ch byte, y, x int) bool {
|
||||
p := &g.Player
|
||||
if p.On(Blind) && (y != s.hero.Y || x != s.hero.X) {
|
||||
return false
|
||||
}
|
||||
|
||||
g.move(y, x)
|
||||
|
||||
if p.Room.Flags.Has(Dark) && !g.Options.SeeFloor && ch == Floor {
|
||||
ch = ' '
|
||||
}
|
||||
|
||||
if tp != nil || ch != g.inch() {
|
||||
g.addch(ch)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// lookCellChar picks what the square shows: trip rendering for empty
|
||||
// squares, waking and disguises for monsters. skip means the square is
|
||||
// not drawn at all (the monster switch of the look loop).
|
||||
func (g *RogueGame) lookCellChar(
|
||||
s *lookScan, tp *Monster, y, x int, ch byte,
|
||||
) (byte, bool) {
|
||||
p := &g.Player
|
||||
|
||||
switch {
|
||||
case tp == nil:
|
||||
return g.tripCh(y, x, ch), false
|
||||
case p.On(SenseMonsters) && tp.On(Invisible):
|
||||
if g.DoorStop && !g.Firstmove {
|
||||
g.Running = false
|
||||
}
|
||||
|
||||
return ch, true
|
||||
default:
|
||||
if s.wakeup {
|
||||
g.wakeMonster(y, x)
|
||||
}
|
||||
|
||||
if g.seeMonst(tp) {
|
||||
if p.On(Hallucinating) {
|
||||
return g.randomMonsterLetter(), false
|
||||
}
|
||||
|
||||
return tp.Disguise, false
|
||||
}
|
||||
|
||||
return ch, false
|
||||
}
|
||||
}
|
||||
|
||||
// lookRunCheck decides whether what this square shows should stop a run
|
||||
// (the DoorStop tail of the misc.c look loop). Squares on the running
|
||||
// edge are ignored.
|
||||
func (g *RogueGame) lookRunCheck(s *lookScan, ch byte, y, x int) {
|
||||
if s.atRunEdge(g.RunCh, y, x) {
|
||||
return
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case Door:
|
||||
if x == s.hero.X || y == s.hero.Y {
|
||||
g.Running = false
|
||||
}
|
||||
case Passage:
|
||||
if x == s.hero.X || y == s.hero.Y {
|
||||
s.passcount++
|
||||
}
|
||||
case Floor, '|', '-', ' ':
|
||||
default:
|
||||
g.Running = false
|
||||
}
|
||||
}
|
||||
|
||||
// atRunEdge reports whether (y, x) sits on the leading edge of the run
|
||||
// direction, where door-stop checking does not apply (the first RunCh
|
||||
// switch of the misc.c look loop).
|
||||
func (s *lookScan) atRunEdge(runCh byte, y, x int) bool {
|
||||
switch runCh {
|
||||
case 'h':
|
||||
return x == s.ex
|
||||
case 'j':
|
||||
return y == s.sy
|
||||
case 'k':
|
||||
return y == s.ey
|
||||
case 'l':
|
||||
return x == s.sx
|
||||
case 'y':
|
||||
return (y+x)-s.sumhero >= 1
|
||||
case 'u':
|
||||
return (y-x)-s.diffhero >= 1
|
||||
case 'n':
|
||||
return (y+x)-s.sumhero <= -1
|
||||
case 'b':
|
||||
return (y-x)-s.diffhero <= -1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// tripCh returns the character for this space, taking into account whether
|
||||
// or not the player is tripping (misc.c trip_ch).
|
||||
func (g *RogueGame) tripCh(y, x int, ch byte) byte {
|
||||
@@ -165,26 +262,30 @@ func (g *RogueGame) tripCh(y, x int, ch byte) byte {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// eraseLamp erases the area shown by a lamp in a dark room
|
||||
// (misc.c erase_lamp).
|
||||
func (g *RogueGame) eraseLamp(pos Coord, rp *Room) {
|
||||
if !(g.Options.SeeFloor && rp.Flags&(Gone|Dark) == Dark &&
|
||||
!g.Player.On(Blind)) {
|
||||
if !g.Options.SeeFloor || rp.Flags&(Gone|Dark) != Dark ||
|
||||
g.Player.On(Blind) {
|
||||
return
|
||||
}
|
||||
|
||||
ey := pos.Y + 1
|
||||
ex := pos.X + 1
|
||||
|
||||
sy := pos.Y - 1
|
||||
for x := pos.X - 1; x <= ex; x++ {
|
||||
for y := sy; y <= ey; y++ {
|
||||
if y == g.Player.Pos.Y && x == g.Player.Pos.X {
|
||||
continue
|
||||
}
|
||||
|
||||
g.move(y, x)
|
||||
|
||||
if g.inch() == Floor {
|
||||
g.addch(' ')
|
||||
}
|
||||
@@ -198,53 +299,53 @@ func (g *RogueGame) showFloor() bool {
|
||||
if g.Player.Room.Flags&(Gone|Dark) == Dark && !g.Player.On(Blind) {
|
||||
return g.Options.SeeFloor
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// findObj finds the unclaimed object at (y, x) (misc.c find_obj).
|
||||
func (g *RogueGame) findObj(y, x int) *Object {
|
||||
for _, obj := range g.Level.Objects {
|
||||
if obj.Pos.Y == y && obj.Pos.X == x {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
// eat lets her try to eat something (misc.c eat).
|
||||
func (g *RogueGame) eat() {
|
||||
obj := g.getItem("eat", KindFood)
|
||||
if obj == nil {
|
||||
obj, ok := g.promptPackItem("eat", KindFood)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Kind != KindFood {
|
||||
if !g.Options.Terse {
|
||||
g.msg("ugh, you would get ill if you ate that")
|
||||
} else {
|
||||
g.msg("that's Inedible!")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
if p.FoodLeft < 0 {
|
||||
p.FoodLeft = 0
|
||||
}
|
||||
|
||||
if p.FoodLeft += HungerTime - 200 + g.rnd(400); p.FoodLeft > StomachSize {
|
||||
p.FoodLeft = StomachSize
|
||||
}
|
||||
|
||||
p.HungryState = 0
|
||||
if obj == p.CurWeapon {
|
||||
p.CurWeapon = nil
|
||||
}
|
||||
if obj.Which == 1 {
|
||||
|
||||
switch {
|
||||
case obj.Which == 1:
|
||||
g.msg("my, that was a yummy %s", g.Fruit)
|
||||
} else if g.rnd(100) > 70 {
|
||||
case g.rnd(100) > 70:
|
||||
p.Stats.Exp++
|
||||
|
||||
g.msg("%s, this food tastes awful", g.chooseStr("bummer", "yuk"))
|
||||
g.checkLevel()
|
||||
} else {
|
||||
default:
|
||||
g.msg("%s, that tasted good", g.chooseStr("oh, wow", "yum"))
|
||||
}
|
||||
|
||||
g.leavePack(obj, false, false)
|
||||
}
|
||||
|
||||
@@ -252,38 +353,46 @@ func (g *RogueGame) eat() {
|
||||
// check_level).
|
||||
func (g *RogueGame) checkLevel() {
|
||||
p := &g.Player
|
||||
|
||||
var i int
|
||||
for i = 0; eLevels[i] != 0; i++ {
|
||||
if eLevels[i] > p.Stats.Exp {
|
||||
for i = 0; g.data.eLevels[i] != 0; i++ {
|
||||
if g.data.eLevels[i] > p.Stats.Exp {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
i++
|
||||
olevel := p.Stats.Lvl
|
||||
|
||||
p.Stats.Lvl = i
|
||||
if i > olevel {
|
||||
add := g.roll(i-olevel, 10)
|
||||
p.Stats.MaxHP += add
|
||||
p.Stats.HP += add
|
||||
|
||||
g.msg("welcome to level %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// chgStr modifies the player's strength, keeping track of the highest it
|
||||
// has been (misc.c chg_str).
|
||||
func (g *RogueGame) chgStr(amt int) {
|
||||
// changeStrength modifies the player's strength, keeping track of the
|
||||
// highest it has been (misc.c chg_str).
|
||||
func (g *RogueGame) changeStrength(amt int) {
|
||||
if amt == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
addStr(&p.Stats.Str, amt)
|
||||
|
||||
comp := p.Stats.Str
|
||||
if p.IsRing(Left, RingAddStrength) {
|
||||
addStr(&comp, -p.CurRing[Left].Bonus)
|
||||
}
|
||||
|
||||
if p.IsRing(Right, RingAddStrength) {
|
||||
addStr(&comp, -p.CurRing[Right].Bonus)
|
||||
}
|
||||
|
||||
if comp > p.MaxStats.Str {
|
||||
p.MaxStats.Str = comp
|
||||
}
|
||||
@@ -303,24 +412,29 @@ func (g *RogueGame) addHaste(potion bool) bool {
|
||||
p := &g.Player
|
||||
if p.On(Hasted) {
|
||||
g.NoCommand += g.rnd(8)
|
||||
|
||||
p.Flags.Clear(Awake | Hasted)
|
||||
g.Extinguish(DNohaste)
|
||||
g.msg("you faint from exhaustion")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
p.Flags.Set(Hasted)
|
||||
|
||||
if potion {
|
||||
g.Fuse(DNohaste, 0, g.rnd(4)+4, After)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// aggravate aggravates all the monsters on this level (misc.c aggravate).
|
||||
func (g *RogueGame) aggravate() {
|
||||
// runto() can splice the monster list while we walk it, so iterate a copy.
|
||||
// runTo() can splice the monster list while we walk it, so iterate a copy.
|
||||
monsters := append([]*Monster(nil), g.Level.Monsters...)
|
||||
for _, mp := range monsters {
|
||||
g.runto(mp.Pos)
|
||||
g.runTo(mp.Pos)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,10 +444,12 @@ func vowelstr(str string) string {
|
||||
if str == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch str[0] {
|
||||
case 'a', 'A', 'e', 'E', 'i', 'I', 'o', 'O', 'u', 'U':
|
||||
return "n"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -343,21 +459,25 @@ func (g *RogueGame) isCurrent(obj *Object) bool {
|
||||
if obj == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
if obj == p.CurArmor || obj == p.CurWeapon ||
|
||||
obj == p.CurRing[Left] || obj == p.CurRing[Right] {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("That's already ")
|
||||
g.addmsgf("That's already ")
|
||||
}
|
||||
|
||||
g.msg("in use")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getDir sets up the direction coordinate for use in various "prefix"
|
||||
// commands (misc.c get_dir).
|
||||
func (g *RogueGame) getDir() bool {
|
||||
// promptDirection sets up the direction coordinate for use in various
|
||||
// "prefix" commands (misc.c get_dir).
|
||||
func (g *RogueGame) promptDirection() bool {
|
||||
if g.Again && g.LastDir != 0 {
|
||||
g.Delta = g.lastDelt
|
||||
g.DirCh = g.LastDir
|
||||
@@ -367,53 +487,76 @@ func (g *RogueGame) getDir() bool {
|
||||
prompt = "which direction? "
|
||||
g.msg("%s", prompt)
|
||||
}
|
||||
|
||||
for {
|
||||
gotit := true
|
||||
switch g.DirCh = g.readchar(); g.DirCh {
|
||||
case 'h', 'H':
|
||||
g.Delta = Coord{X: -1, Y: 0}
|
||||
case 'j', 'J':
|
||||
g.Delta = Coord{X: 0, Y: 1}
|
||||
case 'k', 'K':
|
||||
g.Delta = Coord{X: 0, Y: -1}
|
||||
case 'l', 'L':
|
||||
g.Delta = Coord{X: 1, Y: 0}
|
||||
case 'y', 'Y':
|
||||
g.Delta = Coord{X: -1, Y: -1}
|
||||
case 'u', 'U':
|
||||
g.Delta = Coord{X: 1, Y: -1}
|
||||
case 'b', 'B':
|
||||
g.Delta = Coord{X: -1, Y: 1}
|
||||
case 'n', 'N':
|
||||
g.Delta = Coord{X: 1, Y: 1}
|
||||
case Escape:
|
||||
g.DirCh = g.readchar()
|
||||
if g.DirCh == Escape {
|
||||
g.LastDir = 0
|
||||
g.resetLast()
|
||||
|
||||
return false
|
||||
default:
|
||||
g.Msgs.Mpos = 0
|
||||
g.msg("%s", prompt)
|
||||
gotit = false
|
||||
}
|
||||
if gotit {
|
||||
|
||||
if d, ok := deltaFor(g.DirCh); ok {
|
||||
g.Delta = d
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
g.msg("%s", prompt)
|
||||
}
|
||||
|
||||
g.DirCh = toLower(g.DirCh)
|
||||
g.LastDir = g.DirCh
|
||||
g.lastDelt = g.Delta
|
||||
}
|
||||
|
||||
if g.Player.On(Confused) && g.rnd(5) == 0 {
|
||||
for {
|
||||
g.Delta.Y = g.rnd(3) - 1
|
||||
g.Delta.X = g.rnd(3) - 1
|
||||
if g.Delta.Y != 0 || g.Delta.X != 0 {
|
||||
break
|
||||
}
|
||||
g.confuseDirection()
|
||||
}
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// confuseDirection randomizes the chosen direction for a confused hero
|
||||
// (the ISHUH tail of misc.c get_dir).
|
||||
func (g *RogueGame) confuseDirection() {
|
||||
for {
|
||||
g.Delta.Y = g.rnd(3) - 1
|
||||
|
||||
g.Delta.X = g.rnd(3) - 1
|
||||
if g.Delta.Y != 0 || g.Delta.X != 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
g.Msgs.Mpos = 0
|
||||
return true
|
||||
}
|
||||
|
||||
// deltaFor maps a direction key to its movement delta; ok is false for
|
||||
// keys that are not directions (the switch of misc.c get_dir).
|
||||
func deltaFor(ch byte) (Coord, bool) {
|
||||
switch ch {
|
||||
case 'h', 'H':
|
||||
return Coord{X: -1, Y: 0}, true
|
||||
case 'j', 'J':
|
||||
return Coord{X: 0, Y: 1}, true
|
||||
case 'k', 'K':
|
||||
return Coord{X: 0, Y: -1}, true
|
||||
case 'l', 'L':
|
||||
return Coord{X: 1, Y: 0}, true
|
||||
case 'y', 'Y':
|
||||
return Coord{X: -1, Y: -1}, true
|
||||
case 'u', 'U':
|
||||
return Coord{X: 1, Y: -1}, true
|
||||
case 'b', 'B':
|
||||
return Coord{X: -1, Y: 1}, true
|
||||
case 'n', 'N':
|
||||
return Coord{X: 1, Y: 1}, true
|
||||
}
|
||||
|
||||
return Coord{}, false
|
||||
}
|
||||
|
||||
// callIt calls an object something after use (misc.c call_it).
|
||||
@@ -422,6 +565,7 @@ func (g *RogueGame) callIt(info *ObjInfo) {
|
||||
info.Guess = ""
|
||||
} else if info.Guess == "" {
|
||||
g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? "))
|
||||
|
||||
buf := ""
|
||||
if g.getStr(&buf, g.scr.Std) == Norm {
|
||||
if buf != "" {
|
||||
@@ -431,21 +575,17 @@ func (g *RogueGame) callIt(info *ObjInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// thingList is misc.c rnd_thing()'s static table.
|
||||
var thingList = []byte{
|
||||
Potion, Scroll, Ring, Stick, Food, Weapon, Armor, Stairs, Gold, Amulet,
|
||||
}
|
||||
|
||||
// rndThing picks a random thing appropriate for this level (misc.c
|
||||
// rnd_thing).
|
||||
func (g *RogueGame) rndThing() byte {
|
||||
var i int
|
||||
if g.Depth >= AmuletLevel {
|
||||
i = g.rnd(len(thingList))
|
||||
i = g.rnd(len(g.data.thingList))
|
||||
} else {
|
||||
i = g.rnd(len(thingList) - 1)
|
||||
i = g.rnd(len(g.data.thingList) - 1)
|
||||
}
|
||||
return thingList[i]
|
||||
|
||||
return g.data.thingList[i]
|
||||
}
|
||||
|
||||
// chooseStr picks the first or second string depending on whether the
|
||||
@@ -454,6 +594,7 @@ func (g *RogueGame) chooseStr(ts, ns string) string {
|
||||
if g.Player.On(Hallucinating) {
|
||||
return ts
|
||||
}
|
||||
|
||||
return ns
|
||||
}
|
||||
|
||||
@@ -463,8 +604,10 @@ func unctrl(ch byte) string {
|
||||
if ch < ' ' {
|
||||
return "^" + string(ch+'@')
|
||||
}
|
||||
|
||||
if ch == 0x7f {
|
||||
return "^?"
|
||||
}
|
||||
|
||||
return string(ch)
|
||||
}
|
||||
|
||||
149
game/monsters.go
149
game/monsters.go
@@ -1,34 +1,26 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// monsters.c — monster creation and saving throws.
|
||||
|
||||
// lvlMons and wandMons list monsters in rough order of vorpalness; zero
|
||||
// entries in wandMons never wander (monsters.c).
|
||||
var lvlMons = [26]byte{
|
||||
'K', 'E', 'B', 'S', 'H', 'I', 'R', 'O', 'Z', 'L', 'C', 'Q', 'A',
|
||||
'N', 'Y', 'F', 'T', 'W', 'P', 'X', 'U', 'M', 'V', 'G', 'J', 'D',
|
||||
}
|
||||
|
||||
var wandMons = [26]byte{
|
||||
'K', 'E', 'B', 'S', 'H', 0, 'R', 'O', 'Z', 0, 'C', 'Q', 'A',
|
||||
0, 'Y', 0, 'T', 'W', 'P', 0, 'U', 'M', 'V', 'G', 'J', 0,
|
||||
}
|
||||
|
||||
// randMonster picks a monster to show up; the lower the level, the meaner
|
||||
// the monster (monsters.c randmonster).
|
||||
func (g *RogueGame) randMonster(wander bool) byte {
|
||||
mons := &lvlMons
|
||||
mons := &g.data.lvlMons
|
||||
if wander {
|
||||
mons = &wandMons
|
||||
mons = &g.data.wandMons
|
||||
}
|
||||
|
||||
for {
|
||||
d := g.Depth + (g.rnd(10) - 6)
|
||||
if d < 0 {
|
||||
d = g.rnd(5)
|
||||
}
|
||||
|
||||
if d > 25 {
|
||||
d = g.rnd(5) + 21
|
||||
}
|
||||
|
||||
if mons[d] != 0 {
|
||||
return mons[d]
|
||||
}
|
||||
@@ -38,17 +30,15 @@ func (g *RogueGame) randMonster(wander bool) byte {
|
||||
// newMonster picks a new monster and adds it to the list (monsters.c
|
||||
// new_monster).
|
||||
func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) {
|
||||
levAdd := g.Depth - AmuletLevel
|
||||
if levAdd < 0 {
|
||||
levAdd = 0
|
||||
}
|
||||
attachMon(&g.Level.Monsters, tp)
|
||||
levAdd := max(g.Depth-AmuletLevel, 0)
|
||||
|
||||
g.Level.AddMonster(tp)
|
||||
tp.Type = typ
|
||||
tp.Disguise = typ
|
||||
tp.Pos = cp
|
||||
g.move(cp.Y, cp.X)
|
||||
tp.OldCh = g.inch()
|
||||
tp.Room = g.roomin(cp)
|
||||
tp.Room = g.roomIn(cp)
|
||||
g.Level.SetMonsterAt(cp.Y, cp.X, tp)
|
||||
mp := &g.Monsters[tp.Type-'A']
|
||||
tp.Stats.Lvl = mp.Stats.Lvl + levAdd
|
||||
@@ -58,15 +48,19 @@ func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) {
|
||||
tp.Stats.Dmg = mp.Stats.Dmg
|
||||
tp.Stats.Str = mp.Stats.Str
|
||||
tp.Stats.Exp = mp.Stats.Exp + levAdd*10 + expAdd(tp)
|
||||
|
||||
tp.Flags = mp.Flags
|
||||
if g.Depth > 29 {
|
||||
tp.Flags.Set(Hasted)
|
||||
}
|
||||
|
||||
tp.Turn = true
|
||||
tp.Pack = nil
|
||||
|
||||
if g.Player.IsWearing(RingAggravateMonsters) {
|
||||
g.runto(cp)
|
||||
g.runTo(cp)
|
||||
}
|
||||
|
||||
if typ == 'X' {
|
||||
tp.Disguise = g.rndThing()
|
||||
}
|
||||
@@ -81,11 +75,13 @@ func expAdd(tp *Monster) int {
|
||||
} else {
|
||||
mod = tp.Stats.MaxHP / 6
|
||||
}
|
||||
|
||||
if tp.Stats.Lvl > 9 {
|
||||
mod *= 20
|
||||
} else if tp.Stats.Lvl > 6 {
|
||||
mod *= 4
|
||||
}
|
||||
|
||||
return mod
|
||||
}
|
||||
|
||||
@@ -93,73 +89,115 @@ func expAdd(tp *Monster) int {
|
||||
// (monsters.c wanderer).
|
||||
func (g *RogueGame) wanderer() {
|
||||
tp := &Monster{}
|
||||
|
||||
var cp Coord
|
||||
for {
|
||||
cp, _ = g.findFloor(nil, 0, true)
|
||||
if g.roomin(cp) != g.Player.Room {
|
||||
cp, _ = g.findFloor(true)
|
||||
if g.roomIn(cp) != g.Player.Room {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
g.newMonster(tp, g.randMonster(true), cp)
|
||||
|
||||
if g.Player.On(SenseMonsters) {
|
||||
g.standout()
|
||||
|
||||
if !g.Player.On(Hallucinating) {
|
||||
g.addch(tp.Type)
|
||||
} else {
|
||||
g.addch(byte(g.rnd(26) + 'A'))
|
||||
g.addch(g.randomMonsterLetter())
|
||||
}
|
||||
|
||||
g.standend()
|
||||
}
|
||||
g.runto(tp.Pos)
|
||||
|
||||
g.runTo(tp.Pos)
|
||||
}
|
||||
|
||||
// wakeMonster is what to do when the hero steps next to a monster
|
||||
// (monsters.c wake_monster).
|
||||
func (g *RogueGame) wakeMonster(y, x int) *Monster {
|
||||
func (g *RogueGame) wakeMonster(y, x int) {
|
||||
p := &g.Player
|
||||
|
||||
tp := g.Level.MonsterAt(y, x)
|
||||
if tp == nil {
|
||||
panic("can't find monster in wake_monster")
|
||||
}
|
||||
ch := tp.Type
|
||||
|
||||
// Every time he sees a mean monster, it might start chasing him
|
||||
if !tp.On(Awake) && g.rnd(3) != 0 && tp.On(Mean) && !tp.On(Held) &&
|
||||
!p.IsWearing(RingStealth) && !p.On(Levitating) {
|
||||
if g.meanWakes(tp) {
|
||||
tp.Dest = &p.Pos
|
||||
tp.Flags.Set(Awake)
|
||||
}
|
||||
if ch == 'M' && !p.On(Blind) && !p.On(Hallucinating) &&
|
||||
!tp.On(Found) && !tp.On(Cancelled) && tp.On(Awake) {
|
||||
rp := p.Room
|
||||
if (rp != nil && !rp.Flags.Has(Dark)) ||
|
||||
distance(y, x, p.Pos.Y, p.Pos.X) < LampDist {
|
||||
tp.Flags.Set(Found)
|
||||
if !g.save(VsMagic) {
|
||||
if p.On(Confused) {
|
||||
g.Lengthen(DUnconfuse, g.spread(HuhDuration))
|
||||
} else {
|
||||
g.Fuse(DUnconfuse, 0, g.spread(HuhDuration), After)
|
||||
}
|
||||
p.Flags.Set(Confused)
|
||||
mname := g.setMname(tp)
|
||||
g.addmsg("%s", mname)
|
||||
if mname != "it" {
|
||||
g.addmsg("'")
|
||||
}
|
||||
g.msg("s gaze has confused you")
|
||||
}
|
||||
}
|
||||
|
||||
if g.medusaCatches(tp) {
|
||||
g.medusaGaze(tp, y, x)
|
||||
}
|
||||
// Let greedy ones guard gold
|
||||
if tp.On(Greedy) && !tp.On(Awake) {
|
||||
tp.Flags.Set(Awake)
|
||||
|
||||
if p.Room.GoldVal != 0 {
|
||||
tp.Dest = &p.Room.Gold
|
||||
} else {
|
||||
tp.Dest = &p.Pos
|
||||
}
|
||||
}
|
||||
return tp
|
||||
}
|
||||
|
||||
// meanWakes decides whether a sleeping mean monster starts the chase
|
||||
// (monsters.c wake_monster). The waking roll happens for any sleeping
|
||||
// monster, as in C.
|
||||
func (g *RogueGame) meanWakes(tp *Monster) bool {
|
||||
p := &g.Player
|
||||
|
||||
return !tp.On(Awake) && g.rnd(3) != 0 && tp.On(Mean) && !tp.On(Held) &&
|
||||
!p.IsWearing(RingStealth) && !p.On(Levitating)
|
||||
}
|
||||
|
||||
// medusaCatches reports an uncovered, awake medusa the hero can see
|
||||
// (monsters.c wake_monster).
|
||||
func (g *RogueGame) medusaCatches(tp *Monster) bool {
|
||||
p := &g.Player
|
||||
|
||||
return tp.Type == 'M' && !p.On(Blind) && !p.On(Hallucinating) &&
|
||||
!tp.On(Found) && !tp.On(Cancelled) && tp.On(Awake)
|
||||
}
|
||||
|
||||
// medusaGaze confuses the hero when the medusa's gaze lands (the M
|
||||
// block of monsters.c wake_monster).
|
||||
func (g *RogueGame) medusaGaze(tp *Monster, y, x int) {
|
||||
p := &g.Player
|
||||
|
||||
rp := p.Room
|
||||
if (rp == nil || rp.Flags.Has(Dark)) &&
|
||||
distance(y, x, p.Pos.Y, p.Pos.X) >= LampDist {
|
||||
return
|
||||
}
|
||||
|
||||
tp.Flags.Set(Found)
|
||||
|
||||
if g.save(VsMagic) {
|
||||
return
|
||||
}
|
||||
|
||||
if p.On(Confused) {
|
||||
g.Lengthen(DUnconfuse, g.spread(HuhDuration))
|
||||
} else {
|
||||
g.Fuse(DUnconfuse, 0, g.spread(HuhDuration), After)
|
||||
}
|
||||
|
||||
p.Flags.Set(Confused)
|
||||
|
||||
mname := g.setMname(tp)
|
||||
g.addmsgf("%s", mname)
|
||||
|
||||
if mname != "it" {
|
||||
g.addmsgf("'")
|
||||
}
|
||||
|
||||
g.msg("s gaze has confused you")
|
||||
}
|
||||
|
||||
// givePack gives a pack to a monster if it deserves one (monsters.c
|
||||
@@ -174,6 +212,7 @@ func (g *RogueGame) givePack(tp *Monster) {
|
||||
// save_throw).
|
||||
func (g *RogueGame) saveThrow(which int, st *Stats) bool {
|
||||
need := 14 + which - st.Lvl/2
|
||||
|
||||
return g.roll(1, 20) >= need
|
||||
}
|
||||
|
||||
@@ -185,9 +224,17 @@ func (g *RogueGame) save(which int) bool {
|
||||
if p.IsRing(Left, RingProtection) {
|
||||
which -= p.CurRing[Left].Bonus
|
||||
}
|
||||
|
||||
if p.IsRing(Right, RingProtection) {
|
||||
which -= p.CurRing[Right].Bonus
|
||||
}
|
||||
}
|
||||
|
||||
return g.saveThrow(which, &p.Stats)
|
||||
}
|
||||
|
||||
// randomMonsterLetter picks a random monster display letter, used by the
|
||||
// hallucination effects (the C rnd(26)+'A' idiom).
|
||||
func (g *RogueGame) randomMonsterLetter() byte {
|
||||
return byte(g.rnd(26) + 'A') //nolint:gosec // G115: 'A'..'Z' fits a byte
|
||||
}
|
||||
|
||||
505
game/move.go
505
game/move.go
@@ -1,159 +1,266 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// move.c — hero movement commands.
|
||||
|
||||
// doRun starts the hero running (move.c do_run).
|
||||
func (g *RogueGame) doRun(ch byte) {
|
||||
// startRun starts the hero running (move.c do_run).
|
||||
func (g *RogueGame) startRun(ch byte) {
|
||||
g.Running = true
|
||||
g.After = false
|
||||
g.RunCh = ch
|
||||
}
|
||||
|
||||
// doMove checks that a move is legal and handles the consequences —
|
||||
// fighting, picking up, etc. (move.c do_move).
|
||||
func (g *RogueGame) doMove(dy, dx int) {
|
||||
// moveHero checks that a move is legal and handles the consequences —
|
||||
// fighting, picking up, etc. (move.c do_move). The C `goto over`
|
||||
// re-check after a passage turn is the retry loop.
|
||||
func (g *RogueGame) moveHero(dy, dx int) {
|
||||
p := &g.Player
|
||||
|
||||
g.Firstmove = false
|
||||
if g.NoMove > 0 {
|
||||
g.NoMove--
|
||||
g.msg("you are still stuck in the bear trap")
|
||||
|
||||
return
|
||||
}
|
||||
// Do a confused move (maybe)
|
||||
var nh Coord
|
||||
if p.On(Confused) && g.rnd(5) != 0 {
|
||||
nh = g.rndmove(&p.Creature)
|
||||
nh = g.randomStep(&p.Creature)
|
||||
if nh == p.Pos {
|
||||
g.After = false
|
||||
g.Running = false
|
||||
g.ToDeath = false
|
||||
|
||||
return
|
||||
}
|
||||
} else {
|
||||
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
|
||||
}
|
||||
|
||||
over:
|
||||
// Check if he tried to move off the screen or make an illegal diagonal
|
||||
// move, and stop him if he did.
|
||||
hitBound := nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1
|
||||
var ch byte
|
||||
var fl PlaceFlags
|
||||
if !hitBound {
|
||||
if !g.diagOk(p.Pos, nh) {
|
||||
g.After = false
|
||||
g.Running = false
|
||||
for {
|
||||
ch, fl, stop := g.moveTarget(nh)
|
||||
if stop {
|
||||
return
|
||||
}
|
||||
if g.Running && p.Pos == nh {
|
||||
g.After = false
|
||||
g.Running = false
|
||||
}
|
||||
fl = *g.Level.FlagsAt(nh.Y, nh.X)
|
||||
ch = g.Level.VisibleChar(nh.Y, nh.X)
|
||||
if !fl.Has(FReal) && ch == Floor {
|
||||
if !p.On(Levitating) {
|
||||
ch = Trap
|
||||
g.Level.SetChar(nh.Y, nh.X, Trap)
|
||||
g.Level.FlagsAt(nh.Y, nh.X).Set(FReal)
|
||||
}
|
||||
} else if p.On(Held) && ch != 'F' {
|
||||
g.msg("you are being held")
|
||||
|
||||
turned, ndy, ndx := g.moveResolve(nh, ch, fl, dy, dx)
|
||||
if !turned {
|
||||
return
|
||||
}
|
||||
|
||||
// the C goto over: re-check the turned move
|
||||
dy, dx = ndy, ndx
|
||||
|
||||
g.turnRefresh()
|
||||
|
||||
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
|
||||
}
|
||||
if hitBound {
|
||||
ch = ' ' // fall into the wall case below
|
||||
}
|
||||
}
|
||||
|
||||
// moveResolve acts on the square the hero stepped at: a wall may turn a
|
||||
// passage runner (reported with the new deltas); anything else completes
|
||||
// or refuses the move (the switch of move.c do_move).
|
||||
func (g *RogueGame) moveResolve(
|
||||
nh Coord, ch byte, fl PlaceFlags, dy, dx int,
|
||||
) (bool, int, int) {
|
||||
switch ch {
|
||||
case ' ', '|', '-':
|
||||
if g.Options.PassGo && g.Running && p.Room.Flags.Has(Gone) &&
|
||||
!p.On(Blind) {
|
||||
var b1, b2 bool
|
||||
switch g.RunCh {
|
||||
case 'h', 'l':
|
||||
b1 = p.Pos.Y != 1 && g.turnOk(p.Pos.Y-1, p.Pos.X)
|
||||
b2 = p.Pos.Y != NumLines-2 && g.turnOk(p.Pos.Y+1, p.Pos.X)
|
||||
if b1 != b2 {
|
||||
if b1 {
|
||||
g.RunCh = 'k'
|
||||
dy = -1
|
||||
} else {
|
||||
g.RunCh = 'j'
|
||||
dy = 1
|
||||
}
|
||||
dx = 0
|
||||
g.turnref()
|
||||
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
|
||||
goto over
|
||||
}
|
||||
case 'j', 'k':
|
||||
b1 = p.Pos.X != 0 && g.turnOk(p.Pos.Y, p.Pos.X-1)
|
||||
b2 = p.Pos.X != NumCols-1 && g.turnOk(p.Pos.Y, p.Pos.X+1)
|
||||
if b1 != b2 {
|
||||
if b1 {
|
||||
g.RunCh = 'h'
|
||||
dx = -1
|
||||
} else {
|
||||
g.RunCh = 'l'
|
||||
dx = 1
|
||||
}
|
||||
dy = 0
|
||||
g.turnref()
|
||||
nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx}
|
||||
goto over
|
||||
}
|
||||
}
|
||||
if turn, ndy, ndx := g.passageTurn(dy, dx); turn {
|
||||
return true, ndy, ndx
|
||||
}
|
||||
|
||||
g.Running = false
|
||||
g.After = false
|
||||
default:
|
||||
g.moveEnter(nh, fl, ch)
|
||||
}
|
||||
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
// moveEnter completes a step onto a walkable square: doors, traps,
|
||||
// passages, floor, and things (the entry arms of the move.c do_move
|
||||
// switch).
|
||||
func (g *RogueGame) moveEnter(nh Coord, fl PlaceFlags, ch byte) {
|
||||
p := &g.Player
|
||||
|
||||
switch ch {
|
||||
case Door:
|
||||
g.Running = false
|
||||
if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPassage) {
|
||||
g.enterRoom(nh)
|
||||
}
|
||||
g.moveStuff(nh, fl)
|
||||
case Trap:
|
||||
tr := g.beTrapped(nh)
|
||||
tr := g.springTrap(nh)
|
||||
if tr == TrapDoor || tr == TrapTeleport {
|
||||
return
|
||||
}
|
||||
g.moveStuff(nh, fl)
|
||||
case Passage:
|
||||
// when you're in a corridor, you don't know if you're in a maze
|
||||
// room or not, and there ain't no way to find out if you're
|
||||
// leaving a maze room, so it is necessary to always recalculate
|
||||
// proom.
|
||||
p.Room = g.roomin(p.Pos)
|
||||
g.moveStuff(nh, fl)
|
||||
p.Room = g.roomIn(p.Pos)
|
||||
case Floor:
|
||||
if !fl.Has(FReal) {
|
||||
g.beTrapped(p.Pos)
|
||||
g.springTrap(p.Pos)
|
||||
}
|
||||
g.moveStuff(nh, fl)
|
||||
default:
|
||||
if ch == Stairs {
|
||||
g.SeenStairs = true
|
||||
}
|
||||
g.moveOnto(nh, fl, ch)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
g.finishMove(nh, fl)
|
||||
}
|
||||
|
||||
// offMap reports coordinates outside the walkable map (move.c do_move).
|
||||
func offMap(nh Coord) bool {
|
||||
return nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1
|
||||
}
|
||||
|
||||
// moveTarget inspects the square the hero is stepping onto: bounds and
|
||||
// diagonal legality, hidden traps underfoot, and being held. stop means
|
||||
// the move is refused (the checks of move.c do_move).
|
||||
func (g *RogueGame) moveTarget(nh Coord) (byte, PlaceFlags, bool) {
|
||||
p := &g.Player
|
||||
|
||||
// Check if he tried to move off the screen or make an illegal
|
||||
// diagonal move, and stop him if he did.
|
||||
if offMap(nh) {
|
||||
return ' ', 0, false // fall into the wall case
|
||||
}
|
||||
|
||||
if !g.diagOk(p.Pos, nh) {
|
||||
g.After = false
|
||||
g.Running = false
|
||||
if isUpper(ch) || g.Level.MonsterAt(nh.Y, nh.X) != nil {
|
||||
g.fight(nh, p.CurWeapon, false)
|
||||
} else {
|
||||
if ch != Stairs {
|
||||
g.Take = ch
|
||||
}
|
||||
g.moveStuff(nh, fl)
|
||||
|
||||
return 0, 0, true
|
||||
}
|
||||
|
||||
if g.Running && p.Pos == nh {
|
||||
g.After = false
|
||||
g.Running = false
|
||||
}
|
||||
|
||||
fl := *g.Level.FlagsAt(nh.Y, nh.X)
|
||||
|
||||
ch := g.Level.VisibleChar(nh.Y, nh.X)
|
||||
if !fl.Has(FReal) && ch == Floor {
|
||||
if !p.On(Levitating) {
|
||||
ch = Trap
|
||||
g.Level.SetChar(nh.Y, nh.X, Trap)
|
||||
g.Level.FlagsAt(nh.Y, nh.X).Set(FReal)
|
||||
}
|
||||
} else if p.On(Held) && ch != 'F' {
|
||||
g.msg("you are being held")
|
||||
|
||||
return 0, 0, true
|
||||
}
|
||||
|
||||
return ch, fl, false
|
||||
}
|
||||
|
||||
// moveOnto handles stepping at a monster or onto an item (the default
|
||||
// arm of the move.c do_move switch).
|
||||
func (g *RogueGame) moveOnto(nh Coord, fl PlaceFlags, ch byte) {
|
||||
p := &g.Player
|
||||
if ch == Stairs {
|
||||
g.SeenStairs = true
|
||||
}
|
||||
|
||||
g.Running = false
|
||||
if isUpper(ch) || g.Level.MonsterAt(nh.Y, nh.X) != nil {
|
||||
g.fight(nh, p.CurWeapon, false)
|
||||
} else {
|
||||
if ch != Stairs {
|
||||
g.Take = ch
|
||||
}
|
||||
|
||||
g.finishMove(nh, fl)
|
||||
}
|
||||
}
|
||||
|
||||
// moveStuff is the move_stuff label in do_move: complete the step.
|
||||
func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) {
|
||||
// passageTurn checks whether a runner in a gone-room passage should turn
|
||||
// the corner instead of stopping at a wall (the PASSGO block of move.c
|
||||
// do_move). It reports whether to turn and the new deltas, updating RunCh.
|
||||
func (g *RogueGame) passageTurn(dy, dx int) (bool, int, int) {
|
||||
p := &g.Player
|
||||
if !g.Options.PassGo || !g.Running || !p.Room.Flags.Has(Gone) ||
|
||||
p.On(Blind) {
|
||||
return false, dy, dx
|
||||
}
|
||||
|
||||
switch g.RunCh {
|
||||
case 'h', 'l':
|
||||
if turn, ndy := g.passageTurnVertical(); turn {
|
||||
return true, ndy, 0
|
||||
}
|
||||
case 'j', 'k':
|
||||
if turn, ndx := g.passageTurnHorizontal(); turn {
|
||||
return true, 0, ndx
|
||||
}
|
||||
}
|
||||
|
||||
return false, dy, dx
|
||||
}
|
||||
|
||||
// passageTurnVertical decides whether a horizontal runner turns up or
|
||||
// down at a corner (move.c do_move).
|
||||
func (g *RogueGame) passageTurnVertical() (bool, int) {
|
||||
p := &g.Player
|
||||
|
||||
b1 := p.Pos.Y != 1 && g.turnOk(p.Pos.Y-1, p.Pos.X)
|
||||
|
||||
b2 := p.Pos.Y != NumLines-2 && g.turnOk(p.Pos.Y+1, p.Pos.X)
|
||||
if b1 == b2 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
if b1 {
|
||||
g.RunCh = 'k'
|
||||
|
||||
return true, -1
|
||||
}
|
||||
|
||||
g.RunCh = 'j'
|
||||
|
||||
return true, 1
|
||||
}
|
||||
|
||||
// passageTurnHorizontal decides whether a vertical runner turns left or
|
||||
// right at a corner (move.c do_move).
|
||||
func (g *RogueGame) passageTurnHorizontal() (bool, int) {
|
||||
p := &g.Player
|
||||
|
||||
b1 := p.Pos.X != 0 && g.turnOk(p.Pos.Y, p.Pos.X-1)
|
||||
|
||||
b2 := p.Pos.X != NumCols-1 && g.turnOk(p.Pos.Y, p.Pos.X+1)
|
||||
if b1 == b2 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
if b1 {
|
||||
g.RunCh = 'h'
|
||||
|
||||
return true, -1
|
||||
}
|
||||
|
||||
g.RunCh = 'l'
|
||||
|
||||
return true, 1
|
||||
}
|
||||
|
||||
// finishMove is the move_stuff label in do_move: complete the step.
|
||||
func (g *RogueGame) finishMove(nh Coord, fl PlaceFlags) {
|
||||
p := &g.Player
|
||||
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt())
|
||||
|
||||
if fl.Has(FPassage) && g.Level.Char(g.Oldpos.Y, g.Oldpos.X) == Door {
|
||||
g.leaveRoom(nh)
|
||||
}
|
||||
|
||||
p.Pos = nh
|
||||
}
|
||||
|
||||
@@ -161,17 +268,21 @@ func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) {
|
||||
// (move.c turn_ok).
|
||||
func (g *RogueGame) turnOk(y, x int) bool {
|
||||
pp := g.Level.At(y, x)
|
||||
|
||||
return pp.Ch == Door || pp.Flags&(FReal|FPassage) == (FReal|FPassage)
|
||||
}
|
||||
|
||||
// turnref decides whether to refresh at a passage turning (move.c turnref).
|
||||
func (g *RogueGame) turnref() {
|
||||
// turnRefresh decides whether to refresh at a passage turning (move.c
|
||||
// turnref).
|
||||
func (g *RogueGame) turnRefresh() {
|
||||
p := &g.Player
|
||||
|
||||
pp := g.Level.At(p.Pos.Y, p.Pos.X)
|
||||
if !pp.Flags.Has(FSeen) {
|
||||
if g.Options.Jump {
|
||||
g.refresh()
|
||||
}
|
||||
|
||||
pp.Flags.Set(FSeen)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +293,7 @@ func (g *RogueGame) doorOpen(rp *Room) {
|
||||
if rp.Flags.Has(Gone) {
|
||||
return
|
||||
}
|
||||
|
||||
for y := rp.Pos.Y; y < rp.Pos.Y+rp.Max.Y; y++ {
|
||||
for x := rp.Pos.X; x < rp.Pos.X+rp.Max.X; x++ {
|
||||
if isUpper(g.Level.VisibleChar(y, x)) {
|
||||
@@ -191,102 +303,143 @@ func (g *RogueGame) doorOpen(rp *Room) {
|
||||
}
|
||||
}
|
||||
|
||||
// beTrapped makes him pay for stepping on a trap (move.c be_trapped).
|
||||
func (g *RogueGame) beTrapped(tc Coord) TrapKind {
|
||||
// springTrap makes him pay for stepping on a trap (move.c be_trapped).
|
||||
func (g *RogueGame) springTrap(tc Coord) TrapKind {
|
||||
p := &g.Player
|
||||
if p.On(Levitating) {
|
||||
return TrapRust // anything that's not a door or teleport
|
||||
}
|
||||
|
||||
g.Running = false
|
||||
g.Count = 0
|
||||
pp := g.Level.At(tc.Y, tc.X)
|
||||
pp.Ch = Trap
|
||||
tr := TrapKind(pp.Flags & FTrapMask)
|
||||
pp.Flags.Set(FSeen)
|
||||
switch tr {
|
||||
case TrapDoor:
|
||||
g.Depth++
|
||||
g.NewLevel()
|
||||
g.msg("you fell into a trap!")
|
||||
case TrapBear:
|
||||
g.NoMove += g.spread(3) // BEARTIME
|
||||
g.msg("you are caught in a bear trap")
|
||||
case TrapMystery:
|
||||
switch g.rnd(11) {
|
||||
case 0:
|
||||
g.msg("you are suddenly in a parallel dimension")
|
||||
case 1:
|
||||
g.msg("the light in here suddenly seems %s", rainbow[g.rnd(len(rainbow))])
|
||||
case 2:
|
||||
g.msg("you feel a sting in the side of your neck")
|
||||
case 3:
|
||||
g.msg("multi-colored lines swirl around you, then fade")
|
||||
case 4:
|
||||
g.msg("a %s light flashes in your eyes", rainbow[g.rnd(len(rainbow))])
|
||||
case 5:
|
||||
g.msg("a spike shoots past your ear!")
|
||||
case 6:
|
||||
g.msg("%s sparks dance across your armor", rainbow[g.rnd(len(rainbow))])
|
||||
case 7:
|
||||
g.msg("you suddenly feel very thirsty")
|
||||
case 8:
|
||||
g.msg("you feel time speed up suddenly")
|
||||
case 9:
|
||||
g.msg("time now seems to be going slower")
|
||||
case 10:
|
||||
g.msg("you pack turns %s!", rainbow[g.rnd(len(rainbow))])
|
||||
}
|
||||
case TrapSleep:
|
||||
g.NoCommand += g.spread(5) // SLEEPTIME
|
||||
p.Flags.Clear(Awake)
|
||||
g.msg("a strange white mist envelops you and you fall asleep")
|
||||
case TrapArrow:
|
||||
if g.swing(p.Stats.Lvl-1, p.Stats.ArmorClass, 1) {
|
||||
p.Stats.HP -= g.roll(1, 6)
|
||||
if p.Stats.HP <= 0 {
|
||||
g.msg("an arrow killed you")
|
||||
g.death('a')
|
||||
} else {
|
||||
g.msg("oh no! An arrow shot you")
|
||||
}
|
||||
} else {
|
||||
arrow := newObject()
|
||||
g.initWeapon(arrow, WeaponArrow)
|
||||
arrow.Count = 1
|
||||
arrow.Pos = p.Pos
|
||||
g.fall(arrow, false)
|
||||
g.msg("an arrow shoots past you")
|
||||
}
|
||||
case TrapTeleport:
|
||||
// since the hero's leaving, look() won't put a TRAP down for us,
|
||||
// so we have to do it ourself
|
||||
g.teleport()
|
||||
g.mvaddch(tc.Y, tc.X, Trap)
|
||||
case TrapDart:
|
||||
if !g.swing(p.Stats.Lvl+1, p.Stats.ArmorClass, 1) {
|
||||
g.msg("a small dart whizzes by your ear and vanishes")
|
||||
} else {
|
||||
p.Stats.HP -= g.roll(1, 4)
|
||||
if p.Stats.HP <= 0 {
|
||||
g.msg("a poisoned dart killed you")
|
||||
g.death('d')
|
||||
}
|
||||
if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) {
|
||||
g.chgStr(-1)
|
||||
}
|
||||
g.msg("a small dart just hit you in the shoulder")
|
||||
}
|
||||
case TrapRust:
|
||||
g.msg("a gush of water hits you on the head")
|
||||
g.rustArmor(p.CurArmor)
|
||||
|
||||
if h := g.data.trapHandlers[tr]; h != nil {
|
||||
h(g, tc)
|
||||
}
|
||||
|
||||
g.flushType()
|
||||
|
||||
return tr
|
||||
}
|
||||
|
||||
// rndmove moves in a random direction if the monster/person is confused
|
||||
// (move.c rndmove).
|
||||
func (g *RogueGame) rndmove(who *Creature) Coord {
|
||||
// The per-trap effect handlers, dispatched through
|
||||
// gameData.trapHandlers. Each is one case of the C be_trapped switch.
|
||||
|
||||
func (g *RogueGame) trapFall(Coord) {
|
||||
g.Depth++
|
||||
g.NewLevel()
|
||||
g.msg("you fell into a trap!")
|
||||
}
|
||||
|
||||
func (g *RogueGame) trapBear(Coord) {
|
||||
g.NoMove += g.spread(3) // BEARTIME
|
||||
g.msg("you are caught in a bear trap")
|
||||
}
|
||||
|
||||
func (g *RogueGame) trapMystery(Coord) {
|
||||
which := g.rnd(11)
|
||||
switch which {
|
||||
case 0:
|
||||
g.msg("you are suddenly in a parallel dimension")
|
||||
case 1:
|
||||
g.msg("the light in here suddenly seems %s",
|
||||
g.data.rainbow[g.rnd(len(g.data.rainbow))])
|
||||
case 2:
|
||||
g.msg("you feel a sting in the side of your neck")
|
||||
case 3:
|
||||
g.msg("multi-colored lines swirl around you, then fade")
|
||||
case 4:
|
||||
g.msg("a %s light flashes in your eyes", g.data.rainbow[g.rnd(len(g.data.rainbow))])
|
||||
case 5:
|
||||
g.msg("a spike shoots past your ear!")
|
||||
default:
|
||||
g.trapMysteryMore(which)
|
||||
}
|
||||
}
|
||||
|
||||
// trapMysteryMore holds the back half of the mystery-trap messages.
|
||||
func (g *RogueGame) trapMysteryMore(which int) {
|
||||
switch which {
|
||||
case 6:
|
||||
g.msg("%s sparks dance across your armor", g.data.rainbow[g.rnd(len(g.data.rainbow))])
|
||||
case 7:
|
||||
g.msg("you suddenly feel very thirsty")
|
||||
case 8:
|
||||
g.msg("you feel time speed up suddenly")
|
||||
case 9:
|
||||
g.msg("time now seems to be going slower")
|
||||
case 10:
|
||||
g.msg("you pack turns %s!", g.data.rainbow[g.rnd(len(g.data.rainbow))])
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) trapSleep(Coord) {
|
||||
g.NoCommand += g.spread(5) // SLEEPTIME
|
||||
|
||||
g.Player.Flags.Clear(Awake)
|
||||
g.msg("a strange white mist envelops you and you fall asleep")
|
||||
}
|
||||
|
||||
func (g *RogueGame) trapArrow(Coord) {
|
||||
p := &g.Player
|
||||
if g.swing(p.Stats.Lvl-1, p.Stats.ArmorClass, 1) {
|
||||
p.Stats.HP -= g.roll(1, 6)
|
||||
if p.Stats.HP <= 0 {
|
||||
g.msg("an arrow killed you")
|
||||
g.death('a')
|
||||
} else {
|
||||
g.msg("oh no! An arrow shot you")
|
||||
}
|
||||
} else {
|
||||
arrow := newObject()
|
||||
g.initWeapon(arrow, WeaponArrow)
|
||||
arrow.Count = 1
|
||||
arrow.Pos = p.Pos
|
||||
g.fall(arrow, false)
|
||||
g.msg("an arrow shoots past you")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) trapTeleport(tc Coord) {
|
||||
// since the hero's leaving, look() won't put a TRAP down for us,
|
||||
// so we have to do it ourself
|
||||
g.teleport()
|
||||
g.mvaddch(tc.Y, tc.X, Trap)
|
||||
}
|
||||
|
||||
func (g *RogueGame) trapDart(Coord) {
|
||||
p := &g.Player
|
||||
if !g.swing(p.Stats.Lvl+1, p.Stats.ArmorClass, 1) {
|
||||
g.msg("a small dart whizzes by your ear and vanishes")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.Stats.HP -= g.roll(1, 4)
|
||||
if p.Stats.HP <= 0 {
|
||||
g.msg("a poisoned dart killed you")
|
||||
g.death('d')
|
||||
}
|
||||
|
||||
if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) {
|
||||
g.changeStrength(-1)
|
||||
}
|
||||
|
||||
g.msg("a small dart just hit you in the shoulder")
|
||||
}
|
||||
|
||||
func (g *RogueGame) trapRust(Coord) {
|
||||
g.msg("a gush of water hits you on the head")
|
||||
g.rustArmor(g.Player.CurArmor)
|
||||
}
|
||||
|
||||
// randomStep moves in a random direction if the monster/person is
|
||||
// confused (move.c rndmove).
|
||||
func (g *RogueGame) randomStep(who *Creature) Coord {
|
||||
ret := Coord{
|
||||
Y: who.Pos.Y + g.rnd(3) - 1,
|
||||
X: who.Pos.X + g.rnd(3) - 1,
|
||||
@@ -296,25 +449,32 @@ func (g *RogueGame) rndmove(who *Creature) Coord {
|
||||
if ret == who.Pos {
|
||||
return ret
|
||||
}
|
||||
|
||||
if !g.diagOk(who.Pos, ret) {
|
||||
return who.Pos
|
||||
}
|
||||
|
||||
ch := g.Level.VisibleChar(ret.Y, ret.X)
|
||||
if !stepOk(ch) {
|
||||
return who.Pos
|
||||
}
|
||||
|
||||
if ch == Scroll {
|
||||
var found *Object
|
||||
|
||||
for _, obj := range g.Level.Objects {
|
||||
if ret.Y == obj.Pos.Y && ret.X == obj.Pos.X {
|
||||
found = obj
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found != nil && found.ScrollKind() == ScrollScareMonster {
|
||||
return who.Pos
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -332,6 +492,7 @@ func (g *RogueGame) rustArmor(arm *Object) {
|
||||
}
|
||||
} else {
|
||||
arm.ArmorClass++
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.msg("your armor appears to be weaker now. Oh my!")
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// new_level.c — dig and draw a new level.
|
||||
@@ -13,6 +14,7 @@ const (
|
||||
func (g *RogueGame) NewLevel() {
|
||||
p := &g.Player
|
||||
p.Flags.Clear(Held) // unhold when you go down just in case
|
||||
|
||||
if g.Depth > g.MaxDepth {
|
||||
g.MaxDepth = g.Depth
|
||||
}
|
||||
@@ -20,61 +22,65 @@ func (g *RogueGame) NewLevel() {
|
||||
for i := range g.Level.Places {
|
||||
g.Level.Places[i] = Place{Ch: ' ', Flags: FReal}
|
||||
}
|
||||
|
||||
g.clear()
|
||||
// Free up the monsters on the last level; the objects and their packs
|
||||
// go with them (the garbage collector is our free_list).
|
||||
g.Level.Monsters = nil
|
||||
g.Level.Objects = nil
|
||||
g.doRooms() // Draw rooms
|
||||
g.doPassages() // Draw passages
|
||||
g.digRooms() // Draw rooms
|
||||
g.digPassages() // Draw passages
|
||||
|
||||
p.NoFood++
|
||||
|
||||
g.putThings() // Place objects (if any)
|
||||
// Place the traps
|
||||
if g.rnd(10) < g.Depth {
|
||||
g.Level.TrapCount = g.rnd(g.Depth/4) + 1
|
||||
if g.Level.TrapCount > MaxTraps {
|
||||
g.Level.TrapCount = MaxTraps
|
||||
}
|
||||
g.Level.TrapCount = min(g.rnd(g.Depth/4)+1, MaxTraps)
|
||||
|
||||
for i := g.Level.TrapCount; i > 0; i-- {
|
||||
// not only wouldn't it be NICE to have traps in mazes (not
|
||||
// that we care about being nice), since the trap number is
|
||||
// stored where the passage number is, we can't actually do it.
|
||||
var stairs Coord
|
||||
for {
|
||||
stairs, _ = g.findFloor(nil, 0, false)
|
||||
stairs, _ = g.findFloor(false)
|
||||
if g.Level.Char(stairs.Y, stairs.X) == Floor {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sp := g.Level.FlagsAt(stairs.Y, stairs.X)
|
||||
sp.Clear(FReal)
|
||||
*sp |= PlaceFlags(g.rnd(NumTrapTypes))
|
||||
*sp |= PlaceFlags(g.rnd(NumTrapTypes)) //nolint:gosec // G115: 0..7 fits
|
||||
}
|
||||
}
|
||||
// Place the staircase down.
|
||||
stairs, _ := g.findFloor(nil, 0, false)
|
||||
stairs, _ := g.findFloor(false)
|
||||
g.Level.Stairs = stairs
|
||||
g.Level.SetChar(stairs.Y, stairs.X, Stairs)
|
||||
g.SeenStairs = false
|
||||
|
||||
for _, tp := range g.Level.Monsters {
|
||||
tp.Room = g.roomin(tp.Pos)
|
||||
tp.Room = g.roomIn(tp.Pos)
|
||||
}
|
||||
|
||||
hero, _ := g.findFloor(nil, 0, true)
|
||||
hero, _ := g.findFloor(true)
|
||||
p.Pos = hero
|
||||
g.enterRoom(hero)
|
||||
g.mvaddch(hero.Y, hero.X, PlayerCh)
|
||||
|
||||
if p.On(SenseMonsters) {
|
||||
g.turnSee(false)
|
||||
}
|
||||
|
||||
if p.On(Hallucinating) {
|
||||
g.visuals(0)
|
||||
}
|
||||
}
|
||||
|
||||
// rndRoom picks a room that is really there (new_level.c rnd_room).
|
||||
func (g *RogueGame) rndRoom() int {
|
||||
// randomRoom picks a room that is really there (new_level.c rnd_room).
|
||||
func (g *RogueGame) randomRoom() int {
|
||||
for {
|
||||
rm := g.rnd(MaxRooms)
|
||||
if !g.Level.Rooms[rm].Flags.Has(Gone) {
|
||||
@@ -93,16 +99,16 @@ func (g *RogueGame) putThings() {
|
||||
}
|
||||
// check for treasure rooms, and if so, put it in.
|
||||
if g.rnd(treasRoomChance) == 0 {
|
||||
g.treasRoom()
|
||||
g.treasureRoom()
|
||||
}
|
||||
// Do MAXOBJ attempts to put things on a level
|
||||
for i := 0; i < MaxObj; i++ {
|
||||
for range MaxObj {
|
||||
if g.rnd(100) < 36 {
|
||||
// Pick a new object and link it in the list
|
||||
obj := g.newThing()
|
||||
attachObj(&g.Level.Objects, obj)
|
||||
g.Level.AddObject(obj)
|
||||
// Put it somewhere
|
||||
obj.Pos, _ = g.findFloor(nil, 0, false)
|
||||
obj.Pos, _ = g.findFloor(false)
|
||||
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
|
||||
}
|
||||
}
|
||||
@@ -110,42 +116,40 @@ func (g *RogueGame) putThings() {
|
||||
// yet, put it somewhere on the ground
|
||||
if g.Depth >= AmuletLevel && !g.HasAmulet {
|
||||
obj := newObject()
|
||||
attachObj(&g.Level.Objects, obj)
|
||||
g.Level.AddObject(obj)
|
||||
obj.Damage = dice("0x0")
|
||||
obj.HurlDmg = dice("0x0")
|
||||
obj.ArmorClass = 11
|
||||
obj.Kind = KindAmulet
|
||||
// Put it somewhere
|
||||
obj.Pos, _ = g.findFloor(nil, 0, false)
|
||||
obj.Pos, _ = g.findFloor(false)
|
||||
g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Amulet)
|
||||
}
|
||||
}
|
||||
|
||||
// treasRoom adds a treasure room (new_level.c treas_room).
|
||||
func (g *RogueGame) treasRoom() {
|
||||
rp := &g.Level.Rooms[g.rndRoom()]
|
||||
spots := (rp.Max.Y-2)*(rp.Max.X-2) - minTreas
|
||||
if spots > maxTreas-minTreas {
|
||||
spots = maxTreas - minTreas
|
||||
}
|
||||
// treasureRoom adds a treasure room (new_level.c treas_room).
|
||||
func (g *RogueGame) treasureRoom() {
|
||||
rp := &g.Level.Rooms[g.randomRoom()]
|
||||
|
||||
spots := min((rp.Max.Y-2)*(rp.Max.X-2)-minTreas, maxTreas-minTreas)
|
||||
|
||||
numMonst := g.rnd(spots) + minTreas
|
||||
for nm := numMonst; nm > 0; nm-- {
|
||||
mp, _ := g.findFloorIn(rp, 2*maxTries, false)
|
||||
tp := g.newThing()
|
||||
tp.Pos = mp
|
||||
attachObj(&g.Level.Objects, tp)
|
||||
g.Level.AddObject(tp)
|
||||
g.Level.SetChar(mp.Y, mp.X, tp.Kind.Glyph())
|
||||
}
|
||||
|
||||
// fill up room with monsters from the next level down
|
||||
nm := g.rnd(spots) + minTreas
|
||||
if nm < numMonst+2 {
|
||||
nm = numMonst + 2
|
||||
}
|
||||
nm := max(g.rnd(spots)+minTreas, numMonst+2)
|
||||
|
||||
spots = (rp.Max.Y - 2) * (rp.Max.X - 2)
|
||||
if nm > spots {
|
||||
nm = spots
|
||||
}
|
||||
|
||||
g.Depth++
|
||||
for ; nm > 0; nm-- {
|
||||
if mp, ok := g.findFloorIn(rp, maxTries, true); ok {
|
||||
@@ -155,5 +159,6 @@ func (g *RogueGame) treasRoom() {
|
||||
g.givePack(tp)
|
||||
}
|
||||
}
|
||||
|
||||
g.Depth--
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -7,103 +8,152 @@ import (
|
||||
|
||||
func genLevel(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
g := NewGame(Config{Seed: seed})
|
||||
|
||||
g := New(Params{Seed: seed})
|
||||
g.NewLevel()
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// renderMap draws the raw level map (not the screen) as text.
|
||||
func renderMap(g *RogueGame) string {
|
||||
var sb strings.Builder
|
||||
for y := 0; y < NumLines; y++ {
|
||||
for x := 0; x < NumCols; x++ {
|
||||
|
||||
for y := range NumLines {
|
||||
for x := range NumCols {
|
||||
ch := g.Level.Char(y, x)
|
||||
if m := g.Level.MonsterAt(y, x); m != nil {
|
||||
ch = m.Type
|
||||
}
|
||||
|
||||
sb.WriteByte(ch)
|
||||
}
|
||||
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func TestNewLevelInvariants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, seed := range []int32{1, 12345, 2026, 99999} {
|
||||
g := genLevel(t, seed)
|
||||
|
||||
// The staircase is somewhere real.
|
||||
st := g.Level.Stairs
|
||||
if g.Level.Char(st.Y, st.X) != Stairs {
|
||||
t.Errorf("seed %d: no staircase at recorded stairs position", seed)
|
||||
checkHeroPlacement(t, g, seed)
|
||||
checkRoomsDrawn(t, g, seed)
|
||||
checkMonstersPlaced(t, g, seed)
|
||||
checkObjectsPlaced(t, g, seed)
|
||||
checkStartingKit(t, g, seed)
|
||||
}
|
||||
}
|
||||
|
||||
// checkHeroPlacement verifies the staircase and hero landed on valid,
|
||||
// unoccupied cells.
|
||||
func checkHeroPlacement(t *testing.T, g *RogueGame, seed int32) {
|
||||
t.Helper()
|
||||
|
||||
// The staircase is somewhere real.
|
||||
st := g.Level.Stairs
|
||||
if g.Level.Char(st.Y, st.X) != Stairs {
|
||||
t.Errorf("seed %d: no staircase at recorded stairs position", seed)
|
||||
}
|
||||
|
||||
// The hero stands on a walkable, monster-free cell.
|
||||
hp := g.Player.Pos
|
||||
if !stepOk(g.Level.Char(hp.Y, hp.X)) {
|
||||
t.Errorf("seed %d: hero on unwalkable cell %q", seed,
|
||||
g.Level.Char(hp.Y, hp.X))
|
||||
}
|
||||
|
||||
if g.Level.MonsterAt(hp.Y, hp.X) != nil {
|
||||
t.Errorf("seed %d: hero standing on a monster", seed)
|
||||
}
|
||||
|
||||
if g.Player.Room == nil {
|
||||
t.Errorf("seed %d: hero not in any room", seed)
|
||||
}
|
||||
}
|
||||
|
||||
// checkRoomsDrawn verifies rooms and floor/passages appear on the map.
|
||||
func checkRoomsDrawn(t *testing.T, g *RogueGame, seed int32) {
|
||||
t.Helper()
|
||||
|
||||
m := renderMap(g)
|
||||
if !strings.Contains(m, "|") || !strings.Contains(m, "-") {
|
||||
t.Errorf("seed %d: no room walls drawn", seed)
|
||||
}
|
||||
|
||||
if !strings.Contains(m, ".") && !strings.Contains(m, "#") {
|
||||
t.Errorf("seed %d: no floor or passages drawn", seed)
|
||||
}
|
||||
}
|
||||
|
||||
// checkMonstersPlaced verifies every monster is indexed on the map and
|
||||
// placed in a room.
|
||||
func checkMonstersPlaced(t *testing.T, g *RogueGame, seed int32) {
|
||||
t.Helper()
|
||||
|
||||
for _, mon := range g.Level.Monsters {
|
||||
if g.Level.MonsterAt(mon.Pos.Y, mon.Pos.X) != mon {
|
||||
t.Errorf("seed %d: monster %c not indexed at its position",
|
||||
seed, mon.Type)
|
||||
}
|
||||
|
||||
// The hero stands on a walkable, monster-free cell.
|
||||
hp := g.Player.Pos
|
||||
if !stepOk(g.Level.Char(hp.Y, hp.X)) {
|
||||
t.Errorf("seed %d: hero on unwalkable cell %q", seed,
|
||||
g.Level.Char(hp.Y, hp.X))
|
||||
}
|
||||
if g.Level.MonsterAt(hp.Y, hp.X) != nil {
|
||||
t.Errorf("seed %d: hero standing on a monster", seed)
|
||||
}
|
||||
if g.Player.Room == nil {
|
||||
t.Errorf("seed %d: hero not in any room", seed)
|
||||
}
|
||||
|
||||
// Some rooms exist and are drawn.
|
||||
m := renderMap(g)
|
||||
if !strings.Contains(m, "|") || !strings.Contains(m, "-") {
|
||||
t.Errorf("seed %d: no room walls drawn", seed)
|
||||
}
|
||||
if !strings.Contains(m, ".") && !strings.Contains(m, "#") {
|
||||
t.Errorf("seed %d: no floor or passages drawn", seed)
|
||||
}
|
||||
|
||||
// Every monster is indexed on the map and placed in a room.
|
||||
for _, mon := range g.Level.Monsters {
|
||||
if g.Level.MonsterAt(mon.Pos.Y, mon.Pos.X) != mon {
|
||||
t.Errorf("seed %d: monster %c not indexed at its position",
|
||||
seed, mon.Type)
|
||||
}
|
||||
if mon.Room == nil {
|
||||
t.Errorf("seed %d: monster %c has no room", seed, mon.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Every level object sits on a cell displaying its type (items can
|
||||
// share cells only with monsters standing on them).
|
||||
for _, obj := range g.Level.Objects {
|
||||
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
|
||||
if ch != obj.Kind.Glyph() &&
|
||||
g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil {
|
||||
t.Errorf("seed %d: object %v at (%d,%d) but map shows %q",
|
||||
seed, obj.Kind, obj.Pos.Y, obj.Pos.X, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// The player has her starting kit: food, armor, mace, bow, arrows.
|
||||
if len(g.Player.Pack) != 5 {
|
||||
t.Errorf("seed %d: starting pack has %d items, want 5",
|
||||
seed, len(g.Player.Pack))
|
||||
}
|
||||
if g.Player.CurWeapon == nil ||
|
||||
g.Player.CurWeapon.WeaponKind() != WeaponMace {
|
||||
t.Errorf("seed %d: not wielding the starting mace", seed)
|
||||
}
|
||||
if g.Player.CurArmor == nil ||
|
||||
g.Player.CurArmor.ArmorKind() != ArmorRingMail {
|
||||
t.Errorf("seed %d: not wearing the starting ring mail", seed)
|
||||
if mon.Room == nil {
|
||||
t.Errorf("seed %d: monster %c has no room", seed, mon.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkObjectsPlaced verifies every level object sits on a cell
|
||||
// displaying its type (items can share cells only with monsters standing
|
||||
// on them).
|
||||
func checkObjectsPlaced(t *testing.T, g *RogueGame, seed int32) {
|
||||
t.Helper()
|
||||
|
||||
for _, obj := range g.Level.Objects {
|
||||
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
|
||||
if ch != obj.Kind.Glyph() &&
|
||||
g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil {
|
||||
t.Errorf("seed %d: object %v at (%d,%d) but map shows %q",
|
||||
seed, obj.Kind, obj.Pos.Y, obj.Pos.X, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkStartingKit verifies the player has her starting kit: food, armor,
|
||||
// mace, bow, arrows.
|
||||
func checkStartingKit(t *testing.T, g *RogueGame, seed int32) {
|
||||
t.Helper()
|
||||
|
||||
if len(g.Player.Pack) != 5 {
|
||||
t.Errorf("seed %d: starting pack has %d items, want 5",
|
||||
seed, len(g.Player.Pack))
|
||||
}
|
||||
|
||||
if g.Player.CurWeapon == nil ||
|
||||
g.Player.CurWeapon.WeaponKind() != WeaponMace {
|
||||
t.Errorf("seed %d: not wielding the starting mace", seed)
|
||||
}
|
||||
|
||||
if g.Player.CurArmor == nil ||
|
||||
g.Player.CurArmor.ArmorKind() != ArmorRingMail {
|
||||
t.Errorf("seed %d: not wearing the starting ring mail", seed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLevelDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := renderMap(genLevel(t, 12345))
|
||||
|
||||
b := renderMap(genLevel(t, 12345))
|
||||
if a != b {
|
||||
t.Error("same seed produced different levels")
|
||||
}
|
||||
|
||||
c := renderMap(genLevel(t, 54321))
|
||||
if a == c {
|
||||
t.Error("different seeds produced identical levels")
|
||||
@@ -113,11 +163,14 @@ func TestNewLevelDeterministic(t *testing.T) {
|
||||
// TestDeeperLevels exercises generation across many depths and seeds —
|
||||
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
|
||||
func TestDeeperLevels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, seed := range []int32{7, 42, 1000, 31337} {
|
||||
g := NewGame(Config{Seed: seed})
|
||||
g := New(Params{Seed: seed})
|
||||
for depth := 1; depth <= 30; depth++ {
|
||||
g.Depth = depth
|
||||
g.NewLevel()
|
||||
|
||||
st := g.Level.Stairs
|
||||
if g.Level.Char(st.Y, st.X) != Stairs {
|
||||
t.Fatalf("seed %d depth %d: missing staircase", seed, depth)
|
||||
|
||||
171
game/object.go
171
game/object.go
@@ -5,6 +5,7 @@ package game
|
||||
// category (ObjectKind) from its display character (Glyph).
|
||||
type ObjectKind int
|
||||
|
||||
// Item categories.
|
||||
const (
|
||||
KindNone ObjectKind = iota
|
||||
KindPotion
|
||||
@@ -25,35 +26,51 @@ const (
|
||||
KindRingOrStick ObjectKind = -2
|
||||
)
|
||||
|
||||
// kindGlyphs maps each kind to the character Rogue draws for it.
|
||||
var kindGlyphs = [...]byte{
|
||||
KindNone: ' ',
|
||||
KindPotion: Potion,
|
||||
KindScroll: Scroll,
|
||||
KindFood: Food,
|
||||
KindWeapon: Weapon,
|
||||
KindArmor: Armor,
|
||||
KindRing: Ring,
|
||||
KindWand: Stick,
|
||||
KindAmulet: Amulet,
|
||||
KindGold: Gold,
|
||||
}
|
||||
// Category words shared by ObjectKind.String, the discovery list, and the
|
||||
// ident table. (The bare identifiers Potion, Scroll, Ring, Gold are the
|
||||
// glyph byte constants.)
|
||||
const (
|
||||
potionName = "potion"
|
||||
scrollName = "scroll"
|
||||
ringName = "ring"
|
||||
goldName = "gold"
|
||||
)
|
||||
|
||||
// Glyph returns the map/display character for this kind of object.
|
||||
func (k ObjectKind) Glyph() byte {
|
||||
if k < 0 || int(k) >= len(kindGlyphs) {
|
||||
return ' '
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch k {
|
||||
case KindPotion:
|
||||
return Potion
|
||||
case KindScroll:
|
||||
return Scroll
|
||||
case KindFood:
|
||||
return Food
|
||||
case KindWeapon:
|
||||
return Weapon
|
||||
case KindArmor:
|
||||
return Armor
|
||||
case KindRing:
|
||||
return Ring
|
||||
case KindWand:
|
||||
return Stick
|
||||
case KindAmulet:
|
||||
return Amulet
|
||||
case KindGold:
|
||||
return Gold
|
||||
}
|
||||
return kindGlyphs[k]
|
||||
|
||||
return ' '
|
||||
}
|
||||
|
||||
// String names the category the way the C type_name() did.
|
||||
func (k ObjectKind) String() string {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch k {
|
||||
case KindPotion:
|
||||
return "potion"
|
||||
return potionName
|
||||
case KindScroll:
|
||||
return "scroll"
|
||||
return scrollName
|
||||
case KindFood:
|
||||
return "food"
|
||||
case KindWeapon:
|
||||
@@ -61,27 +78,36 @@ func (k ObjectKind) String() string {
|
||||
case KindArmor:
|
||||
return "suit of armor"
|
||||
case KindRing:
|
||||
return "ring"
|
||||
case KindWand:
|
||||
return "wand or staff"
|
||||
case KindAmulet:
|
||||
return "amulet"
|
||||
case KindGold:
|
||||
return "gold"
|
||||
case KindRingOrStick:
|
||||
return "ring, wand or staff"
|
||||
return ringName
|
||||
default:
|
||||
return k.stringRest()
|
||||
}
|
||||
return "bizarre thing"
|
||||
}
|
||||
|
||||
// objectKindForGlyph is the reverse of Glyph: what category of item does a
|
||||
// map character denote. Returns KindNone for non-item characters.
|
||||
func objectKindForGlyph(ch byte) ObjectKind {
|
||||
for k, g := range kindGlyphs {
|
||||
if g == ch && ObjectKind(k) != KindNone {
|
||||
return ObjectKind(k)
|
||||
}
|
||||
switch ch {
|
||||
case Potion:
|
||||
return KindPotion
|
||||
case Scroll:
|
||||
return KindScroll
|
||||
case Food:
|
||||
return KindFood
|
||||
case Weapon:
|
||||
return KindWeapon
|
||||
case Armor:
|
||||
return KindArmor
|
||||
case Ring:
|
||||
return KindRing
|
||||
case Stick:
|
||||
return KindWand
|
||||
case Amulet:
|
||||
return KindAmulet
|
||||
case Gold:
|
||||
return KindGold
|
||||
}
|
||||
|
||||
return KindNone
|
||||
}
|
||||
|
||||
@@ -91,6 +117,24 @@ func (k ObjectKind) MergesInPack() bool {
|
||||
return k == KindPotion || k == KindScroll || k == KindFood
|
||||
}
|
||||
|
||||
// stringRest names the remaining kinds, including the ring-or-stick
|
||||
// prompt pseudo-kind (the tail of the C type_name switch).
|
||||
func (k ObjectKind) stringRest() string {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch k {
|
||||
case KindWand:
|
||||
return "wand or staff"
|
||||
case KindAmulet:
|
||||
return "amulet"
|
||||
case KindGold:
|
||||
return goldName
|
||||
case KindRingOrStick:
|
||||
return "ring, wand or staff"
|
||||
}
|
||||
|
||||
return "bizarre thing"
|
||||
}
|
||||
|
||||
// Object is the _o arm of the C THING union: anything that can lie on the
|
||||
// floor or ride in a pack.
|
||||
type Object struct {
|
||||
@@ -120,6 +164,38 @@ func newObject() *Object {
|
||||
return &Object{Launch: noWeapon}
|
||||
}
|
||||
|
||||
// whichLimit reports how many entries a kind's per-kind tables hold, so
|
||||
// Which is a legal index exactly while 0 <= Which < whichLimit(Kind).
|
||||
// Kinds whose Which is not a table index at all — food (0 ration, 1 the
|
||||
// fruit), the amulet, gold, and the KindNone an unrecognized glyph maps
|
||||
// to — report 0, meaning any value is acceptable, which is what C did
|
||||
// with them too.
|
||||
//
|
||||
// Weapons count one past NumWeaponTypes on purpose: Items.Weapons is
|
||||
// sized NumWeaponTypes+1 for the WeaponFlame dragon-breath entry, and
|
||||
// fireBolt really does set Which = WeaponFlame on a live Object. That
|
||||
// entry has no init_dam[] row, so initWeapon and createObj bound
|
||||
// themselves by NumWeaponTypes instead.
|
||||
func whichLimit(kind ObjectKind) int {
|
||||
//nolint:exhaustive // the remaining kinds do not index a per-kind table
|
||||
switch kind {
|
||||
case KindPotion:
|
||||
return int(NumPotionTypes)
|
||||
case KindScroll:
|
||||
return int(NumScrollTypes)
|
||||
case KindRing:
|
||||
return int(NumRingTypes)
|
||||
case KindWand:
|
||||
return int(NumWandTypes)
|
||||
case KindArmor:
|
||||
return int(NumArmorTypes)
|
||||
case KindWeapon:
|
||||
return int(NumWeaponTypes) + 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// PotionKind returns Which as a potion kind; valid only for KindPotion.
|
||||
func (o *Object) PotionKind() PotionKind { return PotionKind(o.Which) }
|
||||
|
||||
@@ -138,6 +214,36 @@ func (o *Object) WeaponKind() WeaponKind { return WeaponKind(o.Which) }
|
||||
// ArmorKind returns Which as an armor kind; valid only for KindArmor.
|
||||
func (o *Object) ArmorKind() ArmorKind { return ArmorKind(o.Which) }
|
||||
|
||||
// hasValidWhich reports whether Which is a legal index into this
|
||||
// object's per-kind tables. Objects the game builds itself always
|
||||
// satisfy it; the wizard-create command and a restored save file are the
|
||||
// only two ways a malformed one can appear, and both reject it (see
|
||||
// createObj and validateSnapshotObjects).
|
||||
//
|
||||
// The Which >= 0 arm is unreachable from the keyboard: createObj derives
|
||||
// Which with byte arithmetic (int(ch-'a') + 10), which wraps to a large
|
||||
// positive value rather than going negative. It is kept as
|
||||
// defense-in-depth for the non-keyboard source — a decoded save file,
|
||||
// where Which is an int off the wire and can hold anything — and is
|
||||
// exercised there by TestRestoreRejectsOutOfRangeWhich.
|
||||
func (o *Object) hasValidWhich() bool {
|
||||
limit := whichLimit(o.Kind)
|
||||
|
||||
return limit == 0 || (o.Which >= 0 && o.Which < limit)
|
||||
}
|
||||
|
||||
// wizardCanCreate reports whether Which names something the wizard-create
|
||||
// command can actually build. It is hasValidWhich narrowed for weapons:
|
||||
// WeaponFlame owns a name-table slot but no init_dam[] row, so asking for
|
||||
// it would leave initWeapon nothing to copy.
|
||||
func (o *Object) wizardCanCreate() bool {
|
||||
if o.Kind == KindWeapon {
|
||||
return o.Which >= 0 && o.Which < int(NumWeaponTypes)
|
||||
}
|
||||
|
||||
return o.hasValidWhich()
|
||||
}
|
||||
|
||||
// attachObj is list.c attach(): push item onto the front of a list.
|
||||
func attachObj(list *[]*Object, item *Object) {
|
||||
*list = append([]*Object{item}, *list...)
|
||||
@@ -148,6 +254,7 @@ func detachObj(list *[]*Object, item *Object) {
|
||||
for i, o := range *list {
|
||||
if o == item {
|
||||
*list = append((*list)[:i], (*list)[i+1:]...)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
284
game/options.go
284
game/options.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "strings"
|
||||
@@ -28,6 +29,7 @@ type optDesc struct {
|
||||
// optList builds the options.c optlist for this game.
|
||||
func (g *RogueGame) optList() []optDesc {
|
||||
o := &g.Options
|
||||
|
||||
return []optDesc{
|
||||
{"terse", "Terse output", optBool, &o.Terse, nil, nil},
|
||||
{"flush", "Flush typeahead during battle", optBool, &o.FightFlush, nil, nil},
|
||||
@@ -46,6 +48,7 @@ func (g *RogueGame) optList() []optDesc {
|
||||
func (g *RogueGame) option() {
|
||||
hw := g.scr.Hw
|
||||
optlist := g.optList()
|
||||
|
||||
hw.Clear()
|
||||
// Display current values of options
|
||||
for i := range optlist {
|
||||
@@ -56,9 +59,11 @@ func (g *RogueGame) option() {
|
||||
}
|
||||
// Set values
|
||||
hw.Move(0, 0)
|
||||
|
||||
for i := 0; i < len(optlist); i++ {
|
||||
op := &optlist[i]
|
||||
g.prOptname(op)
|
||||
|
||||
retval := g.getOpt(op)
|
||||
if retval != Norm {
|
||||
if retval == Quit {
|
||||
@@ -70,6 +75,7 @@ func (g *RogueGame) option() {
|
||||
i -= 2
|
||||
} else { // trying to back up beyond the top
|
||||
hw.Move(0, 0)
|
||||
|
||||
i--
|
||||
}
|
||||
}
|
||||
@@ -84,18 +90,19 @@ func (g *RogueGame) option() {
|
||||
|
||||
// prOptname prints out the option name prompt (options.c pr_optname).
|
||||
func (g *RogueGame) prOptname(op *optDesc) {
|
||||
g.scr.Hw.Printw("%s (\"%s\"): ", op.prompt, op.name)
|
||||
g.scr.Hw.Printwf("%s (\"%s\"): ", op.prompt, op.name)
|
||||
}
|
||||
|
||||
// putOpt prints an option's current value (options.c put_bool/put_str/
|
||||
// put_inv_t).
|
||||
func (g *RogueGame) putOpt(op *optDesc) {
|
||||
hw := g.scr.Hw
|
||||
|
||||
switch op.kind {
|
||||
case optBool, optSeeFloor:
|
||||
hw.AddStr(boolStr(*op.boolP))
|
||||
case optInvT:
|
||||
hw.AddStr(invTName[*op.intP])
|
||||
hw.AddStr(g.data.invTName[*op.intP])
|
||||
case optStr:
|
||||
hw.AddStr(*op.strP)
|
||||
}
|
||||
@@ -105,12 +112,14 @@ func boolStr(b bool) string {
|
||||
if b {
|
||||
return "True"
|
||||
}
|
||||
|
||||
return "False"
|
||||
}
|
||||
|
||||
// getOpt reads a new value for an option (options.c get_bool/get_sf/
|
||||
// get_inv_t/get_str dispatch).
|
||||
func (g *RogueGame) getOpt(op *optDesc) int {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch op.kind {
|
||||
case optBool:
|
||||
return g.getBool(op.boolP)
|
||||
@@ -129,9 +138,11 @@ func (g *RogueGame) getBool(bp *bool) int {
|
||||
win := g.scr.Hw
|
||||
oy, ox := win.GetYX()
|
||||
win.AddStr(boolStr(*bp))
|
||||
|
||||
for {
|
||||
win.Move(oy, ox)
|
||||
g.scr.RefreshWin(win)
|
||||
|
||||
switch g.readchar() {
|
||||
case 't', 'T':
|
||||
*bp = true
|
||||
@@ -145,13 +156,17 @@ func (g *RogueGame) getBool(bp *bool) int {
|
||||
default:
|
||||
win.Move(oy, ox+10)
|
||||
win.AddStr("(T or F)")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
win.Move(oy, ox)
|
||||
win.AddStr(boolStr(*bp))
|
||||
win.AddCh('\n')
|
||||
|
||||
return Norm
|
||||
}
|
||||
|
||||
@@ -159,10 +174,12 @@ func (g *RogueGame) getBool(bp *bool) int {
|
||||
// get_sf).
|
||||
func (g *RogueGame) getSf(bp *bool) int {
|
||||
wasSf := g.Options.SeeFloor
|
||||
|
||||
retval := g.getBool(bp)
|
||||
if retval == Quit {
|
||||
return Quit
|
||||
}
|
||||
|
||||
if wasSf != g.Options.SeeFloor {
|
||||
if !g.Options.SeeFloor {
|
||||
g.Options.SeeFloor = true
|
||||
@@ -172,6 +189,7 @@ func (g *RogueGame) getSf(bp *bool) int {
|
||||
g.look(false)
|
||||
}
|
||||
}
|
||||
|
||||
return Norm
|
||||
}
|
||||
|
||||
@@ -183,57 +201,36 @@ func (g *RogueGame) getStr(opt *string, win *Window) int {
|
||||
oy, ox := win.GetYX()
|
||||
g.scr.RefreshWin(win)
|
||||
// loop reading in the string, and put it in a temporary buffer
|
||||
var buf []byte
|
||||
var c byte
|
||||
var (
|
||||
buf []byte
|
||||
c byte
|
||||
)
|
||||
for {
|
||||
c = g.readchar()
|
||||
if c == '\n' || c == '\r' || c == Escape {
|
||||
if endsInput(c) || (len(buf) == 0 && c == '-' && !onStd) {
|
||||
break
|
||||
}
|
||||
if c == 8 || c == 0x7f { // erase character
|
||||
if len(buf) > 0 {
|
||||
buf = buf[:len(buf)-1]
|
||||
win.Move(oy, ox+len(displayStr(buf)))
|
||||
}
|
||||
win.Clrtoeol()
|
||||
g.scr.RefreshWin(win)
|
||||
continue
|
||||
}
|
||||
if c == CTRL('U') { // kill character
|
||||
buf = buf[:0]
|
||||
win.Move(oy, ox)
|
||||
win.Clrtoeol()
|
||||
g.scr.RefreshWin(win)
|
||||
continue
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
if c == '-' && !onStd {
|
||||
break
|
||||
}
|
||||
if c == '~' {
|
||||
buf = append(buf, g.Home...)
|
||||
win.AddStr(g.Home)
|
||||
win.Clrtoeol()
|
||||
g.scr.RefreshWin(win)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(buf) >= MaxInp || !(isPrint(c) || c == ' ') {
|
||||
continue // C beeps here
|
||||
}
|
||||
buf = append(buf, c)
|
||||
win.AddStr(unctrl(c))
|
||||
win.Clrtoeol()
|
||||
g.scr.RefreshWin(win)
|
||||
|
||||
buf = g.getStrEdit(win, buf, c, oy, ox)
|
||||
}
|
||||
|
||||
if len(buf) > 0 { // only change option if something has been typed
|
||||
*opt = strucpy(string(buf))
|
||||
}
|
||||
win.MvPrintw(oy, ox, "%s\n", *opt)
|
||||
|
||||
win.MvPrintwf(oy, ox, "%s\n", *opt)
|
||||
g.scr.RefreshWin(win)
|
||||
|
||||
if onStd {
|
||||
g.Msgs.Mpos += len(buf)
|
||||
}
|
||||
|
||||
return getStrResult(c)
|
||||
}
|
||||
|
||||
// getStrResult maps the terminating key to the C return code (options.c
|
||||
// get_str).
|
||||
func getStrResult(c byte) int {
|
||||
switch c {
|
||||
case '-':
|
||||
return Minus
|
||||
@@ -244,12 +241,59 @@ func (g *RogueGame) getStr(opt *string, win *Window) int {
|
||||
}
|
||||
}
|
||||
|
||||
// endsInput reports the keys that finish line input (options.c get_str).
|
||||
func endsInput(c byte) bool {
|
||||
return c == '\n' || c == '\r' || c == Escape
|
||||
}
|
||||
|
||||
// getStrErase deletes the last character of the buffer (options.c
|
||||
// get_str).
|
||||
func getStrErase(win *Window, buf []byte, oy, ox int) []byte {
|
||||
if len(buf) > 0 {
|
||||
buf = buf[:len(buf)-1]
|
||||
win.Move(oy, ox+len(displayStr(buf)))
|
||||
}
|
||||
|
||||
win.Clrtoeol()
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// getStrEdit applies one key to the line editor's buffer: erase, kill,
|
||||
// home expansion, or a typed character (options.c get_str).
|
||||
func (g *RogueGame) getStrEdit(win *Window, buf []byte, c byte, oy, ox int) []byte {
|
||||
switch {
|
||||
case c == 8 || c == 0x7f:
|
||||
buf = getStrErase(win, buf, oy, ox)
|
||||
case c == CTRL('U'): // kill character
|
||||
buf = buf[:0]
|
||||
|
||||
win.Move(oy, ox)
|
||||
win.Clrtoeol()
|
||||
case len(buf) == 0 && c == '~':
|
||||
buf = append(buf, g.Home...)
|
||||
win.AddStr(g.Home)
|
||||
win.Clrtoeol()
|
||||
case len(buf) >= MaxInp || (!isPrint(c) && c != ' '):
|
||||
return buf // C beeps here
|
||||
default:
|
||||
buf = append(buf, c)
|
||||
win.AddStr(unctrl(c))
|
||||
win.Clrtoeol()
|
||||
}
|
||||
|
||||
g.scr.RefreshWin(win)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// displayStr renders a buffer the way the input echo did.
|
||||
func displayStr(buf []byte) string {
|
||||
var sb strings.Builder
|
||||
for _, c := range buf {
|
||||
sb.WriteString(unctrl(c))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -257,10 +301,12 @@ func displayStr(buf []byte) string {
|
||||
func (g *RogueGame) getInvT(ip *int) int {
|
||||
win := g.scr.Hw
|
||||
oy, ox := win.GetYX()
|
||||
win.AddStr(invTName[*ip])
|
||||
win.AddStr(g.data.invTName[*ip])
|
||||
|
||||
for {
|
||||
win.Move(oy, ox)
|
||||
g.scr.RefreshWin(win)
|
||||
|
||||
switch g.readchar() {
|
||||
case 'o', 'O':
|
||||
*ip = InvOver
|
||||
@@ -276,11 +322,15 @@ func (g *RogueGame) getInvT(ip *int) int {
|
||||
default:
|
||||
win.Move(oy, ox+15)
|
||||
win.AddStr("(O, S, or C)")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
win.MvPrintw(oy, ox, "%s\n", invTName[*ip])
|
||||
|
||||
win.MvPrintwf(oy, ox, "%s\n", g.data.invTName[*ip])
|
||||
|
||||
return Norm
|
||||
}
|
||||
|
||||
@@ -289,87 +339,121 @@ func (g *RogueGame) getInvT(ip *int) int {
|
||||
// "noname" (false), strings as "name=..." (options.c parse_opts).
|
||||
func (g *RogueGame) ParseOpts(str string) {
|
||||
optlist := g.optList()
|
||||
|
||||
for str != "" {
|
||||
// Get option name
|
||||
i := 0
|
||||
for i < len(str) && isAlpha(str[i]) {
|
||||
i++
|
||||
}
|
||||
name := str[:i]
|
||||
rest := str[i:]
|
||||
matched := false
|
||||
for oi := range optlist {
|
||||
op := &optlist[oi]
|
||||
isBoolOpt := op.kind == optBool || op.kind == optSeeFloor
|
||||
if strings.HasPrefix(op.name, name) && name != "" {
|
||||
matched = true
|
||||
if isBoolOpt {
|
||||
*op.boolP = true
|
||||
} else {
|
||||
// Skip to start of string value
|
||||
for rest != "" && rest[0] == '=' {
|
||||
rest = rest[1:]
|
||||
}
|
||||
val := rest
|
||||
var prefix string
|
||||
if val != "" && val[0] == '~' {
|
||||
prefix = g.Home
|
||||
val = val[1:]
|
||||
for val != "" && val[0] == '/' {
|
||||
val = val[1:]
|
||||
}
|
||||
}
|
||||
// Skip to end of string value
|
||||
end := strings.IndexByte(val, ',')
|
||||
if end < 0 {
|
||||
end = len(val)
|
||||
}
|
||||
word := val[:end]
|
||||
rest = val[end:]
|
||||
if op.kind == optInvT {
|
||||
// check for type of inventory
|
||||
w := word
|
||||
if w != "" {
|
||||
w = string(toUpper(w[0])) + w[1:]
|
||||
}
|
||||
for ti, tn := range invTName {
|
||||
if strings.HasPrefix(tn, w) {
|
||||
*op.intP = ti
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*op.strP = prefix + strucpy(word)
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if isBoolOpt && strings.HasPrefix(name, "no") &&
|
||||
strings.HasPrefix(op.name, name[2:]) {
|
||||
matched = true
|
||||
*op.boolP = false
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = matched
|
||||
|
||||
rest := g.parseOptName(optlist, str[:i], str[i:])
|
||||
// skip to start of next option name
|
||||
for rest != "" && !isAlpha(rest[0]) {
|
||||
rest = rest[1:]
|
||||
}
|
||||
|
||||
str = rest
|
||||
}
|
||||
}
|
||||
|
||||
// parseOptName applies one named option, returning the unconsumed
|
||||
// remainder: "name" turns a boolean on, "noname" turns it off, and
|
||||
// string options consume a value (the option scan of options.c
|
||||
// parse_opts).
|
||||
func (g *RogueGame) parseOptName(optlist []optDesc, name, rest string) string {
|
||||
for oi := range optlist {
|
||||
op := &optlist[oi]
|
||||
|
||||
isBoolOpt := op.kind == optBool || op.kind == optSeeFloor
|
||||
if strings.HasPrefix(op.name, name) && name != "" {
|
||||
if isBoolOpt {
|
||||
*op.boolP = true
|
||||
|
||||
return rest
|
||||
}
|
||||
|
||||
return g.parseOptValue(op, rest)
|
||||
}
|
||||
|
||||
if isBoolOpt && strings.HasPrefix(name, "no") &&
|
||||
strings.HasPrefix(op.name, name[2:]) {
|
||||
*op.boolP = false
|
||||
|
||||
return rest
|
||||
}
|
||||
}
|
||||
|
||||
return rest
|
||||
}
|
||||
|
||||
// parseOptValue consumes an option's "=value" from rest, storing it,
|
||||
// and returns the remainder (the string arm of options.c parse_opts).
|
||||
func (g *RogueGame) parseOptValue(op *optDesc, rest string) string {
|
||||
// Skip to start of string value
|
||||
for rest != "" && rest[0] == '=' {
|
||||
rest = rest[1:]
|
||||
}
|
||||
|
||||
val := rest
|
||||
|
||||
var prefix string
|
||||
if val != "" && val[0] == '~' {
|
||||
prefix = g.Home
|
||||
|
||||
val = val[1:]
|
||||
for val != "" && val[0] == '/' {
|
||||
val = val[1:]
|
||||
}
|
||||
}
|
||||
// Skip to end of string value
|
||||
end := strings.IndexByte(val, ',')
|
||||
if end < 0 {
|
||||
end = len(val)
|
||||
}
|
||||
|
||||
word := val[:end]
|
||||
|
||||
if op.kind == optInvT {
|
||||
g.parseInvType(op, word)
|
||||
} else {
|
||||
*op.strP = prefix + strucpy(word)
|
||||
}
|
||||
|
||||
return val[end:]
|
||||
}
|
||||
|
||||
// parseInvType matches an inventory-style name by prefix (options.c
|
||||
// parse_opts).
|
||||
func (g *RogueGame) parseInvType(op *optDesc, word string) {
|
||||
// check for type of inventory
|
||||
if word != "" {
|
||||
word = string(toUpper(word[0])) + word[1:]
|
||||
}
|
||||
|
||||
for ti, tn := range g.data.invTName {
|
||||
if strings.HasPrefix(tn, word) {
|
||||
*op.intP = ti
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// strucpy copies a string keeping only printable characters, capped at
|
||||
// MAXINP (options.c strucpy).
|
||||
func strucpy(s string) string {
|
||||
if len(s) > MaxInp {
|
||||
s = s[:MaxInp]
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
|
||||
for i := range len(s) {
|
||||
if isPrint(s[i]) || s[i] == ' ' {
|
||||
sb.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
454
game/pack.go
454
game/pack.go
@@ -7,98 +7,23 @@ package game
|
||||
func (g *RogueGame) addPack(obj *Object, silent bool) {
|
||||
p := &g.Player
|
||||
fromFloor := false
|
||||
|
||||
if obj == nil {
|
||||
if obj = g.findObj(p.Pos.Y, p.Pos.X); obj == nil {
|
||||
if obj = g.Level.ObjectAt(p.Pos.Y, p.Pos.X); obj == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fromFloor = true
|
||||
}
|
||||
|
||||
// Check for and deal with scare monster scrolls
|
||||
if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster && obj.Flags.Has(WasFound) {
|
||||
detachObj(&g.Level.Objects, obj)
|
||||
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
|
||||
if p.Room.Flags.Has(Gone) {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
|
||||
} else {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
|
||||
}
|
||||
g.msg("the scroll turns to dust as you pick it up")
|
||||
if g.pickupScareScroll(obj) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(p.Pack) == 0 {
|
||||
p.Pack = append(p.Pack, obj)
|
||||
obj.PackCh = g.packChar()
|
||||
p.Inpack++
|
||||
} else {
|
||||
// Walk the pack looking for the insertion point, keeping items of
|
||||
// one type together and merging stackable/grouped items — a direct
|
||||
// translation of the C linked-list walk. lp is the index to insert
|
||||
// after; -1 after a merge means no insertion.
|
||||
lp := -1
|
||||
merged := false
|
||||
for i := 0; i < len(p.Pack); i++ {
|
||||
if p.Pack[i].Kind != obj.Kind {
|
||||
lp = i
|
||||
continue
|
||||
}
|
||||
// found the group of our type: scan for matching subtype
|
||||
for p.Pack[i].Kind == obj.Kind && p.Pack[i].Which != obj.Which {
|
||||
lp = i
|
||||
if i+1 >= len(p.Pack) {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
op := p.Pack[i]
|
||||
if op.Kind == obj.Kind && op.Which == obj.Which {
|
||||
if op.Kind.MergesInPack() {
|
||||
if !g.packRoom(fromFloor, obj) {
|
||||
return
|
||||
}
|
||||
op.Count++
|
||||
obj = op
|
||||
lp = -1
|
||||
merged = true
|
||||
} else if obj.Group != 0 {
|
||||
lp = i
|
||||
for p.Pack[i].Kind == obj.Kind &&
|
||||
p.Pack[i].Which == obj.Which &&
|
||||
p.Pack[i].Group != obj.Group {
|
||||
lp = i
|
||||
if i+1 >= len(p.Pack) {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
op = p.Pack[i]
|
||||
if op.Kind == obj.Kind && op.Which == obj.Which &&
|
||||
op.Group == obj.Group {
|
||||
op.Count += obj.Count
|
||||
p.Inpack--
|
||||
if !g.packRoom(fromFloor, obj) {
|
||||
return
|
||||
}
|
||||
obj = op
|
||||
lp = -1
|
||||
merged = true
|
||||
}
|
||||
} else {
|
||||
lp = i
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if !merged && lp != -1 {
|
||||
if !g.packRoom(fromFloor, obj) {
|
||||
return
|
||||
}
|
||||
obj.PackCh = g.packChar()
|
||||
p.Pack = append(p.Pack[:lp+1],
|
||||
append([]*Object{obj}, p.Pack[lp+1:]...)...)
|
||||
}
|
||||
obj, ok := g.packInsert(obj, fromFloor)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
obj.Flags.Set(WasFound)
|
||||
@@ -117,119 +42,264 @@ func (g *RogueGame) addPack(obj *Object, silent bool) {
|
||||
// Notify the user
|
||||
if !silent {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("you now have ")
|
||||
g.addmsgf("you now have ")
|
||||
}
|
||||
g.msg("%s (%c)", g.invName(obj, !g.Options.Terse), obj.PackCh)
|
||||
|
||||
g.msg("%s (%c)", g.inventoryName(obj, !g.Options.Terse), obj.PackCh)
|
||||
}
|
||||
}
|
||||
|
||||
// pickupScareScroll crumbles a found scare monster scroll when it is
|
||||
// picked up again; it reports whether it did (pack.c add_pack).
|
||||
func (g *RogueGame) pickupScareScroll(obj *Object) bool {
|
||||
if obj.Kind != KindScroll || obj.ScrollKind() != ScrollScareMonster ||
|
||||
!obj.Flags.Has(WasFound) {
|
||||
return false
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
|
||||
g.Level.RemoveObject(obj)
|
||||
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
|
||||
|
||||
if p.Room.Flags.Has(Gone) {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
|
||||
} else {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
|
||||
}
|
||||
|
||||
g.msg("the scroll turns to dust as you pick it up")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// packInsert places the object in the pack, keeping items of one type
|
||||
// together and merging stackable and grouped items — a translation of
|
||||
// the C linked-list walk in pack.c add_pack. It returns the pack entry
|
||||
// the object ended up as; ok is false when the pack has no room.
|
||||
func (g *RogueGame) packInsert(obj *Object, fromFloor bool) (*Object, bool) {
|
||||
p := &g.Player
|
||||
if len(p.Pack) == 0 {
|
||||
p.Pack = append(p.Pack, obj)
|
||||
obj.PackCh = p.nextPackChar()
|
||||
p.Inpack++
|
||||
|
||||
return obj, true
|
||||
}
|
||||
|
||||
merged := false
|
||||
|
||||
// lp is the index to insert after; -1 after a merge means no
|
||||
// insertion.
|
||||
i, lp := packScanKind(p.Pack, obj.Kind)
|
||||
if i < len(p.Pack) {
|
||||
i, lp = packScanWhich(p.Pack, obj, i, lp)
|
||||
|
||||
if op := p.Pack[i]; op.Kind == obj.Kind && op.Which == obj.Which {
|
||||
var ok bool
|
||||
|
||||
obj, lp, merged, ok = g.packMatch(obj, op, i, fromFloor)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !merged && lp != -1 {
|
||||
if !g.packRoom(fromFloor, obj) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
obj.PackCh = p.nextPackChar()
|
||||
p.Pack = append(p.Pack[:lp+1],
|
||||
append([]*Object{obj}, p.Pack[lp+1:]...)...)
|
||||
}
|
||||
|
||||
return obj, true
|
||||
}
|
||||
|
||||
// packScanKind scans to the first pack entry of this kind, returning
|
||||
// its index (or the pack length) and the entry to insert after (the
|
||||
// outer scan of pack.c add_pack).
|
||||
func packScanKind(pack []*Object, kind ObjectKind) (int, int) {
|
||||
lp := -1
|
||||
|
||||
i := 0
|
||||
for ; i < len(pack); i++ {
|
||||
if pack[i].Kind == kind {
|
||||
break
|
||||
}
|
||||
|
||||
lp = i
|
||||
}
|
||||
|
||||
return i, lp
|
||||
}
|
||||
|
||||
// packScanWhich scans within the kind group for the matching subtype
|
||||
// (the inner scan of pack.c add_pack).
|
||||
func packScanWhich(pack []*Object, obj *Object, i, lp int) (int, int) {
|
||||
for pack[i].Kind == obj.Kind && pack[i].Which != obj.Which {
|
||||
lp = i
|
||||
if i+1 >= len(pack) {
|
||||
break
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
return i, lp
|
||||
}
|
||||
|
||||
// packMatch merges the object with a matching pack entry when possible:
|
||||
// stackables merge counts, grouped missiles rejoin their bundle, and
|
||||
// anything else marks the insertion point (the matched-subtype switch
|
||||
// of pack.c add_pack). It returns the resulting entry, the insert-after
|
||||
// index, whether a merge happened, and ok false when the pack is full.
|
||||
func (g *RogueGame) packMatch(
|
||||
obj, op *Object, i int, fromFloor bool,
|
||||
) (*Object, int, bool, bool) {
|
||||
switch {
|
||||
case op.Kind.MergesInPack():
|
||||
if !g.packRoom(fromFloor, obj) {
|
||||
return nil, 0, false, false
|
||||
}
|
||||
|
||||
op.Count++
|
||||
|
||||
return op, -1, true, true
|
||||
case obj.Group != 0:
|
||||
return g.packMatchGroup(obj, i, fromFloor)
|
||||
default:
|
||||
return obj, i, false, true
|
||||
}
|
||||
}
|
||||
|
||||
// packMatchGroup rejoins a grouped missile bundle with its group entry
|
||||
// (the o_group arm of pack.c add_pack).
|
||||
func (g *RogueGame) packMatchGroup(
|
||||
obj *Object, i int, fromFloor bool,
|
||||
) (*Object, int, bool, bool) {
|
||||
p := &g.Player
|
||||
|
||||
lp := i
|
||||
for p.Pack[i].Kind == obj.Kind &&
|
||||
p.Pack[i].Which == obj.Which &&
|
||||
p.Pack[i].Group != obj.Group {
|
||||
lp = i
|
||||
if i+1 >= len(p.Pack) {
|
||||
break
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
op := p.Pack[i]
|
||||
if op.Kind == obj.Kind && op.Which == obj.Which &&
|
||||
op.Group == obj.Group {
|
||||
op.Count += obj.Count
|
||||
p.Inpack--
|
||||
|
||||
if !g.packRoom(fromFloor, obj) {
|
||||
return nil, 0, false, false
|
||||
}
|
||||
|
||||
return op, -1, true, true
|
||||
}
|
||||
|
||||
return obj, lp, false, true
|
||||
}
|
||||
|
||||
// packRoom sees if there's room in the pack; if not, prints an appropriate
|
||||
// message (pack.c pack_room).
|
||||
func (g *RogueGame) packRoom(fromFloor bool, obj *Object) bool {
|
||||
p := &g.Player
|
||||
if p.Inpack++; p.Inpack > MaxPack {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("there's ")
|
||||
g.addmsgf("there's ")
|
||||
}
|
||||
g.addmsg("no room")
|
||||
|
||||
g.addmsgf("no room")
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(" in your pack")
|
||||
g.addmsgf(" in your pack")
|
||||
}
|
||||
|
||||
g.endmsg()
|
||||
|
||||
if fromFloor {
|
||||
g.moveMsg(obj)
|
||||
}
|
||||
|
||||
p.Inpack = MaxPack
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if fromFloor {
|
||||
detachObj(&g.Level.Objects, obj)
|
||||
g.Level.RemoveObject(obj)
|
||||
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
|
||||
|
||||
if p.Room.Flags.Has(Gone) {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
|
||||
} else {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// leavePack takes an item out of the pack (pack.c leave_pack).
|
||||
// leavePack takes an item out of the pack (pack.c leave_pack), keeping
|
||||
// the repeat-command bookkeeping; the pack surgery is
|
||||
// Player.removeFromPack.
|
||||
func (g *RogueGame) leavePack(obj *Object, newobj, all bool) *Object {
|
||||
p := &g.Player
|
||||
p.Inpack--
|
||||
nobj := obj
|
||||
if obj.Count > 1 && !all {
|
||||
g.LastPick = obj
|
||||
obj.Count--
|
||||
if obj.Group != 0 {
|
||||
p.Inpack++
|
||||
}
|
||||
if newobj {
|
||||
copied := *obj
|
||||
nobj = &copied
|
||||
nobj.Count = 1
|
||||
}
|
||||
} else {
|
||||
g.LastPick = nil
|
||||
p.PackUsed[obj.PackCh-'a'] = false
|
||||
detachObj(&p.Pack, obj)
|
||||
}
|
||||
return nobj
|
||||
}
|
||||
|
||||
// packChar returns the next unused pack character (pack.c pack_char).
|
||||
func (g *RogueGame) packChar() byte {
|
||||
p := &g.Player
|
||||
for i := range p.PackUsed {
|
||||
if !p.PackUsed[i] {
|
||||
p.PackUsed[i] = true
|
||||
return byte(i) + 'a'
|
||||
}
|
||||
}
|
||||
return byte(len(p.PackUsed)) + 'a' // C would walk off the array here
|
||||
return g.Player.removeFromPack(obj, newobj, all)
|
||||
}
|
||||
|
||||
// inventory lists what is in the pack; returns true if there is something
|
||||
// of the given type (pack.c inventory).
|
||||
func (g *RogueGame) inventory(list []*Object, kind ObjectKind) bool {
|
||||
g.NObjs = 0
|
||||
|
||||
for _, item := range list {
|
||||
if kind != KindNone && kind != item.Kind &&
|
||||
!(kind == KindCallable && item.Kind != KindFood &&
|
||||
item.Kind != KindAmulet) &&
|
||||
!(kind == KindRingOrStick &&
|
||||
(item.Kind == KindRing || item.Kind == KindWand)) {
|
||||
if !matchesFilter(kind, item) {
|
||||
continue
|
||||
}
|
||||
|
||||
g.NObjs++
|
||||
g.Msgs.MsgEsc = true
|
||||
line := string(item.PackCh) + ") " + g.invName(item, false)
|
||||
|
||||
line := string(item.PackCh) + ") " + g.inventoryName(item, false)
|
||||
if g.addLine("%s", line) == Escape {
|
||||
g.Msgs.MsgEsc = false
|
||||
g.msg("")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
g.Msgs.MsgEsc = false
|
||||
}
|
||||
|
||||
if g.NObjs == 0 {
|
||||
if g.Options.Terse {
|
||||
if kind == KindNone {
|
||||
g.msg("empty handed")
|
||||
} else {
|
||||
g.msg("nothing appropriate")
|
||||
}
|
||||
if kind == KindNone {
|
||||
g.msg("%s", g.chooseTerse("empty handed", "you are empty handed"))
|
||||
} else {
|
||||
if kind == KindNone {
|
||||
g.msg("you are empty handed")
|
||||
} else {
|
||||
g.msg("you don't have anything appropriate")
|
||||
}
|
||||
g.msg("%s", g.chooseTerse("nothing appropriate",
|
||||
"you don't have anything appropriate"))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
g.endLine()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -240,31 +310,54 @@ func (g *RogueGame) pickUp(ch byte) {
|
||||
return
|
||||
}
|
||||
|
||||
obj := g.findObj(p.Pos.Y, p.Pos.X)
|
||||
obj := g.Level.ObjectAt(p.Pos.Y, p.Pos.X)
|
||||
if g.MoveOn {
|
||||
g.moveMsg(obj)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case Gold:
|
||||
if obj == nil {
|
||||
return
|
||||
}
|
||||
|
||||
g.money(obj.GoldValue)
|
||||
detachObj(&g.Level.Objects, obj)
|
||||
g.Level.RemoveObject(obj)
|
||||
|
||||
p.Room.GoldVal = 0
|
||||
default:
|
||||
g.addPack(nil, false)
|
||||
}
|
||||
}
|
||||
|
||||
// matchesFilter reports whether an item passes a get_item/inventory kind
|
||||
// filter (the C condition in pack.c inventory, untangled): KindNone takes
|
||||
// everything, KindCallable takes anything nameable (not food, not the
|
||||
// amulet), KindRingOrStick takes rings and wands.
|
||||
func matchesFilter(kind ObjectKind, item *Object) bool {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch kind {
|
||||
case KindNone:
|
||||
return true
|
||||
case KindCallable:
|
||||
return item.Kind != KindFood && item.Kind != KindAmulet
|
||||
case KindRingOrStick:
|
||||
return item.Kind == KindRing || item.Kind == KindWand
|
||||
default:
|
||||
return item.Kind == kind
|
||||
}
|
||||
}
|
||||
|
||||
// moveMsg prints the message if you are just moving onto an object
|
||||
// (pack.c move_msg).
|
||||
func (g *RogueGame) moveMsg(obj *Object) {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("you ")
|
||||
g.addmsgf("you ")
|
||||
}
|
||||
g.msg("moved onto %s", g.invName(obj, true))
|
||||
|
||||
g.msg("moved onto %s", g.inventoryName(obj, true))
|
||||
}
|
||||
|
||||
// pickyInven allows the player to inventory a single item (pack.c
|
||||
@@ -273,51 +366,55 @@ func (g *RogueGame) pickyInven() {
|
||||
p := &g.Player
|
||||
if len(p.Pack) == 0 {
|
||||
g.msg("you aren't carrying anything")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(p.Pack) == 1 {
|
||||
g.msg("a) %s", g.invName(p.Pack[0], false))
|
||||
g.msg("a) %s", g.inventoryName(p.Pack[0], false))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
g.msg("%s", g.chooseTerse("item: ", "which item do you wish to inventory: "))
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
mch := g.readchar()
|
||||
if mch == Escape {
|
||||
g.msg("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, obj := range p.Pack {
|
||||
if mch == obj.PackCh {
|
||||
g.msg("%c) %s", mch, g.invName(obj, false))
|
||||
g.msg("%c) %s", mch, g.inventoryName(obj, false))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
g.msg("'%s' not in pack", unctrl(mch))
|
||||
}
|
||||
|
||||
// getItem picks something out of a pack for a purpose (pack.c get_item).
|
||||
func (g *RogueGame) getItem(purpose string, kind ObjectKind) *Object {
|
||||
// promptPackItem picks something out of a pack for a purpose (pack.c
|
||||
// get_item); the second result reports whether the player chose an
|
||||
// item.
|
||||
func (g *RogueGame) promptPackItem(purpose string, kind ObjectKind) (*Object, bool) {
|
||||
p := &g.Player
|
||||
if len(p.Pack) == 0 {
|
||||
g.msg("you aren't carrying anything")
|
||||
return nil
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if g.Again {
|
||||
if g.LastPick != nil {
|
||||
return g.LastPick
|
||||
}
|
||||
g.msg("you ran out")
|
||||
return nil
|
||||
return g.repeatLastItem()
|
||||
}
|
||||
|
||||
for {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("which object do you want to ")
|
||||
}
|
||||
g.addmsg("%s", purpose)
|
||||
if g.Options.Terse {
|
||||
g.addmsg(" what")
|
||||
}
|
||||
g.msg("? (* for list): ")
|
||||
g.promptItemPurpose(purpose)
|
||||
|
||||
ch := g.readchar()
|
||||
g.Msgs.Mpos = 0
|
||||
// Give the poor player a chance to abort the command
|
||||
@@ -325,40 +422,77 @@ func (g *RogueGame) getItem(purpose string, kind ObjectKind) *Object {
|
||||
g.resetLast()
|
||||
g.After = false
|
||||
g.msg("")
|
||||
return nil
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
g.NObjs = 1 // normal case: person types one char
|
||||
if ch == '*' {
|
||||
g.Msgs.Mpos = 0
|
||||
if !g.inventory(p.Pack, kind) {
|
||||
g.After = false
|
||||
return nil
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for _, obj := range p.Pack {
|
||||
if obj.PackCh == ch {
|
||||
return obj
|
||||
return obj, true
|
||||
}
|
||||
}
|
||||
|
||||
g.msg("'%s' is not a valid item", unctrl(ch))
|
||||
}
|
||||
}
|
||||
|
||||
// repeatLastItem replays the previous selection for the repeat command
|
||||
// (pack.c get_item).
|
||||
func (g *RogueGame) repeatLastItem() (*Object, bool) {
|
||||
if g.LastPick != nil {
|
||||
return g.LastPick, true
|
||||
}
|
||||
|
||||
g.msg("you ran out")
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// promptItemPurpose prints the "which object do you want to ...?"
|
||||
// prompt (pack.c get_item).
|
||||
func (g *RogueGame) promptItemPurpose(purpose string) {
|
||||
if !g.Options.Terse {
|
||||
g.addmsgf("which object do you want to ")
|
||||
}
|
||||
|
||||
g.addmsgf("%s", purpose)
|
||||
|
||||
if g.Options.Terse {
|
||||
g.addmsgf(" what")
|
||||
}
|
||||
|
||||
g.msg("? (* for list): ")
|
||||
}
|
||||
|
||||
// money adds or subtracts gold from the pack (pack.c money).
|
||||
func (g *RogueGame) money(value int) {
|
||||
p := &g.Player
|
||||
p.Purse += value
|
||||
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh())
|
||||
|
||||
if p.Room.Flags.Has(Gone) {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage)
|
||||
} else {
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor)
|
||||
}
|
||||
|
||||
if value > 0 {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("you found ")
|
||||
g.addmsgf("you found ")
|
||||
}
|
||||
|
||||
g.msg("%d gold pieces", value)
|
||||
}
|
||||
}
|
||||
@@ -369,9 +503,11 @@ func (g *RogueGame) floorCh() byte {
|
||||
if g.Player.Room.Flags.Has(Gone) {
|
||||
return Passage
|
||||
}
|
||||
|
||||
if g.showFloor() {
|
||||
return Floor
|
||||
}
|
||||
|
||||
return ' '
|
||||
}
|
||||
|
||||
@@ -382,6 +518,7 @@ func (g *RogueGame) floorAt() byte {
|
||||
if ch == Floor {
|
||||
ch = g.floorCh()
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
@@ -398,5 +535,6 @@ func (g *RogueGame) chooseTerse(terse, verbose string) string {
|
||||
if g.Options.Terse {
|
||||
return terse
|
||||
}
|
||||
|
||||
return verbose
|
||||
}
|
||||
|
||||
464
game/passages.go
464
game/passages.go
@@ -1,222 +1,271 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// passages.c — draw the connecting passages.
|
||||
|
||||
// rdesConn is the hardcoded 3x3 room adjacency matrix from do_passages.
|
||||
var rdesConn = [MaxRooms][MaxRooms]bool{
|
||||
{false, true, false, true, false, false, false, false, false},
|
||||
{true, false, true, false, true, false, false, false, false},
|
||||
{false, true, false, false, false, true, false, false, false},
|
||||
{true, false, false, false, true, false, true, false, false},
|
||||
{false, true, false, true, false, true, false, true, false},
|
||||
{false, false, true, false, true, false, false, false, true},
|
||||
{false, false, false, true, false, false, false, true, false},
|
||||
{false, false, false, false, true, false, true, false, true},
|
||||
{false, false, false, false, false, true, false, true, false},
|
||||
}
|
||||
|
||||
// doPassages draws all the passages on a level (passages.c do_passages).
|
||||
func (g *RogueGame) doPassages() {
|
||||
var isconn [MaxRooms][MaxRooms]bool
|
||||
var ingraph [MaxRooms]bool
|
||||
// digPassages draws all the passages on a level (passages.c do_passages).
|
||||
func (g *RogueGame) digPassages() {
|
||||
var (
|
||||
isconn [MaxRooms][MaxRooms]bool
|
||||
ingraph [MaxRooms]bool
|
||||
)
|
||||
|
||||
// starting with one room, connect it to a random adjacent room and
|
||||
// then pick a new room to start with.
|
||||
roomcount := 1
|
||||
r1 := g.rnd(MaxRooms)
|
||||
ingraph[r1] = true
|
||||
for {
|
||||
|
||||
for roomcount < MaxRooms {
|
||||
// find a room to connect with
|
||||
j := 0
|
||||
r2 := -1
|
||||
for i := 0; i < MaxRooms; i++ {
|
||||
if rdesConn[r1][i] && !ingraph[i] {
|
||||
if j++; g.rnd(j) == 0 {
|
||||
r2 = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if j == 0 {
|
||||
// if no adjacent rooms are outside the graph, pick a new room
|
||||
// to look from
|
||||
r2 := g.pickNeighbor(r1, func(i int) bool { return !ingraph[i] })
|
||||
if r2 < 0 {
|
||||
// if no adjacent rooms are outside the graph, pick a new
|
||||
// room to look from
|
||||
for {
|
||||
r1 = g.rnd(MaxRooms)
|
||||
if ingraph[r1] {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// otherwise, connect new room to the graph, and draw a tunnel
|
||||
// to it
|
||||
ingraph[r2] = true
|
||||
g.conn(r1, r2)
|
||||
isconn[r1][r2] = true
|
||||
isconn[r2][r1] = true
|
||||
roomcount++
|
||||
}
|
||||
if roomcount >= MaxRooms {
|
||||
break
|
||||
|
||||
continue
|
||||
}
|
||||
// otherwise, connect new room to the graph, and draw a tunnel
|
||||
// to it
|
||||
ingraph[r2] = true
|
||||
g.connectRooms(r1, r2)
|
||||
isconn[r1][r2] = true
|
||||
isconn[r2][r1] = true
|
||||
roomcount++
|
||||
}
|
||||
|
||||
// attempt to add passages to the graph a random number of times so that
|
||||
// there isn't always just one unique passage through it.
|
||||
for roomcount = g.rnd(5); roomcount > 0; roomcount-- {
|
||||
r1 = g.rnd(MaxRooms) // a random room to look from
|
||||
// find an adjacent room not already connected
|
||||
j := 0
|
||||
r2 := -1
|
||||
for i := 0; i < MaxRooms; i++ {
|
||||
if rdesConn[r1][i] && !isconn[r1][i] {
|
||||
if j++; g.rnd(j) == 0 {
|
||||
r2 = i
|
||||
}
|
||||
}
|
||||
}
|
||||
// if there is one, connect it and look for the next added passage
|
||||
if j != 0 {
|
||||
g.conn(r1, r2)
|
||||
// find an adjacent room not already connected; if there is one,
|
||||
// connect it and look for the next added passage
|
||||
r2 := g.pickNeighbor(r1, func(i int) bool { return !isconn[r1][i] })
|
||||
if r2 >= 0 {
|
||||
g.connectRooms(r1, r2)
|
||||
isconn[r1][r2] = true
|
||||
isconn[r2][r1] = true
|
||||
}
|
||||
}
|
||||
g.passnum()
|
||||
|
||||
g.numberPassages()
|
||||
}
|
||||
|
||||
// conn draws a corridor from a room in a certain direction (passages.c
|
||||
// conn).
|
||||
func (g *RogueGame) conn(r1, r2 int) {
|
||||
var rm int
|
||||
var direc byte
|
||||
if r1 < r2 {
|
||||
rm = r1
|
||||
if r1+1 == r2 {
|
||||
direc = 'r'
|
||||
} else {
|
||||
direc = 'd'
|
||||
}
|
||||
} else {
|
||||
rm = r2
|
||||
if r2+1 == r1 {
|
||||
direc = 'r'
|
||||
} else {
|
||||
direc = 'd'
|
||||
// pickNeighbor reservoir-picks an adjacent room for which ok holds, or
|
||||
// -1 when there is none (passages.c do_passages).
|
||||
func (g *RogueGame) pickNeighbor(r1 int, ok func(int) bool) int {
|
||||
j := 0
|
||||
r2 := -1
|
||||
|
||||
for i := range MaxRooms {
|
||||
if g.data.rdesConn[r1][i] && ok(i) {
|
||||
if j++; g.rnd(j) == 0 {
|
||||
r2 = i
|
||||
}
|
||||
}
|
||||
}
|
||||
rpf := &g.Level.Rooms[rm]
|
||||
|
||||
// Set up the movement variables, in two cases: first drawing one down.
|
||||
var rpt *Room
|
||||
var del, turnDelta, spos, epos Coord
|
||||
var distance, turnDistance int
|
||||
return r2
|
||||
}
|
||||
|
||||
// corridorPlan is the movement setup connectRooms computes before it
|
||||
// digs (the local variables of passages.c conn).
|
||||
type corridorPlan struct {
|
||||
rpf, rpt *Room // the rooms being joined
|
||||
del Coord // direction of move
|
||||
turnDelta Coord // direction to turn
|
||||
spos, epos Coord // start and end of move
|
||||
distance, turnDistance int // how far to move and to turn
|
||||
}
|
||||
|
||||
// connectRooms draws a corridor from a room in a certain direction
|
||||
// (passages.c conn).
|
||||
func (g *RogueGame) connectRooms(r1, r2 int) {
|
||||
rm, direc := connOrient(r1, r2)
|
||||
|
||||
var plan corridorPlan
|
||||
if direc == 'd' {
|
||||
rmt := rm + 3 // room # of dest
|
||||
rpt = &g.Level.Rooms[rmt] // room pointer of dest
|
||||
del = Coord{X: 0, Y: 1} // direction of move
|
||||
spos = rpf.Pos // start of move
|
||||
epos = rpt.Pos // end of move
|
||||
if !rpf.Flags.Has(Gone) { // if not gone pick door pos
|
||||
for {
|
||||
spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1
|
||||
spos.Y = rpf.Pos.Y + rpf.Max.Y - 1
|
||||
if !(rpf.Flags.Has(Maze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rpt.Flags.Has(Gone) {
|
||||
for {
|
||||
epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1
|
||||
if !(rpt.Flags.Has(Maze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
distance = abs(spos.Y-epos.Y) - 1 // distance to move
|
||||
turnDelta.Y = 0 // direction to turn
|
||||
if spos.X < epos.X {
|
||||
turnDelta.X = 1
|
||||
} else {
|
||||
turnDelta.X = -1
|
||||
}
|
||||
turnDistance = abs(spos.X - epos.X) // how far to turn
|
||||
} else { // setup for moving right
|
||||
rmt := rm + 1
|
||||
rpt = &g.Level.Rooms[rmt]
|
||||
del = Coord{X: 1, Y: 0}
|
||||
spos = rpf.Pos
|
||||
epos = rpt.Pos
|
||||
if !rpf.Flags.Has(Gone) {
|
||||
for {
|
||||
spos.X = rpf.Pos.X + rpf.Max.X - 1
|
||||
spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1
|
||||
if !(rpf.Flags.Has(Maze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rpt.Flags.Has(Gone) {
|
||||
for {
|
||||
epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1
|
||||
if !(rpt.Flags.Has(Maze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
distance = abs(spos.X-epos.X) - 1
|
||||
if spos.Y < epos.Y {
|
||||
turnDelta.Y = 1
|
||||
} else {
|
||||
turnDelta.Y = -1
|
||||
}
|
||||
turnDelta.X = 0
|
||||
turnDistance = abs(spos.Y - epos.Y)
|
||||
plan = g.connPlanDown(rm)
|
||||
} else {
|
||||
plan = g.connPlanRight(rm)
|
||||
}
|
||||
|
||||
turnSpot := g.rnd(distance-1) + 1 // where turn starts
|
||||
turnSpot := g.rnd(plan.distance-1) + 1 // where turn starts
|
||||
|
||||
// Draw in the doors on either side of the passage or just put #'s if
|
||||
// the rooms are gone.
|
||||
if !rpf.Flags.Has(Gone) {
|
||||
g.door(rpf, spos)
|
||||
} else {
|
||||
g.putpass(spos)
|
||||
g.connEnd(plan.rpf, plan.spos)
|
||||
g.connEnd(plan.rpt, plan.epos)
|
||||
|
||||
g.digCorridor(plan, turnSpot)
|
||||
}
|
||||
|
||||
// connOrient picks the upper-left room of the pair and the digging
|
||||
// direction: right for horizontal neighbors, down otherwise (passages.c
|
||||
// conn).
|
||||
func connOrient(r1, r2 int) (int, byte) {
|
||||
rm := min(r1, r2)
|
||||
if abs(r1-r2) == 1 {
|
||||
return rm, 'r'
|
||||
}
|
||||
|
||||
return rm, 'd'
|
||||
}
|
||||
|
||||
// connPlanDown sets up the movement variables for a corridor drawn
|
||||
// downward (passages.c conn).
|
||||
func (g *RogueGame) connPlanDown(rm int) corridorPlan {
|
||||
rpf := &g.Level.Rooms[rm]
|
||||
rpt := &g.Level.Rooms[rm+3] // room pointer of dest
|
||||
|
||||
plan := corridorPlan{
|
||||
rpf: rpf,
|
||||
rpt: rpt,
|
||||
del: Coord{X: 0, Y: 1}, // direction of move
|
||||
spos: rpf.Pos, // start of move
|
||||
epos: rpt.Pos, // end of move
|
||||
}
|
||||
|
||||
if !rpf.Flags.Has(Gone) { // if not gone pick door pos
|
||||
for {
|
||||
plan.spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1
|
||||
|
||||
plan.spos.Y = rpf.Pos.Y + rpf.Max.Y - 1
|
||||
if !rpf.Flags.Has(Maze) ||
|
||||
g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !rpt.Flags.Has(Gone) {
|
||||
g.door(rpt, epos)
|
||||
} else {
|
||||
g.putpass(epos)
|
||||
for {
|
||||
plan.epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1
|
||||
if !rpt.Flags.Has(Maze) ||
|
||||
g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get ready to move...
|
||||
curr := spos
|
||||
|
||||
plan.distance = abs(plan.spos.Y-plan.epos.Y) - 1 // distance to move
|
||||
|
||||
plan.turnDelta.Y = 0 // direction to turn
|
||||
if plan.spos.X < plan.epos.X {
|
||||
plan.turnDelta.X = 1
|
||||
} else {
|
||||
plan.turnDelta.X = -1
|
||||
}
|
||||
|
||||
plan.turnDistance = abs(plan.spos.X - plan.epos.X) // how far to turn
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
// connPlanRight sets up the movement variables for a corridor drawn to
|
||||
// the right (passages.c conn).
|
||||
func (g *RogueGame) connPlanRight(rm int) corridorPlan {
|
||||
rpf := &g.Level.Rooms[rm]
|
||||
rpt := &g.Level.Rooms[rm+1]
|
||||
|
||||
plan := corridorPlan{
|
||||
rpf: rpf,
|
||||
rpt: rpt,
|
||||
del: Coord{X: 1, Y: 0},
|
||||
spos: rpf.Pos,
|
||||
epos: rpt.Pos,
|
||||
}
|
||||
|
||||
if !rpf.Flags.Has(Gone) {
|
||||
for {
|
||||
plan.spos.X = rpf.Pos.X + rpf.Max.X - 1
|
||||
|
||||
plan.spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1
|
||||
if !rpf.Flags.Has(Maze) ||
|
||||
g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !rpt.Flags.Has(Gone) {
|
||||
for {
|
||||
plan.epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1
|
||||
if !rpt.Flags.Has(Maze) ||
|
||||
g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan.distance = abs(plan.spos.X-plan.epos.X) - 1
|
||||
if plan.spos.Y < plan.epos.Y {
|
||||
plan.turnDelta.Y = 1
|
||||
} else {
|
||||
plan.turnDelta.Y = -1
|
||||
}
|
||||
|
||||
plan.turnDelta.X = 0
|
||||
plan.turnDistance = abs(plan.spos.Y - plan.epos.Y)
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
// connEnd draws a corridor end: a door on a real room, a passage square
|
||||
// on a gone one (passages.c conn).
|
||||
func (g *RogueGame) connEnd(rp *Room, pos Coord) {
|
||||
if !rp.Flags.Has(Gone) {
|
||||
g.door(rp, pos)
|
||||
} else {
|
||||
g.putPassage(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// digCorridor digs from spos to epos, turning at turnSpot (the digging
|
||||
// loop of passages.c conn).
|
||||
func (g *RogueGame) digCorridor(plan corridorPlan, turnSpot int) {
|
||||
curr := plan.spos
|
||||
distance := plan.distance
|
||||
turnDistance := plan.turnDistance
|
||||
|
||||
for distance > 0 {
|
||||
// Move to new position
|
||||
curr.X += del.X
|
||||
curr.Y += del.Y
|
||||
curr.X += plan.del.X
|
||||
curr.Y += plan.del.Y
|
||||
// Check if we are at the turn place, if so do the turn
|
||||
if distance == turnSpot {
|
||||
for ; turnDistance > 0; turnDistance-- {
|
||||
g.putpass(curr)
|
||||
curr.X += turnDelta.X
|
||||
curr.Y += turnDelta.Y
|
||||
g.putPassage(curr)
|
||||
curr.X += plan.turnDelta.X
|
||||
curr.Y += plan.turnDelta.Y
|
||||
}
|
||||
}
|
||||
// Continue digging along
|
||||
g.putpass(curr)
|
||||
g.putPassage(curr)
|
||||
|
||||
distance--
|
||||
}
|
||||
curr.X += del.X
|
||||
curr.Y += del.Y
|
||||
if curr != epos {
|
||||
|
||||
curr.X += plan.del.X
|
||||
|
||||
curr.Y += plan.del.Y
|
||||
if curr != plan.epos {
|
||||
g.msg("warning, connectivity problem on this level")
|
||||
}
|
||||
}
|
||||
|
||||
// putpass adds a passage character or secret passage here (passages.c
|
||||
// putPassage adds a passage character or secret passage here (passages.c
|
||||
// putpass).
|
||||
func (g *RogueGame) putpass(cp Coord) {
|
||||
func (g *RogueGame) putPassage(cp Coord) {
|
||||
pp := g.Level.At(cp.Y, cp.X)
|
||||
pp.Flags.Set(FPassage)
|
||||
|
||||
if g.rnd(10)+1 < g.Depth && g.rnd(40) == 0 {
|
||||
pp.Flags.Clear(FReal)
|
||||
} else {
|
||||
@@ -240,6 +289,7 @@ func (g *RogueGame) door(rm *Room, cp Coord) {
|
||||
} else {
|
||||
pp.Ch = '|'
|
||||
}
|
||||
|
||||
pp.Flags.Clear(FReal)
|
||||
} else {
|
||||
pp.Ch = Door
|
||||
@@ -250,79 +300,100 @@ func (g *RogueGame) door(rm *Room, cp Coord) {
|
||||
// (passages.c add_pass).
|
||||
func (g *RogueGame) addPass() {
|
||||
for y := 1; y < NumLines-1; y++ {
|
||||
for x := 0; x < NumCols; x++ {
|
||||
pp := g.Level.At(y, x)
|
||||
if pp.Flags.Has(FPassage) || pp.Ch == Door ||
|
||||
(!pp.Flags.Has(FReal) && (pp.Ch == '|' || pp.Ch == '-')) {
|
||||
ch := pp.Ch
|
||||
if pp.Flags.Has(FPassage) {
|
||||
ch = Passage
|
||||
}
|
||||
pp.Flags.Set(FSeen)
|
||||
g.move(y, x)
|
||||
if pp.Monst != nil {
|
||||
pp.Monst.OldCh = pp.Ch
|
||||
} else if pp.Flags.Has(FReal) {
|
||||
g.addch(ch)
|
||||
} else {
|
||||
g.standout()
|
||||
if pp.Flags.Has(FPassage) {
|
||||
g.addch(Passage)
|
||||
} else {
|
||||
g.addch(Door)
|
||||
}
|
||||
g.standend()
|
||||
}
|
||||
}
|
||||
for x := range NumCols {
|
||||
g.addPassSpot(g.Level.At(y, x), y, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// passnum assigns a number to each passageway (passages.c passnum).
|
||||
func (g *RogueGame) passnum() {
|
||||
// addPassSpot shows one passage or door square for the wizard (the loop
|
||||
// body of passages.c add_pass).
|
||||
func (g *RogueGame) addPassSpot(pp *Place, y, x int) {
|
||||
if !pp.Flags.Has(FPassage) && !hiddenExit(pp.Flags, pp.Ch) {
|
||||
return
|
||||
}
|
||||
|
||||
ch := pp.Ch
|
||||
if pp.Flags.Has(FPassage) {
|
||||
ch = Passage
|
||||
}
|
||||
|
||||
pp.Flags.Set(FSeen)
|
||||
g.move(y, x)
|
||||
|
||||
switch {
|
||||
case pp.Monst != nil:
|
||||
pp.Monst.OldCh = pp.Ch
|
||||
case pp.Flags.Has(FReal):
|
||||
g.addch(ch)
|
||||
default:
|
||||
g.standout()
|
||||
|
||||
if pp.Flags.Has(FPassage) {
|
||||
g.addch(Passage)
|
||||
} else {
|
||||
g.addch(Door)
|
||||
}
|
||||
|
||||
g.standend()
|
||||
}
|
||||
}
|
||||
|
||||
// hiddenExit reports a door, or a secret door still drawn as a wall
|
||||
// (passages.c add_pass / numpass).
|
||||
func hiddenExit(fp PlaceFlags, ch byte) bool {
|
||||
return ch == Door || (!fp.Has(FReal) && (ch == '|' || ch == '-'))
|
||||
}
|
||||
|
||||
// numberPassages assigns a number to each passageway (passages.c passnum).
|
||||
func (g *RogueGame) numberPassages() {
|
||||
g.pnum = 0
|
||||
|
||||
g.newpnum = false
|
||||
for i := range g.Level.Passages {
|
||||
g.Level.Passages[i].Exits = g.Level.Passages[i].Exits[:0]
|
||||
}
|
||||
|
||||
for i := range g.Level.Rooms {
|
||||
rp := &g.Level.Rooms[i]
|
||||
for j := range rp.Exits {
|
||||
g.newpnum = true
|
||||
g.numpass(rp.Exits[j].Y, rp.Exits[j].X)
|
||||
g.numberPassage(rp.Exits[j].Y, rp.Exits[j].X)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// numpass numbers a passageway square and its brethren (passages.c
|
||||
// numberPassage numbers a passageway square and its brethren (passages.c
|
||||
// numpass).
|
||||
func (g *RogueGame) numpass(y, x int) {
|
||||
func (g *RogueGame) numberPassage(y, x int) {
|
||||
if x >= NumCols || x < 0 || y >= NumLines || y <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fp := g.Level.FlagsAt(y, x)
|
||||
if fp.Has(FPassNum) {
|
||||
return
|
||||
}
|
||||
|
||||
if g.newpnum {
|
||||
g.pnum++
|
||||
g.newpnum = false
|
||||
}
|
||||
// check to see if it is a door or secret door, i.e., a new exit, or a
|
||||
// numerable type of place
|
||||
if ch := g.Level.Char(y, x); ch == Door ||
|
||||
(!fp.Has(FReal) && (ch == '|' || ch == '-')) {
|
||||
if hiddenExit(*fp, g.Level.Char(y, x)) {
|
||||
rp := &g.Level.Passages[g.pnum]
|
||||
rp.Exits = append(rp.Exits, Coord{Y: y, X: x})
|
||||
} else if !fp.Has(FPassage) {
|
||||
return
|
||||
}
|
||||
*fp |= PlaceFlags(g.pnum)
|
||||
|
||||
*fp |= PlaceFlags(g.pnum) //nolint:gosec // G115: pnum < MaxPass=13
|
||||
// recurse on the surrounding places
|
||||
g.numpass(y+1, x)
|
||||
g.numpass(y-1, x)
|
||||
g.numpass(y, x+1)
|
||||
g.numpass(y, x-1)
|
||||
g.numberPassage(y+1, x)
|
||||
g.numberPassage(y-1, x)
|
||||
g.numberPassage(y, x+1)
|
||||
g.numberPassage(y, x-1)
|
||||
}
|
||||
|
||||
// abs is C abs() for ints.
|
||||
@@ -330,5 +401,6 @@ func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
398
game/potions.go
398
game/potions.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
@@ -13,185 +14,236 @@ type pact struct {
|
||||
straight string
|
||||
}
|
||||
|
||||
// pActions is potions.c p_actions[]. The P_SEEINVIS message is dynamic
|
||||
// (it names the fruit) and is computed in doPot.
|
||||
var pActions = [NumPotionTypes]pact{
|
||||
PotionConfusion: {Confused, DUnconfuse, HuhDuration,
|
||||
"what a tripy feeling!",
|
||||
"wait, what's going on here. Huh? What? Who?"},
|
||||
PotionLSD: {Hallucinating, DComeDown, SeeDuration,
|
||||
"Oh, wow! Everything seems so cosmic!",
|
||||
"Oh, wow! Everything seems so cosmic!"},
|
||||
PotionSeeInvisible: {CanSeeInvisible, DUnsee, SeeDuration, "", ""},
|
||||
PotionBlindness: {Blind, DSight, SeeDuration,
|
||||
"oh, bummer! Everything is dark! Help!",
|
||||
"a cloak of darkness falls around you"},
|
||||
PotionLevitation: {Levitating, DLand, HealTime,
|
||||
"oh, wow! You're floating in the air!",
|
||||
"you start to float in the air"},
|
||||
}
|
||||
|
||||
// quaff drinks a potion from the pack (potions.c quaff).
|
||||
func (g *RogueGame) quaff() {
|
||||
p := &g.Player
|
||||
obj := g.getItem("quaff", KindPotion)
|
||||
obj, ok := g.promptPackItem("quaff", KindPotion)
|
||||
// Make certain that it is something that we want to drink
|
||||
if obj == nil {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Kind != KindPotion {
|
||||
if !g.Options.Terse {
|
||||
g.msg("yuk! Why would you want to drink that?")
|
||||
} else {
|
||||
g.msg("that's undrinkable")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if obj == p.CurWeapon {
|
||||
p.CurWeapon = nil
|
||||
}
|
||||
|
||||
// Calculate the effect it has on the poor guy.
|
||||
trip := p.On(Hallucinating)
|
||||
|
||||
g.leavePack(obj, false, false)
|
||||
switch obj.PotionKind() {
|
||||
case PotionConfusion:
|
||||
g.doPot(PotionConfusion, !trip)
|
||||
case PotionPoison:
|
||||
g.Items.Potions[PotionPoison].Know = true
|
||||
if p.IsWearing(RingSustainStrength) {
|
||||
g.msg("you feel momentarily sick")
|
||||
} else {
|
||||
g.chgStr(-(g.rnd(3) + 1))
|
||||
g.msg("you feel very sick now")
|
||||
g.comeDown(0)
|
||||
}
|
||||
case PotionHealing:
|
||||
g.Items.Potions[PotionHealing].Know = true
|
||||
if p.Stats.HP += g.roll(p.Stats.Lvl, 4); p.Stats.HP > p.Stats.MaxHP {
|
||||
p.Stats.MaxHP++
|
||||
p.Stats.HP = p.Stats.MaxHP
|
||||
}
|
||||
g.sight(0)
|
||||
g.msg("you begin to feel better")
|
||||
case PotionGainStrength:
|
||||
g.Items.Potions[PotionGainStrength].Know = true
|
||||
g.chgStr(1)
|
||||
g.msg("you feel stronger, now. What bulging muscles!")
|
||||
case PotionDetectMonsters:
|
||||
p.Flags.Set(SenseMonsters)
|
||||
g.Fuse(DTurnSee, 1, HuhDuration, After)
|
||||
if !g.turnSee(false) {
|
||||
g.msg("you have a %s feeling for a moment, then it passes",
|
||||
g.chooseStr("normal", "strange"))
|
||||
}
|
||||
case PotionDetectMagic:
|
||||
// Potion of magic detection. Show the potions and scrolls
|
||||
show := false
|
||||
if len(g.Level.Objects) > 0 {
|
||||
g.scr.Hw.Clear()
|
||||
for _, tp := range g.Level.Objects {
|
||||
if tp.isMagic() {
|
||||
show = true
|
||||
g.scr.Hw.MvAddCh(tp.Pos.Y, tp.Pos.X, Magic)
|
||||
g.Items.Potions[PotionDetectMagic].Know = true
|
||||
}
|
||||
}
|
||||
for _, mp := range g.Level.Monsters {
|
||||
for _, tp := range mp.Pack {
|
||||
if tp.isMagic() {
|
||||
show = true
|
||||
g.scr.Hw.MvAddCh(mp.Pos.Y, mp.Pos.X, Magic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if show {
|
||||
g.Items.Potions[PotionDetectMagic].Know = true
|
||||
g.showWin("You sense the presence of magic on this level.--More--")
|
||||
} else {
|
||||
g.msg("you have a %s feeling for a moment, then it passes",
|
||||
g.chooseStr("normal", "strange"))
|
||||
}
|
||||
case PotionLSD:
|
||||
if !trip {
|
||||
if p.On(SenseMonsters) {
|
||||
g.turnSee(false)
|
||||
}
|
||||
g.StartDaemon(DVisuals, 0, Before)
|
||||
g.SeenStairs = g.seenStairs()
|
||||
}
|
||||
g.doPot(PotionLSD, true)
|
||||
case PotionSeeInvisible:
|
||||
show := p.On(CanSeeInvisible)
|
||||
g.doPot(PotionSeeInvisible, false)
|
||||
if !show {
|
||||
g.invisOn()
|
||||
}
|
||||
g.sight(0)
|
||||
case PotionRaiseLevel:
|
||||
g.Items.Potions[PotionRaiseLevel].Know = true
|
||||
g.msg("you suddenly feel much more skillful")
|
||||
g.raiseLevel()
|
||||
case PotionExtraHealing:
|
||||
g.Items.Potions[PotionExtraHealing].Know = true
|
||||
if p.Stats.HP += g.roll(p.Stats.Lvl, 8); p.Stats.HP > p.Stats.MaxHP {
|
||||
if p.Stats.HP > p.Stats.MaxHP+p.Stats.Lvl+1 {
|
||||
p.Stats.MaxHP++
|
||||
}
|
||||
p.Stats.MaxHP++
|
||||
p.Stats.HP = p.Stats.MaxHP
|
||||
}
|
||||
g.sight(0)
|
||||
g.comeDown(0)
|
||||
g.msg("you begin to feel much better")
|
||||
case PotionHaste:
|
||||
g.Items.Potions[PotionHaste].Know = true
|
||||
g.After = false
|
||||
if g.addHaste(true) {
|
||||
g.msg("you feel yourself moving much faster")
|
||||
}
|
||||
case PotionRestoreStrength:
|
||||
if p.IsRing(Left, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, -p.CurRing[Left].Bonus)
|
||||
}
|
||||
if p.IsRing(Right, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, -p.CurRing[Right].Bonus)
|
||||
}
|
||||
if p.Stats.Str < p.MaxStats.Str {
|
||||
p.Stats.Str = p.MaxStats.Str
|
||||
}
|
||||
if p.IsRing(Left, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, p.CurRing[Left].Bonus)
|
||||
}
|
||||
if p.IsRing(Right, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, p.CurRing[Right].Bonus)
|
||||
}
|
||||
g.msg("hey, this tastes great. It make you feel warm all over")
|
||||
case PotionBlindness:
|
||||
g.doPot(PotionBlindness, true)
|
||||
case PotionLevitation:
|
||||
g.doPot(PotionLevitation, true)
|
||||
|
||||
if h := g.data.quaffHandler(obj); h != nil {
|
||||
h(g, trip)
|
||||
}
|
||||
|
||||
g.status()
|
||||
// Throw the item away
|
||||
g.callIt(&g.Items.Potions[obj.Which])
|
||||
// Throw the item away. A malformed potion has no lore entry to name,
|
||||
// so it is drunk for no effect and never prompts to be called anything.
|
||||
if obj.hasValidWhich() {
|
||||
g.callIt(&g.Items.Potions[obj.Which])
|
||||
}
|
||||
}
|
||||
|
||||
// The per-potion effect handlers, dispatched through
|
||||
// gameData.quaffHandlers. Each is one case of the C quaff switch.
|
||||
|
||||
func (g *RogueGame) quaffConfusion(trip bool) {
|
||||
g.applyPotionFuse(PotionConfusion, !trip)
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffPoison(bool) {
|
||||
g.Items.Potions[PotionPoison].Know = true
|
||||
if g.Player.IsWearing(RingSustainStrength) {
|
||||
g.msg("you feel momentarily sick")
|
||||
} else {
|
||||
g.changeStrength(-(g.rnd(3) + 1))
|
||||
g.msg("you feel very sick now")
|
||||
g.comeDown(0)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffHealing(bool) {
|
||||
p := &g.Player
|
||||
|
||||
g.Items.Potions[PotionHealing].Know = true
|
||||
if p.Stats.HP += g.roll(p.Stats.Lvl, 4); p.Stats.HP > p.Stats.MaxHP {
|
||||
p.Stats.MaxHP++
|
||||
p.Stats.HP = p.Stats.MaxHP
|
||||
}
|
||||
|
||||
g.sight(0)
|
||||
g.msg("you begin to feel better")
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffGainStrength(bool) {
|
||||
g.Items.Potions[PotionGainStrength].Know = true
|
||||
g.changeStrength(1)
|
||||
g.msg("you feel stronger, now. What bulging muscles!")
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffDetectMonsters(bool) {
|
||||
g.Player.Flags.Set(SenseMonsters)
|
||||
g.Fuse(DTurnSee, 1, HuhDuration, After)
|
||||
|
||||
if !g.turnSee(false) {
|
||||
g.msg("you have a %s feeling for a moment, then it passes",
|
||||
g.chooseStr("normal", "strange"))
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffDetectMagic(bool) {
|
||||
// Potion of magic detection. Show the potions and scrolls
|
||||
show := false
|
||||
|
||||
if len(g.Level.Objects) > 0 {
|
||||
g.scr.Hw.Clear()
|
||||
|
||||
for _, tp := range g.Level.Objects {
|
||||
if g.isMagic(tp) {
|
||||
show = true
|
||||
|
||||
g.scr.Hw.MvAddCh(tp.Pos.Y, tp.Pos.X, Magic)
|
||||
g.Items.Potions[PotionDetectMagic].Know = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, mp := range g.Level.Monsters {
|
||||
for _, tp := range mp.Pack {
|
||||
if g.isMagic(tp) {
|
||||
show = true
|
||||
|
||||
g.scr.Hw.MvAddCh(mp.Pos.Y, mp.Pos.X, Magic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if show {
|
||||
g.Items.Potions[PotionDetectMagic].Know = true
|
||||
g.showWin("You sense the presence of magic on this level.--More--")
|
||||
} else {
|
||||
g.msg("you have a %s feeling for a moment, then it passes",
|
||||
g.chooseStr("normal", "strange"))
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffLSD(trip bool) {
|
||||
p := &g.Player
|
||||
if !trip {
|
||||
if p.On(SenseMonsters) {
|
||||
g.turnSee(false)
|
||||
}
|
||||
|
||||
g.StartDaemon(DVisuals, 0, Before)
|
||||
g.SeenStairs = g.seenStairs()
|
||||
}
|
||||
|
||||
g.applyPotionFuse(PotionLSD, true)
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffSeeInvisible(bool) {
|
||||
show := g.Player.On(CanSeeInvisible)
|
||||
|
||||
g.applyPotionFuse(PotionSeeInvisible, false)
|
||||
|
||||
if !show {
|
||||
g.invisOn()
|
||||
}
|
||||
|
||||
g.sight(0)
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffRaiseLevel(bool) {
|
||||
g.Items.Potions[PotionRaiseLevel].Know = true
|
||||
g.msg("you suddenly feel much more skillful")
|
||||
g.raiseLevel()
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffExtraHealing(bool) {
|
||||
p := &g.Player
|
||||
|
||||
g.Items.Potions[PotionExtraHealing].Know = true
|
||||
if p.Stats.HP += g.roll(p.Stats.Lvl, 8); p.Stats.HP > p.Stats.MaxHP {
|
||||
if p.Stats.HP > p.Stats.MaxHP+p.Stats.Lvl+1 {
|
||||
p.Stats.MaxHP++
|
||||
}
|
||||
|
||||
p.Stats.MaxHP++
|
||||
p.Stats.HP = p.Stats.MaxHP
|
||||
}
|
||||
|
||||
g.sight(0)
|
||||
g.comeDown(0)
|
||||
g.msg("you begin to feel much better")
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffHaste(bool) {
|
||||
g.Items.Potions[PotionHaste].Know = true
|
||||
|
||||
g.After = false
|
||||
if g.addHaste(true) {
|
||||
g.msg("you feel yourself moving much faster")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffRestoreStrength(bool) {
|
||||
p := &g.Player
|
||||
if p.IsRing(Left, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, -p.CurRing[Left].Bonus)
|
||||
}
|
||||
|
||||
if p.IsRing(Right, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, -p.CurRing[Right].Bonus)
|
||||
}
|
||||
|
||||
if p.Stats.Str < p.MaxStats.Str {
|
||||
p.Stats.Str = p.MaxStats.Str
|
||||
}
|
||||
|
||||
if p.IsRing(Left, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, p.CurRing[Left].Bonus)
|
||||
}
|
||||
|
||||
if p.IsRing(Right, RingAddStrength) {
|
||||
addStr(&p.Stats.Str, p.CurRing[Right].Bonus)
|
||||
}
|
||||
|
||||
g.msg("hey, this tastes great. It make you feel warm all over")
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffBlindness(bool) {
|
||||
g.applyPotionFuse(PotionBlindness, true)
|
||||
}
|
||||
|
||||
func (g *RogueGame) quaffLevitation(bool) {
|
||||
g.applyPotionFuse(PotionLevitation, true)
|
||||
}
|
||||
|
||||
// raiseLevel: the guy just magically went up a level (potions.c
|
||||
// raise_level).
|
||||
func (g *RogueGame) raiseLevel() {
|
||||
g.Player.Stats.Exp = eLevels[g.Player.Stats.Lvl-1] + 1
|
||||
g.Player.Stats.Exp = g.data.eLevels[g.Player.Stats.Lvl-1] + 1
|
||||
g.checkLevel()
|
||||
}
|
||||
|
||||
// doPot does a potion with standard setup: it uses a fuse and turns on a
|
||||
// flag (potions.c do_pot).
|
||||
func (g *RogueGame) doPot(kind PotionKind, knowit bool) {
|
||||
pp := &pActions[kind]
|
||||
// applyPotionFuse does a potion with standard setup: it uses a fuse and
|
||||
// turns on a flag (potions.c do_pot).
|
||||
func (g *RogueGame) applyPotionFuse(kind PotionKind, knowit bool) {
|
||||
pp := &g.data.pActions[kind]
|
||||
if !g.Items.Potions[kind].Know {
|
||||
g.Items.Potions[kind].Know = knowit
|
||||
}
|
||||
|
||||
t := g.spread(pp.time)
|
||||
if !g.Player.On(pp.flags) {
|
||||
g.Player.Flags.Set(pp.flags)
|
||||
@@ -200,24 +252,29 @@ func (g *RogueGame) doPot(kind PotionKind, knowit bool) {
|
||||
} else {
|
||||
g.Lengthen(pp.daemon, t)
|
||||
}
|
||||
|
||||
high, straight := pp.high, pp.straight
|
||||
|
||||
if kind == PotionSeeInvisible {
|
||||
s := fmt.Sprintf("this potion tastes like %s juice", g.Fruit)
|
||||
high, straight = s, s
|
||||
}
|
||||
|
||||
g.msg("%s", g.chooseStr(high, straight))
|
||||
}
|
||||
|
||||
// isMagic reports whether an object radiates magic (potions.c is_magic).
|
||||
func (o *Object) isMagic() bool {
|
||||
func (g *RogueGame) isMagic(o *Object) bool {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch o.Kind {
|
||||
case KindArmor:
|
||||
return o.Flags.Has(Protected) || o.ArmorClass != aClass[o.Which]
|
||||
return o.Flags.Has(Protected) || o.ArmorClass != g.data.armorClass(o.Which)
|
||||
case KindWeapon:
|
||||
return o.HPlus != 0 || o.DPlus != 0
|
||||
case KindPotion, KindScroll, KindWand, KindRing, KindAmulet:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -225,6 +282,7 @@ func (o *Object) isMagic() bool {
|
||||
// invis_on).
|
||||
func (g *RogueGame) invisOn() {
|
||||
g.Player.Flags.Set(CanSeeInvisible)
|
||||
|
||||
for _, mp := range g.Level.Monsters {
|
||||
if mp.On(Invisible) && g.seeMonst(mp) && !g.Player.On(Hallucinating) {
|
||||
g.mvaddch(mp.Pos.Y, mp.Pos.X, mp.Disguise)
|
||||
@@ -236,44 +294,62 @@ func (g *RogueGame) invisOn() {
|
||||
// turn_see).
|
||||
func (g *RogueGame) turnSee(turnOff bool) bool {
|
||||
addNew := false
|
||||
|
||||
for _, mp := range g.Level.Monsters {
|
||||
g.move(mp.Pos.Y, mp.Pos.X)
|
||||
|
||||
canSee := g.seeMonst(mp)
|
||||
if turnOff {
|
||||
if !canSee {
|
||||
g.addch(mp.OldCh)
|
||||
}
|
||||
} else {
|
||||
if !canSee {
|
||||
g.standout()
|
||||
}
|
||||
if !g.Player.On(Hallucinating) {
|
||||
g.addch(mp.Type)
|
||||
} else {
|
||||
g.addch(byte(g.rnd(26) + 'A'))
|
||||
}
|
||||
if !canSee {
|
||||
g.standend()
|
||||
addNew = true
|
||||
}
|
||||
} else if g.showSensed(mp, canSee) {
|
||||
addNew = true
|
||||
}
|
||||
}
|
||||
|
||||
if turnOff {
|
||||
g.Player.Flags.Clear(SenseMonsters)
|
||||
} else {
|
||||
g.Player.Flags.Set(SenseMonsters)
|
||||
}
|
||||
|
||||
return addNew
|
||||
}
|
||||
|
||||
// showSensed draws one monster for monster sense, standout when it is
|
||||
// otherwise invisible; it reports whether the monster was newly revealed
|
||||
// (the turn-on arm of the C turn_see loop).
|
||||
func (g *RogueGame) showSensed(mp *Monster, canSee bool) bool {
|
||||
if !canSee {
|
||||
g.standout()
|
||||
}
|
||||
|
||||
if !g.Player.On(Hallucinating) {
|
||||
g.addch(mp.Type)
|
||||
} else {
|
||||
g.addch(g.randomMonsterLetter())
|
||||
}
|
||||
|
||||
if !canSee {
|
||||
g.standend()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// seenStairs reports whether the player has seen the stairs (potions.c
|
||||
// seen_stairs).
|
||||
func (g *RogueGame) seenStairs() bool {
|
||||
st := g.Level.Stairs
|
||||
g.move(st.Y, st.X)
|
||||
|
||||
if g.inch() == Stairs { // it's on the map
|
||||
return true
|
||||
}
|
||||
|
||||
if g.Player.Pos == st { // it's under him
|
||||
return true
|
||||
}
|
||||
@@ -282,9 +358,11 @@ func (g *RogueGame) seenStairs() bool {
|
||||
if g.seeMonst(tp) && tp.On(Awake) { // visible and awake:
|
||||
return true // it must have moved there
|
||||
}
|
||||
|
||||
if g.Player.On(SenseMonsters) && tp.OldCh == Stairs {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
106
game/rings.go
106
game/rings.go
@@ -7,17 +7,16 @@ import "fmt"
|
||||
// ringOn puts a ring on a hand (rings.c ring_on).
|
||||
func (g *RogueGame) ringOn() {
|
||||
p := &g.Player
|
||||
obj := g.getItem("put on", KindRing)
|
||||
obj, ok := g.promptPackItem("put on", KindRing)
|
||||
// Make certain that it is something that we want to wear
|
||||
if obj == nil {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Kind != KindRing {
|
||||
if !g.Options.Terse {
|
||||
g.msg("it would be difficult to wrap that around a finger")
|
||||
} else {
|
||||
g.msg("not a ring")
|
||||
}
|
||||
g.msg("%s", g.chooseTerse("not a ring",
|
||||
"it would be difficult to wrap that around a finger"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,30 +25,18 @@ func (g *RogueGame) ringOn() {
|
||||
return
|
||||
}
|
||||
|
||||
var ring int
|
||||
switch {
|
||||
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
|
||||
if ring = g.gethand(); ring < 0 {
|
||||
return
|
||||
}
|
||||
case p.CurRing[Left] == nil:
|
||||
ring = Left
|
||||
case p.CurRing[Right] == nil:
|
||||
ring = Right
|
||||
default:
|
||||
if !g.Options.Terse {
|
||||
g.msg("you already have a ring on each hand")
|
||||
} else {
|
||||
g.msg("wearing two")
|
||||
}
|
||||
ring := g.pickRingHand()
|
||||
if ring < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
p.CurRing[ring] = obj
|
||||
|
||||
// Calculate the effect it has on the poor guy.
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingAddStrength:
|
||||
g.chgStr(obj.Bonus)
|
||||
g.changeStrength(obj.Bonus)
|
||||
case RingSeeInvisible:
|
||||
g.invisOn()
|
||||
case RingAggravateMonsters:
|
||||
@@ -57,15 +44,38 @@ func (g *RogueGame) ringOn() {
|
||||
}
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("you are now wearing ")
|
||||
g.addmsgf("you are now wearing ")
|
||||
}
|
||||
|
||||
g.msg("%s (%c)", g.inventoryName(obj, true), obj.PackCh)
|
||||
}
|
||||
|
||||
// pickRingHand chooses the hand for a new ring, asking when both are
|
||||
// free; negative aborts (rings.c ring_on).
|
||||
func (g *RogueGame) pickRingHand() int {
|
||||
p := &g.Player
|
||||
|
||||
switch {
|
||||
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
|
||||
return g.gethand()
|
||||
case p.CurRing[Left] == nil:
|
||||
return Left
|
||||
case p.CurRing[Right] == nil:
|
||||
return Right
|
||||
default:
|
||||
g.msg("%s", g.chooseTerse("wearing two",
|
||||
"you already have a ring on each hand"))
|
||||
|
||||
return -1
|
||||
}
|
||||
g.msg("%s (%c)", g.invName(obj, true), obj.PackCh)
|
||||
}
|
||||
|
||||
// ringOff takes off a ring (rings.c ring_off).
|
||||
func (g *RogueGame) ringOff() {
|
||||
p := &g.Player
|
||||
|
||||
var ring int
|
||||
|
||||
switch {
|
||||
case p.CurRing[Left] == nil && p.CurRing[Right] == nil:
|
||||
if g.Options.Terse {
|
||||
@@ -73,6 +83,7 @@ func (g *RogueGame) ringOff() {
|
||||
} else {
|
||||
g.msg("you aren't wearing any rings")
|
||||
}
|
||||
|
||||
return
|
||||
case p.CurRing[Left] == nil:
|
||||
ring = Right
|
||||
@@ -83,14 +94,18 @@ func (g *RogueGame) ringOff() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
obj := p.CurRing[ring]
|
||||
if obj == nil {
|
||||
g.msg("not wearing such a ring")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if g.dropCheck(obj) {
|
||||
g.msg("was wearing %s(%c)", g.invName(obj, true), obj.PackCh)
|
||||
g.msg("was wearing %s(%c)", g.inventoryName(obj, true), obj.PackCh)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,17 +117,22 @@ func (g *RogueGame) gethand() int {
|
||||
} else {
|
||||
g.msg("left hand or right hand? ")
|
||||
}
|
||||
|
||||
c := g.readchar()
|
||||
if c == Escape {
|
||||
return -1
|
||||
}
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
if c == 'l' || c == 'L' {
|
||||
return Left
|
||||
}
|
||||
|
||||
if c == 'r' || c == 'R' {
|
||||
return Right
|
||||
}
|
||||
|
||||
if g.Options.Terse {
|
||||
g.msg("L or R")
|
||||
} else {
|
||||
@@ -121,25 +141,6 @@ func (g *RogueGame) gethand() int {
|
||||
}
|
||||
}
|
||||
|
||||
// ringUses is the rings.c ring_eat static uses[] table: how much food each
|
||||
// ring type uses up per turn (negative = a 1-in-n chance of 1).
|
||||
var ringUses = [NumRingTypes]int{
|
||||
1, // R_PROTECT
|
||||
1, // R_ADDSTR
|
||||
1, // R_SUSTSTR
|
||||
-3, // R_SEARCH
|
||||
-5, // R_SEEINVIS
|
||||
0, // R_NOP
|
||||
0, // R_AGGR
|
||||
-3, // R_ADDHIT
|
||||
-3, // R_ADDDAM
|
||||
2, // R_REGEN
|
||||
-2, // R_DIGEST
|
||||
0, // R_TELEPORT
|
||||
1, // R_STEALTH
|
||||
1, // R_SUSTARM
|
||||
}
|
||||
|
||||
// ringEat reports how much food the ring on the given hand uses up
|
||||
// (rings.c ring_eat).
|
||||
func (g *RogueGame) ringEat(hand int) int {
|
||||
@@ -147,7 +148,8 @@ func (g *RogueGame) ringEat(hand int) int {
|
||||
if ring == nil {
|
||||
return 0
|
||||
}
|
||||
eat := ringUses[ring.RingKind()]
|
||||
|
||||
eat := g.data.ringUses[ring.RingKind()]
|
||||
if eat < 0 {
|
||||
if g.rnd(-eat) == 0 {
|
||||
eat = 1
|
||||
@@ -155,20 +157,26 @@ func (g *RogueGame) ringEat(hand int) int {
|
||||
eat = 0
|
||||
}
|
||||
}
|
||||
|
||||
if ring.RingKind() == RingSlowDigestion {
|
||||
eat = -eat
|
||||
}
|
||||
|
||||
return eat
|
||||
}
|
||||
|
||||
// ringNum prints ring bonuses (rings.c ring_num).
|
||||
func ringNum(g *RogueGame, obj *Object) string {
|
||||
// ringNum prints ring bonuses (rings.c ring_num). The unused game
|
||||
// parameter keeps the nameit prfunc signature.
|
||||
func ringNum(_ *RogueGame, obj *Object) string {
|
||||
if !obj.Flags.Has(Known) {
|
||||
return ""
|
||||
}
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingProtection, RingAddStrength, RingIncreaseDamage, RingDexterity:
|
||||
return fmt.Sprintf(" [%s]", num(obj.Bonus, 0, Ring))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
265
game/rip.go
265
game/rip.go
@@ -1,77 +1,73 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rip.c — the fun ends: death or a total win.
|
||||
//
|
||||
// The C functions here call exit(); the port panics with a gameEnd sentinel
|
||||
// that Run recovers, so the terminal is restored by normal unwinding.
|
||||
// The C functions here call exit(). One game run is one process, so the
|
||||
// port does the same: myExit restores the terminal and exits directly.
|
||||
|
||||
// gameEnd is the sentinel carried by the panic that replaces my_exit().
|
||||
type gameEnd struct{ status int }
|
||||
|
||||
// myExit leaves the process properly (main.c my_exit): it unwinds to Run.
|
||||
func (g *RogueGame) myExit(st int) {
|
||||
g.Playing = false
|
||||
panic(gameEnd{status: st})
|
||||
}
|
||||
|
||||
var ripArt = []string{
|
||||
" __________",
|
||||
" / \\",
|
||||
" / REST \\",
|
||||
" / IN \\",
|
||||
" / PEACE \\",
|
||||
" / \\",
|
||||
" | |",
|
||||
" | |",
|
||||
" | killed by a |",
|
||||
" | |",
|
||||
" | 1980 |",
|
||||
" *| * * * | *",
|
||||
" ________)/\\\\_//(\\/(/\\)/\\//\\/|_)_______",
|
||||
// myExit leaves the process properly (main.c my_exit): it restores the
|
||||
// terminal and ends the process. Every C caller exited with status 0.
|
||||
func (g *RogueGame) myExit() {
|
||||
g.scr.Fini()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// death does something really fun when he dies (rip.c death).
|
||||
func (g *RogueGame) death(monst byte) {
|
||||
p := &g.Player
|
||||
p.Purse -= p.Purse / 10
|
||||
|
||||
g.clear()
|
||||
|
||||
killer := g.killname(monst, false)
|
||||
if !g.Options.Tombstone {
|
||||
g.scr.Std.MvPrintw(NumLines-2, 0, "Killed by ")
|
||||
g.scr.Std.MvPrintwf(NumLines-2, 0, "Killed by ")
|
||||
|
||||
if monst != 's' && monst != 'h' {
|
||||
g.printw("a%s ", vowelstr(killer))
|
||||
}
|
||||
|
||||
g.printw("%s with %d gold", killer, p.Purse)
|
||||
} else {
|
||||
year := time.Now().Year()
|
||||
for i, line := range ripArt {
|
||||
|
||||
for i, line := range g.data.ripArt {
|
||||
g.scr.Std.MvAddStr(8+i, 0, line)
|
||||
}
|
||||
|
||||
g.mvaddstr(17, center(killer), killer)
|
||||
|
||||
if monst == 's' || monst == 'h' {
|
||||
g.mvaddch(16, 32, ' ')
|
||||
} else {
|
||||
g.mvaddstr(16, 33, vowelstr(killer))
|
||||
}
|
||||
|
||||
g.mvaddstr(14, center(g.Whoami), g.Whoami)
|
||||
|
||||
au := fmt.Sprintf("%d Au", p.Purse)
|
||||
g.mvaddstr(15, center(au), au)
|
||||
g.mvaddstr(18, 26, fmt.Sprintf("%4d", year))
|
||||
}
|
||||
|
||||
g.mvaddstr(NumLines-1, 0, "[Press return to continue]")
|
||||
g.refresh()
|
||||
|
||||
flags := 0
|
||||
if g.HasAmulet {
|
||||
flags = 3
|
||||
}
|
||||
|
||||
g.score(p.Purse, flags, monst)
|
||||
g.waitFor('\n')
|
||||
g.myExit(0)
|
||||
g.myExit()
|
||||
}
|
||||
|
||||
// center returns the column to center the given string on the tombstone
|
||||
@@ -85,6 +81,7 @@ func (g *RogueGame) totalWinner() {
|
||||
p := &g.Player
|
||||
g.clear()
|
||||
g.standout()
|
||||
|
||||
banner := []string{
|
||||
" ",
|
||||
" @ @ @ @ @ @@@ @ @ ",
|
||||
@@ -100,9 +97,12 @@ func (g *RogueGame) totalWinner() {
|
||||
for i, line := range banner {
|
||||
g.scr.Std.MvAddStr(i, 0, line)
|
||||
}
|
||||
|
||||
g.standend()
|
||||
g.scr.Std.MvAddStr(10, 0, "You have joined the elite ranks of those who have escaped the")
|
||||
g.scr.Std.MvAddStr(11, 0, "Dungeons of Doom alive. You journey home and sell all your loot at")
|
||||
g.scr.Std.MvAddStr(10, 0,
|
||||
"You have joined the elite ranks of those who have escaped the")
|
||||
g.scr.Std.MvAddStr(11, 0,
|
||||
"Dungeons of Doom alive. You journey home and sell all your loot at")
|
||||
g.scr.Std.MvAddStr(12, 0, "a great profit and are admitted to the Fighters' Guild.")
|
||||
g.mvaddstr(NumLines-1, 0, "--Press space to continue--")
|
||||
g.refresh()
|
||||
@@ -110,127 +110,167 @@ func (g *RogueGame) totalWinner() {
|
||||
g.clear()
|
||||
g.mvaddstr(0, 0, " Worth Item")
|
||||
g.move(1, 0)
|
||||
|
||||
oldpurse := p.Purse
|
||||
line := 1
|
||||
|
||||
for _, obj := range p.Pack {
|
||||
worth := 0
|
||||
it := &g.Items
|
||||
switch obj.Kind {
|
||||
case KindFood:
|
||||
worth = 2 * obj.Count
|
||||
case KindWeapon:
|
||||
worth = it.Weapons[obj.Which].Worth
|
||||
worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count
|
||||
obj.Flags.Set(Known)
|
||||
case KindArmor:
|
||||
worth = it.Armors[obj.Which].Worth
|
||||
worth += (9 - obj.ArmorClass) * 100
|
||||
worth += 10 * (aClass[obj.Which] - obj.ArmorClass)
|
||||
obj.Flags.Set(Known)
|
||||
case KindScroll:
|
||||
op := &it.Scrolls[obj.Which]
|
||||
worth = op.Worth * obj.Count
|
||||
if !op.Know {
|
||||
worth /= 2
|
||||
}
|
||||
op.Know = true
|
||||
case KindPotion:
|
||||
op := &it.Potions[obj.Which]
|
||||
worth = op.Worth * obj.Count
|
||||
if !op.Know {
|
||||
worth /= 2
|
||||
}
|
||||
op.Know = true
|
||||
case KindRing:
|
||||
op := &it.Rings[obj.Which]
|
||||
worth = op.Worth
|
||||
if obj.RingKind() == RingAddStrength || obj.RingKind() == RingIncreaseDamage ||
|
||||
obj.RingKind() == RingProtection || obj.RingKind() == RingDexterity {
|
||||
if obj.Bonus > 0 {
|
||||
worth += obj.Bonus * 100
|
||||
} else {
|
||||
worth = 10
|
||||
}
|
||||
}
|
||||
if !obj.Flags.Has(Known) {
|
||||
worth /= 2
|
||||
}
|
||||
obj.Flags.Set(Known)
|
||||
op.Know = true
|
||||
case KindWand:
|
||||
op := &it.Sticks[obj.Which]
|
||||
worth = op.Worth
|
||||
worth += 20 * obj.Charges
|
||||
if !obj.Flags.Has(Known) {
|
||||
worth /= 2
|
||||
}
|
||||
obj.Flags.Set(Known)
|
||||
op.Know = true
|
||||
case KindAmulet:
|
||||
worth = 1000
|
||||
}
|
||||
if worth < 0 {
|
||||
worth = 0
|
||||
}
|
||||
g.scr.Std.MvPrintw(line, 0, "%c) %5d %s", obj.PackCh, worth,
|
||||
g.invName(obj, false))
|
||||
worth := g.objectWorth(obj)
|
||||
|
||||
g.scr.Std.MvPrintwf(line, 0, "%c) %5d %s", obj.PackCh, worth,
|
||||
g.inventoryName(obj, false))
|
||||
line++
|
||||
p.Purse += worth
|
||||
}
|
||||
g.scr.Std.MvPrintw(line, 0, " %5d Gold Pieces ", oldpurse)
|
||||
|
||||
g.scr.Std.MvPrintwf(line, 0, " %5d Gold Pieces ", oldpurse)
|
||||
g.refresh()
|
||||
g.score(p.Purse, 2, ' ')
|
||||
g.myExit(0)
|
||||
g.myExit()
|
||||
}
|
||||
|
||||
// killnameTable is the rip.c nlist[]: special death causes.
|
||||
var killnameTable = []helpEntry{
|
||||
{'a', "arrow", true},
|
||||
{'b', "bolt", true},
|
||||
{'d', "dart", true},
|
||||
{'h', "hypothermia", false},
|
||||
{'s', "starvation", false},
|
||||
// objectWorth appraises one pack item on the way out, marking it known
|
||||
// (the switch of rip.c total_winner).
|
||||
func (g *RogueGame) objectWorth(obj *Object) int {
|
||||
// Same defense as inventoryName: most arms below appraise from a
|
||||
// per-kind table at obj.Which, so a malformed item is worth nothing
|
||||
// rather than a panic on the way to the scoreboard.
|
||||
if !obj.hasValidWhich() {
|
||||
return 0
|
||||
}
|
||||
|
||||
it := &g.Items
|
||||
worth := 0
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindFood:
|
||||
worth = 2 * obj.Count
|
||||
case KindWeapon:
|
||||
worth = it.Weapons[obj.Which].Worth
|
||||
worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count
|
||||
obj.Flags.Set(Known)
|
||||
case KindArmor:
|
||||
worth = it.Armors[obj.Which].Worth
|
||||
worth += (9 - obj.ArmorClass) * 100
|
||||
worth += 10 * (g.data.armorClass(obj.Which) - obj.ArmorClass)
|
||||
obj.Flags.Set(Known)
|
||||
case KindScroll:
|
||||
worth = loreWorth(&it.Scrolls[obj.Which], obj.Count)
|
||||
case KindPotion:
|
||||
worth = loreWorth(&it.Potions[obj.Which], obj.Count)
|
||||
case KindRing:
|
||||
worth = g.ringWorth(obj)
|
||||
case KindWand:
|
||||
worth = g.wandWorth(obj)
|
||||
case KindAmulet:
|
||||
worth = 1000
|
||||
}
|
||||
|
||||
if worth < 0 {
|
||||
worth = 0
|
||||
}
|
||||
|
||||
return worth
|
||||
}
|
||||
|
||||
// loreWorth appraises a scroll or potion, halved when unidentified, and
|
||||
// identifies it (rip.c total_winner).
|
||||
func loreWorth(op *ObjInfo, count int) int {
|
||||
worth := op.Worth * count
|
||||
if !op.Know {
|
||||
worth /= 2
|
||||
}
|
||||
|
||||
op.Know = true
|
||||
|
||||
return worth
|
||||
}
|
||||
|
||||
// ringWorth appraises a ring: bonus rings gain by their bonus, cursed
|
||||
// ones are junk (rip.c total_winner).
|
||||
func (g *RogueGame) ringWorth(obj *Object) int {
|
||||
op := &g.Items.Rings[obj.Which]
|
||||
worth := op.Worth
|
||||
|
||||
if obj.RingKind() == RingAddStrength || obj.RingKind() == RingIncreaseDamage ||
|
||||
obj.RingKind() == RingProtection || obj.RingKind() == RingDexterity {
|
||||
if obj.Bonus > 0 {
|
||||
worth += obj.Bonus * 100
|
||||
} else {
|
||||
worth = 10
|
||||
}
|
||||
}
|
||||
|
||||
if !obj.Flags.Has(Known) {
|
||||
worth /= 2
|
||||
}
|
||||
|
||||
obj.Flags.Set(Known)
|
||||
|
||||
op.Know = true
|
||||
|
||||
return worth
|
||||
}
|
||||
|
||||
// wandWorth appraises a wand or staff by its charges (rip.c
|
||||
// total_winner).
|
||||
func (g *RogueGame) wandWorth(obj *Object) int {
|
||||
op := &g.Items.Sticks[obj.Which]
|
||||
worth := op.Worth
|
||||
|
||||
worth += 20 * obj.Charges
|
||||
if !obj.Flags.Has(Known) {
|
||||
worth /= 2
|
||||
}
|
||||
|
||||
obj.Flags.Set(Known)
|
||||
|
||||
op.Know = true
|
||||
|
||||
return worth
|
||||
}
|
||||
|
||||
// killname converts a code to a monster name (rip.c killname).
|
||||
func (g *RogueGame) killname(monst byte, doart bool) string {
|
||||
var sp string
|
||||
var article bool
|
||||
var (
|
||||
sp string
|
||||
article bool
|
||||
)
|
||||
|
||||
if isUpper(monst) {
|
||||
sp = g.Monsters[monst-'A'].Name
|
||||
article = true
|
||||
} else {
|
||||
sp = "Wally the Wonder Badger"
|
||||
article = false
|
||||
for _, hp := range killnameTable {
|
||||
|
||||
for _, hp := range g.data.killnameTable {
|
||||
if hp.Ch == monst {
|
||||
sp = hp.Desc
|
||||
article = hp.Print
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if doart && article {
|
||||
return "a" + vowelstr(sp) + " " + sp
|
||||
}
|
||||
|
||||
return sp
|
||||
}
|
||||
|
||||
// DeathDemo implements the -d command line option (main.c): burn some
|
||||
// random numbers to break patterns, then die a random death.
|
||||
// random numbers to break patterns, then die a random death. It does not
|
||||
// return — death exits the process.
|
||||
func (g *RogueGame) DeathDemo() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(gameEnd); ok {
|
||||
return
|
||||
}
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
dnum := g.rnd(100)
|
||||
for dnum--; dnum > 0; dnum-- {
|
||||
g.rnd(100)
|
||||
}
|
||||
|
||||
g.Player.Purse = g.rnd(100) + 1
|
||||
g.Depth = g.rnd(100) + 1
|
||||
g.death(g.deathMonst())
|
||||
@@ -245,5 +285,6 @@ func (g *RogueGame) deathMonst() byte {
|
||||
'Y', 'Z', 'a', 'b', 'h', 'd', 's',
|
||||
' ', // generates the "Wally the Wonder Badger" message
|
||||
}
|
||||
|
||||
return poss[g.rnd(len(poss))]
|
||||
}
|
||||
|
||||
17
game/rng.go
17
game/rng.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// Rng is the original Rogue linear congruential generator. The C RN macro is
|
||||
@@ -11,21 +12,17 @@ type Rng struct {
|
||||
Seed int32
|
||||
}
|
||||
|
||||
// next steps the generator and returns the next raw value (the RN macro).
|
||||
func (r *Rng) next() int {
|
||||
r.Seed = r.Seed*11109 + 13849
|
||||
return int(r.Seed>>16) & 0xffff
|
||||
}
|
||||
|
||||
// Rnd picks a very random number in [0, rng) (main.c rnd).
|
||||
func (r *Rng) Rnd(rng int) int {
|
||||
if rng == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
v := r.next()
|
||||
if v < 0 {
|
||||
v = -v
|
||||
}
|
||||
|
||||
return v % rng
|
||||
}
|
||||
|
||||
@@ -35,9 +32,17 @@ func (r *Rng) Roll(number, sides int) int {
|
||||
for ; number > 0; number-- {
|
||||
dtotal += r.Rnd(sides) + 1
|
||||
}
|
||||
|
||||
return dtotal
|
||||
}
|
||||
|
||||
// next steps the generator and returns the next raw value (the RN macro).
|
||||
func (r *Rng) next() int {
|
||||
r.Seed = r.Seed*11109 + 13849
|
||||
|
||||
return int(r.Seed>>16) & 0xffff
|
||||
}
|
||||
|
||||
// rnd is the ported code's spelling of C rnd(): every call site in the C
|
||||
// sources reads rnd(x), and keeping that shape makes cross-checking easy.
|
||||
func (g *RogueGame) rnd(rng int) int { return g.Rng.Rnd(rng) }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
@@ -6,6 +7,8 @@ import "testing"
|
||||
// (seed = seed*11109+13849; rnd(range) = abs(RN) % range) compiled and run
|
||||
// on this machine. They lock in seed compatibility with the C game.
|
||||
func TestRndMatchesCImplementation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
golden := map[int32][]int{
|
||||
1: {0, 30, 79, 7, 87, 1, 23, 7, 57, 98},
|
||||
12345: {92, 92, 45, 98, 24, 39, 92, 67, 3, 7},
|
||||
@@ -23,6 +26,8 @@ func TestRndMatchesCImplementation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRollMatchesCImplementation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
golden := map[int32][]int{
|
||||
1: {6, 8, 10},
|
||||
12345: {10, 10, 15},
|
||||
@@ -42,10 +47,13 @@ func TestRollMatchesCImplementation(t *testing.T) {
|
||||
// rnd(0) must return 0 without stepping the generator: the C macro
|
||||
// short-circuits before evaluating RN, and BEFORE/AFTER depend on it.
|
||||
func TestRndZeroDoesNotStep(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := &Rng{Seed: 42}
|
||||
if got := r.Rnd(0); got != 0 {
|
||||
t.Fatalf("rnd(0) = %d, want 0", got)
|
||||
}
|
||||
|
||||
if r.Seed != 42 {
|
||||
t.Fatalf("rnd(0) stepped the generator: seed = %d, want 42", r.Seed)
|
||||
}
|
||||
@@ -54,10 +62,13 @@ func TestRndZeroDoesNotStep(t *testing.T) {
|
||||
// spread(1)==1 and spread(2)==2 deterministically; the C BEFORE/AFTER
|
||||
// constants rely on this.
|
||||
func TestSpreadSmallValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := &RogueGame{Rng: &Rng{Seed: 7}}
|
||||
if got := g.spread(1); got != 1 {
|
||||
t.Errorf("spread(1) = %d, want 1", got)
|
||||
}
|
||||
|
||||
if got := g.spread(2); got != 2 {
|
||||
t.Errorf("spread(2) = %d, want 2", got)
|
||||
}
|
||||
|
||||
468
game/rooms.go
468
game/rooms.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// rooms.c — create the layout for the new level.
|
||||
@@ -17,10 +18,11 @@ type mazeState struct {
|
||||
|
||||
const goldGrp = 1
|
||||
|
||||
// doRooms creates rooms and corridors with a connectivity graph (rooms.c
|
||||
// digRooms creates rooms and corridors with a connectivity graph (rooms.c
|
||||
// do_rooms).
|
||||
func (g *RogueGame) doRooms() {
|
||||
func (g *RogueGame) digRooms() {
|
||||
var bsze Coord // maximum room size
|
||||
|
||||
bsze.X = NumCols / 3
|
||||
bsze.Y = NumLines / 3
|
||||
// Clear things for a new level
|
||||
@@ -32,92 +34,136 @@ func (g *RogueGame) doRooms() {
|
||||
}
|
||||
// Put the gone rooms, if any, on the level
|
||||
leftOut := g.rnd(4)
|
||||
for i := 0; i < leftOut; i++ {
|
||||
g.Level.Rooms[g.rndRoom()].Flags.Set(Gone)
|
||||
for range leftOut {
|
||||
g.Level.Rooms[g.randomRoom()].Flags.Set(Gone)
|
||||
}
|
||||
// dig and populate all the rooms on the level
|
||||
for i := range g.Level.Rooms {
|
||||
rp := &g.Level.Rooms[i]
|
||||
// Find upper left corner of box that this room goes in
|
||||
top := Coord{X: (i%3)*bsze.X + 1, Y: (i / 3) * bsze.Y}
|
||||
if rp.Flags.Has(Gone) {
|
||||
// Place a gone room. Make certain that there is a blank line
|
||||
// for passage drawing.
|
||||
for {
|
||||
rp.Pos.X = top.X + g.rnd(bsze.X-2) + 1
|
||||
rp.Pos.Y = top.Y + g.rnd(bsze.Y-2) + 1
|
||||
rp.Max.X = -NumCols
|
||||
rp.Max.Y = -NumLines
|
||||
if rp.Pos.Y > 0 && rp.Pos.Y < NumLines-1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
g.digRoom(i, bsze)
|
||||
}
|
||||
}
|
||||
|
||||
// digRoom digs and populates one room (the loop body of rooms.c
|
||||
// do_rooms).
|
||||
func (g *RogueGame) digRoom(i int, bsze Coord) {
|
||||
rp := &g.Level.Rooms[i]
|
||||
// Find upper left corner of box that this room goes in
|
||||
top := Coord{X: (i%3)*bsze.X + 1, Y: (i / 3) * bsze.Y}
|
||||
|
||||
if rp.Flags.Has(Gone) {
|
||||
g.placeGoneRoom(rp, top, bsze)
|
||||
|
||||
return
|
||||
}
|
||||
// set room type
|
||||
if g.rnd(10) < g.Depth-1 {
|
||||
rp.Flags.Set(Dark) // dark room
|
||||
|
||||
if g.rnd(15) == 0 {
|
||||
rp.Flags = Maze // maze room
|
||||
}
|
||||
// set room type
|
||||
if g.rnd(10) < g.Depth-1 {
|
||||
rp.Flags.Set(Dark) // dark room
|
||||
if g.rnd(15) == 0 {
|
||||
rp.Flags = Maze // maze room
|
||||
}
|
||||
}
|
||||
// Find a place and size for a random room
|
||||
if rp.Flags.Has(Maze) {
|
||||
placeMazeRoom(rp, top, bsze)
|
||||
} else {
|
||||
g.placeNormalRoom(rp, top, bsze)
|
||||
}
|
||||
|
||||
g.drawRoom(rp)
|
||||
g.roomGold(rp)
|
||||
g.roomMonster(rp)
|
||||
}
|
||||
|
||||
// placeGoneRoom places a gone room, making certain that there is a
|
||||
// blank line for passage drawing (rooms.c do_rooms).
|
||||
func (g *RogueGame) placeGoneRoom(rp *Room, top, bsze Coord) {
|
||||
for {
|
||||
rp.Pos.X = top.X + g.rnd(bsze.X-2) + 1
|
||||
rp.Pos.Y = top.Y + g.rnd(bsze.Y-2) + 1
|
||||
rp.Max.X = -NumCols
|
||||
|
||||
rp.Max.Y = -NumLines
|
||||
if rp.Pos.Y > 0 && rp.Pos.Y < NumLines-1 {
|
||||
return
|
||||
}
|
||||
// Find a place and size for a random room
|
||||
if rp.Flags.Has(Maze) {
|
||||
rp.Max.X = bsze.X - 1
|
||||
rp.Max.Y = bsze.Y - 1
|
||||
if rp.Pos.X = top.X; rp.Pos.X == 1 {
|
||||
rp.Pos.X = 0
|
||||
}
|
||||
if rp.Pos.Y = top.Y; rp.Pos.Y == 0 {
|
||||
rp.Pos.Y++
|
||||
rp.Max.Y--
|
||||
}
|
||||
} else {
|
||||
for {
|
||||
rp.Max.X = g.rnd(bsze.X-4) + 4
|
||||
rp.Max.Y = g.rnd(bsze.Y-4) + 4
|
||||
rp.Pos.X = top.X + g.rnd(bsze.X-rp.Max.X)
|
||||
rp.Pos.Y = top.Y + g.rnd(bsze.Y-rp.Max.Y)
|
||||
if rp.Pos.Y != 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
g.drawRoom(rp)
|
||||
// Put the gold in
|
||||
if g.rnd(2) == 0 && (!g.HasAmulet || g.Depth >= g.MaxDepth) {
|
||||
gold := newObject()
|
||||
rp.GoldVal = g.goldCalc()
|
||||
gold.GoldValue = rp.GoldVal
|
||||
rp.Gold, _ = g.findFloorIn(rp, 0, false)
|
||||
gold.Pos = rp.Gold
|
||||
g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold)
|
||||
gold.Flags = Stackable
|
||||
gold.Group = goldGrp
|
||||
gold.Kind = KindGold
|
||||
attachObj(&g.Level.Objects, gold)
|
||||
}
|
||||
// Put the monster in
|
||||
prob := 25
|
||||
if rp.GoldVal > 0 {
|
||||
prob = 80
|
||||
}
|
||||
if g.rnd(100) < prob {
|
||||
tp := &Monster{}
|
||||
mp, _ := g.findFloorIn(rp, 0, true)
|
||||
g.newMonster(tp, g.randMonster(false), mp)
|
||||
g.givePack(tp)
|
||||
}
|
||||
}
|
||||
|
||||
// placeMazeRoom sizes a maze room to fill its box (rooms.c do_rooms).
|
||||
func placeMazeRoom(rp *Room, top, bsze Coord) {
|
||||
rp.Max.X = bsze.X - 1
|
||||
|
||||
rp.Max.Y = bsze.Y - 1
|
||||
if rp.Pos.X = top.X; rp.Pos.X == 1 {
|
||||
rp.Pos.X = 0
|
||||
}
|
||||
|
||||
if rp.Pos.Y = top.Y; rp.Pos.Y == 0 {
|
||||
rp.Pos.Y++
|
||||
rp.Max.Y--
|
||||
}
|
||||
}
|
||||
|
||||
// placeNormalRoom rolls a place and size for an ordinary room (rooms.c
|
||||
// do_rooms).
|
||||
func (g *RogueGame) placeNormalRoom(rp *Room, top, bsze Coord) {
|
||||
for {
|
||||
rp.Max.X = g.rnd(bsze.X-4) + 4
|
||||
rp.Max.Y = g.rnd(bsze.Y-4) + 4
|
||||
rp.Pos.X = top.X + g.rnd(bsze.X-rp.Max.X)
|
||||
|
||||
rp.Pos.Y = top.Y + g.rnd(bsze.Y-rp.Max.Y)
|
||||
if rp.Pos.Y != 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// roomGold maybe puts a gold pile in the room (rooms.c do_rooms).
|
||||
func (g *RogueGame) roomGold(rp *Room) {
|
||||
if g.rnd(2) != 0 || (g.HasAmulet && g.Depth < g.MaxDepth) {
|
||||
return
|
||||
}
|
||||
|
||||
gold := newObject()
|
||||
rp.GoldVal = g.goldCalc()
|
||||
gold.GoldValue = rp.GoldVal
|
||||
rp.Gold, _ = g.findFloorIn(rp, 0, false)
|
||||
gold.Pos = rp.Gold
|
||||
g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold)
|
||||
|
||||
gold.Flags = Stackable
|
||||
gold.Group = goldGrp
|
||||
gold.Kind = KindGold
|
||||
g.Level.AddObject(gold)
|
||||
}
|
||||
|
||||
// roomMonster maybe puts a monster in the room; gold attracts them
|
||||
// (rooms.c do_rooms).
|
||||
func (g *RogueGame) roomMonster(rp *Room) {
|
||||
prob := 25
|
||||
if rp.GoldVal > 0 {
|
||||
prob = 80
|
||||
}
|
||||
|
||||
if g.rnd(100) < prob {
|
||||
tp := &Monster{}
|
||||
mp, _ := g.findFloorIn(rp, 0, true)
|
||||
g.newMonster(tp, g.randMonster(false), mp)
|
||||
g.givePack(tp)
|
||||
}
|
||||
}
|
||||
|
||||
// drawRoom draws a box around a room and lays down the floor for normal
|
||||
// rooms; for maze rooms, draws the maze (rooms.c draw_room).
|
||||
func (g *RogueGame) drawRoom(rp *Room) {
|
||||
if rp.Flags.Has(Maze) {
|
||||
g.doMaze(rp)
|
||||
g.digMaze(rp)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
g.vert(rp, rp.Pos.X) // Draw left side
|
||||
g.vert(rp, rp.Pos.X+rp.Max.X-1) // Draw right side
|
||||
g.horiz(rp, rp.Pos.Y) // Draw top
|
||||
@@ -145,8 +191,8 @@ func (g *RogueGame) horiz(rp *Room, starty int) {
|
||||
}
|
||||
}
|
||||
|
||||
// doMaze digs a maze (rooms.c do_maze).
|
||||
func (g *RogueGame) doMaze(rp *Room) {
|
||||
// digMaze digs a maze (rooms.c do_maze).
|
||||
func (g *RogueGame) digMaze(rp *Room) {
|
||||
m := &g.maze
|
||||
for y := range m.maze {
|
||||
for x := range m.maze[y] {
|
||||
@@ -162,65 +208,84 @@ func (g *RogueGame) doMaze(rp *Room) {
|
||||
starty := (g.rnd(rp.Max.Y) / 2) * 2
|
||||
startx := (g.rnd(rp.Max.X) / 2) * 2
|
||||
pos := Coord{Y: starty + m.starty, X: startx + m.startx}
|
||||
g.putpass(pos)
|
||||
g.putPassage(pos)
|
||||
g.dig(starty, startx)
|
||||
}
|
||||
|
||||
// dig digs out from around where we are now, if possible (rooms.c dig).
|
||||
func (g *RogueGame) dig(y, x int) {
|
||||
m := &g.maze
|
||||
del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}}
|
||||
|
||||
for {
|
||||
cnt := 0
|
||||
var nexty, nextx int
|
||||
for _, cp := range del {
|
||||
newy := y + cp.Y
|
||||
newx := x + cp.X
|
||||
if newy < 0 || newy > m.maxy || newx < 0 || newx > m.maxx {
|
||||
continue
|
||||
}
|
||||
if g.Level.FlagsAt(newy+m.starty, newx+m.startx).Has(FPassage) {
|
||||
continue
|
||||
}
|
||||
if cnt++; g.rnd(cnt) == 0 {
|
||||
nexty = newy
|
||||
nextx = newx
|
||||
}
|
||||
}
|
||||
if cnt == 0 {
|
||||
nexty, nextx, ok := g.digPick(y, x)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
g.accntMaze(y, x, nexty, nextx)
|
||||
g.accntMaze(nexty, nextx, y, x)
|
||||
var pos Coord
|
||||
if nexty == y {
|
||||
pos.Y = y + m.starty
|
||||
if nextx-x < 0 {
|
||||
pos.X = nextx + m.startx + 1
|
||||
} else {
|
||||
pos.X = nextx + m.startx - 1
|
||||
}
|
||||
} else {
|
||||
pos.X = x + m.startx
|
||||
if nexty-y < 0 {
|
||||
pos.Y = nexty + m.starty + 1
|
||||
} else {
|
||||
pos.Y = nexty + m.starty - 1
|
||||
}
|
||||
}
|
||||
g.putpass(pos)
|
||||
pos.Y = nexty + m.starty
|
||||
pos.X = nextx + m.startx
|
||||
g.putpass(pos)
|
||||
|
||||
g.accountMaze(y, x, nexty, nextx)
|
||||
g.accountMaze(nexty, nextx, y, x)
|
||||
|
||||
g.putPassage(digWallGap(m, y, x, nexty, nextx))
|
||||
g.putPassage(Coord{Y: nexty + m.starty, X: nextx + m.startx})
|
||||
g.dig(nexty, nextx)
|
||||
}
|
||||
}
|
||||
|
||||
// accntMaze accounts for maze exits (rooms.c accnt_maze).
|
||||
func (g *RogueGame) accntMaze(y, x, ny, nx int) {
|
||||
// digPick reservoir-picks the next unvisited maze cell; ok is false
|
||||
// when the digger is boxed in (the candidate scan of rooms.c dig).
|
||||
func (g *RogueGame) digPick(y, x int) (int, int, bool) {
|
||||
m := &g.maze
|
||||
del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}}
|
||||
|
||||
cnt := 0
|
||||
|
||||
var nexty, nextx int
|
||||
|
||||
for _, cp := range del {
|
||||
newy := y + cp.Y
|
||||
|
||||
newx := x + cp.X
|
||||
if newy < 0 || newy > m.maxy || newx < 0 || newx > m.maxx {
|
||||
continue
|
||||
}
|
||||
|
||||
if g.Level.FlagsAt(newy+m.starty, newx+m.startx).Has(FPassage) {
|
||||
continue
|
||||
}
|
||||
|
||||
if cnt++; g.rnd(cnt) == 0 {
|
||||
nexty = newy
|
||||
nextx = newx
|
||||
}
|
||||
}
|
||||
|
||||
return nexty, nextx, cnt != 0
|
||||
}
|
||||
|
||||
// digWallGap picks the wall square to knock out between two maze cells
|
||||
// (rooms.c dig).
|
||||
func digWallGap(m *mazeState, y, x, nexty, nextx int) Coord {
|
||||
if nexty == y {
|
||||
pos := Coord{Y: y + m.starty, X: nextx + m.startx - 1}
|
||||
if nextx-x < 0 {
|
||||
pos.X = nextx + m.startx + 1
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
pos := Coord{X: x + m.startx, Y: nexty + m.starty - 1}
|
||||
if nexty-y < 0 {
|
||||
pos.Y = nexty + m.starty + 1
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
// accountMaze accounts for maze exits (rooms.c accnt_maze).
|
||||
func (g *RogueGame) accountMaze(y, x, ny, nx int) {
|
||||
sp := &g.maze.maze[y][x]
|
||||
for i := 0; i < sp.nexits; i++ {
|
||||
for i := range sp.nexits {
|
||||
if sp.exits[i].Y == ny && sp.exits[i].X == nx {
|
||||
return
|
||||
}
|
||||
@@ -233,18 +298,21 @@ func (g *RogueGame) accntMaze(y, x, ny, nx int) {
|
||||
}
|
||||
}
|
||||
|
||||
// rndPos picks a random spot in a room (rooms.c rnd_pos).
|
||||
func (g *RogueGame) rndPos(rp *Room) Coord {
|
||||
// randomPos picks a random spot in a room (rooms.c rnd_pos).
|
||||
func (g *RogueGame) randomPos(rp *Room) Coord {
|
||||
var cp Coord
|
||||
|
||||
cp.X = rp.Pos.X + g.rnd(rp.Max.X-2) + 1
|
||||
cp.Y = rp.Pos.Y + g.rnd(rp.Max.Y-2) + 1
|
||||
|
||||
return cp
|
||||
}
|
||||
|
||||
// findFloor finds a valid floor spot, picking a new random room each time
|
||||
// around the loop (rooms.c find_floor with rp == NULL).
|
||||
func (g *RogueGame) findFloor(rp *Room, limit int, monst bool) (Coord, bool) {
|
||||
return g.findFloorImpl(rp, limit, monst, rp == nil)
|
||||
// around the loop; it retries forever (rooms.c find_floor with rp == NULL
|
||||
// — every such C call site passed FALSE for the limit).
|
||||
func (g *RogueGame) findFloor(monst bool) (Coord, bool) {
|
||||
return g.findFloorImpl(nil, 0, monst, true)
|
||||
}
|
||||
|
||||
// findFloorIn finds a valid floor spot in this room (rooms.c find_floor
|
||||
@@ -253,14 +321,24 @@ func (g *RogueGame) findFloorIn(rp *Room, limit int, monst bool) (Coord, bool) {
|
||||
return g.findFloorImpl(rp, limit, monst, false)
|
||||
}
|
||||
|
||||
func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Coord, bool) {
|
||||
// floorChar is what an empty spot looks like in a room: passage in a
|
||||
// maze, floor otherwise (rooms.c find_floor).
|
||||
func floorChar(rp *Room) byte {
|
||||
if rp.Flags.Has(Maze) {
|
||||
return Passage
|
||||
}
|
||||
|
||||
return Floor
|
||||
}
|
||||
|
||||
func (g *RogueGame) findFloorImpl(
|
||||
rp *Room, limit int, monst, pickroom bool,
|
||||
) (Coord, bool) {
|
||||
var compchar byte
|
||||
if !pickroom {
|
||||
compchar = Floor
|
||||
if rp.Flags.Has(Maze) {
|
||||
compchar = Passage
|
||||
}
|
||||
compchar = floorChar(rp)
|
||||
}
|
||||
|
||||
cnt := limit
|
||||
for {
|
||||
if limit != 0 {
|
||||
@@ -268,14 +346,14 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co
|
||||
return Coord{}, false
|
||||
}
|
||||
}
|
||||
|
||||
if pickroom {
|
||||
rp = &g.Level.Rooms[g.rndRoom()]
|
||||
compchar = Floor
|
||||
if rp.Flags.Has(Maze) {
|
||||
compchar = Passage
|
||||
}
|
||||
rp = &g.Level.Rooms[g.randomRoom()]
|
||||
compchar = floorChar(rp)
|
||||
}
|
||||
cp := g.rndPos(rp)
|
||||
|
||||
cp := g.randomPos(rp)
|
||||
|
||||
pp := g.Level.At(cp.Y, cp.X)
|
||||
if monst {
|
||||
if pp.Monst == nil && stepOk(pp.Ch) {
|
||||
@@ -291,40 +369,53 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co
|
||||
// enter_room).
|
||||
func (g *RogueGame) enterRoom(cp Coord) {
|
||||
p := &g.Player
|
||||
rp := g.roomin(cp)
|
||||
rp := g.roomIn(cp)
|
||||
p.Room = rp
|
||||
g.doorOpen(rp)
|
||||
|
||||
if !rp.Flags.Has(Dark) && !p.On(Blind) {
|
||||
for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ {
|
||||
g.move(y, rp.Pos.X)
|
||||
|
||||
for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ {
|
||||
tp := g.Level.MonsterAt(y, x)
|
||||
ch := g.Level.Char(y, x)
|
||||
if tp == nil {
|
||||
if g.inch() != ch {
|
||||
g.addch(ch)
|
||||
} else {
|
||||
g.move(y, x+1)
|
||||
}
|
||||
} else {
|
||||
tp.OldCh = ch
|
||||
if !g.seeMonst(tp) {
|
||||
if p.On(SenseMonsters) {
|
||||
g.standout()
|
||||
g.addch(tp.Disguise)
|
||||
g.standend()
|
||||
} else {
|
||||
g.addch(ch)
|
||||
}
|
||||
} else {
|
||||
g.addch(tp.Disguise)
|
||||
}
|
||||
}
|
||||
g.enterRoomCell(y, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enterRoomCell draws one square of a room being lit on entry (the loop
|
||||
// body of rooms.c enter_room).
|
||||
func (g *RogueGame) enterRoomCell(y, x int) {
|
||||
tp := g.Level.MonsterAt(y, x)
|
||||
|
||||
ch := g.Level.Char(y, x)
|
||||
if tp == nil {
|
||||
if g.inch() != ch {
|
||||
g.addch(ch)
|
||||
} else {
|
||||
g.move(y, x+1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tp.OldCh = ch
|
||||
if g.seeMonst(tp) {
|
||||
g.addch(tp.Disguise)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if g.Player.On(SenseMonsters) {
|
||||
g.standout()
|
||||
g.addch(tp.Disguise)
|
||||
g.standend()
|
||||
} else {
|
||||
g.addch(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// leaveRoom is the code for when we exit a room (rooms.c leave_room).
|
||||
func (g *RogueGame) leaveRoom(cp Coord) {
|
||||
p := &g.Player
|
||||
@@ -335,6 +426,7 @@ func (g *RogueGame) leaveRoom(cp Coord) {
|
||||
}
|
||||
|
||||
var floor byte
|
||||
|
||||
switch {
|
||||
case rp.Flags.Has(Gone):
|
||||
floor = Passage
|
||||
@@ -347,31 +439,43 @@ func (g *RogueGame) leaveRoom(cp Coord) {
|
||||
p.Room = &g.Level.Passages[*g.Level.FlagsAt(cp.Y, cp.X)&FPassNum]
|
||||
for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ {
|
||||
for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ {
|
||||
g.move(y, x)
|
||||
switch ch := g.inch(); ch {
|
||||
case Floor:
|
||||
if floor == ' ' {
|
||||
g.addch(' ')
|
||||
}
|
||||
default:
|
||||
// to check for monster, we have to strip out the standout
|
||||
// bit (our Window returns the bare character already)
|
||||
if isUpper(ch) {
|
||||
if p.On(SenseMonsters) {
|
||||
g.standout()
|
||||
g.addch(ch)
|
||||
g.standend()
|
||||
break
|
||||
}
|
||||
pp := g.Level.At(y, x)
|
||||
if pp.Ch == Door {
|
||||
g.addch(Door)
|
||||
} else {
|
||||
g.addch(floor)
|
||||
}
|
||||
}
|
||||
}
|
||||
g.leaveRoomCell(floor, y, x)
|
||||
}
|
||||
}
|
||||
|
||||
g.doorOpen(rp)
|
||||
}
|
||||
|
||||
// leaveRoomCell hides one square of a room being left (the loop body of
|
||||
// rooms.c leave_room).
|
||||
func (g *RogueGame) leaveRoomCell(floor byte, y, x int) {
|
||||
g.move(y, x)
|
||||
|
||||
switch ch := g.inch(); ch {
|
||||
case Floor:
|
||||
if floor == ' ' {
|
||||
g.addch(' ')
|
||||
}
|
||||
default:
|
||||
// to check for monster, we have to strip out the standout
|
||||
// bit (our Window returns the bare character already)
|
||||
if !isUpper(ch) {
|
||||
return
|
||||
}
|
||||
|
||||
if g.Player.On(SenseMonsters) {
|
||||
g.standout()
|
||||
g.addch(ch)
|
||||
g.standend()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pp := g.Level.At(y, x)
|
||||
if pp.Ch == Door {
|
||||
g.addch(Door)
|
||||
} else {
|
||||
g.addch(floor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
253
game/run_test.go
253
game/run_test.go
@@ -1,104 +1,211 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRunScriptedSession drives a complete game through Run(): a few
|
||||
// moves, a rest, an inventory, then Q-quit answered yes.
|
||||
func TestRunScriptedSession(t *testing.T) {
|
||||
tt := &testTerm{input: []byte("hjkl.i Qy")}
|
||||
g := NewGame(Config{Seed: 99, Term: tt})
|
||||
if err := g.Run(); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if g.Playing {
|
||||
t.Error("still playing after quit")
|
||||
}
|
||||
// After quitting, the scoreboard is the last thing shown (in C it went
|
||||
// to stdout after endwin; here it is drawn on the screen).
|
||||
found := false
|
||||
for y := 0; y < NumLines; y++ {
|
||||
if strings.Contains(g.scr.Std.Line(y), "Top Ten") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("score list not on screen after quit")
|
||||
// fortify makes the hero effectively immortal for a crash-sweep drive:
|
||||
// game-over now calls myExit and os.Exit(0) (step 8), which would kill the
|
||||
// test binary, so every death vector is neutralized. Re-applied each turn
|
||||
// because combat, digestion, freezing, and level drain chip away at these.
|
||||
func fortify(g *RogueGame) {
|
||||
p := &g.Player
|
||||
p.Stats.HP = 30000 // survive combat, arrow/dart traps, bolts
|
||||
p.Stats.MaxHP = 30000 // survive vampire max-hp drain
|
||||
p.Stats.Exp = 30000 // survive wraith level drain (death when exp hits 0)
|
||||
p.FoodLeft = 30000 // never starve
|
||||
g.NoCommand = 0 // never freeze to death (ice monster / sleep trap)
|
||||
g.NoMove = 0 // never stay stuck in a bear trap
|
||||
}
|
||||
|
||||
// driveTurns runs the game's per-turn loop up to n times, doing the same
|
||||
// first-level and pre-play setup Run() does. Run() itself no longer
|
||||
// returns — game-over exits the process — so tests drive command()
|
||||
// directly, with short scripts that avoid quitting, saving, or playing
|
||||
// long enough to starve, any of which would exit the test binary.
|
||||
func driveTurns(t *testing.T, g *RogueGame, n int) {
|
||||
t.Helper()
|
||||
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
for range n {
|
||||
g.command()
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunManyTurns mashes movement keys for a while as a crash sweep of
|
||||
// the whole turn loop (daemons, hunger, monsters, combat), ending with a
|
||||
// quit. The input alternates directions so the hero bumps around rooms.
|
||||
func TestRunManyTurns(t *testing.T) {
|
||||
// Spaces between commands double as answers to any --More-- prompts;
|
||||
// without them a single prompt would swallow the rest of the script
|
||||
// (wait_for eats everything that isn't a space).
|
||||
var script []byte
|
||||
moves := []byte("h h j j k k l l y u b n s s . . ")
|
||||
for range 200 {
|
||||
script = append(script, moves...)
|
||||
}
|
||||
script = append(script, " Q y Qy"...)
|
||||
tt := &testTerm{input: script}
|
||||
g := NewGame(Config{Seed: 31337, Term: tt})
|
||||
if err := g.Run(); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if g.Playing {
|
||||
t.Error("session did not end")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDownStairs walks the hero onto the stairs by teleporting there in
|
||||
// wizard style, then descends and keeps playing.
|
||||
// TestRunDownStairs stands the hero on the staircase and descends via the
|
||||
// '>' command through the real turn loop, then checks the level changed.
|
||||
func TestRunDownStairs(t *testing.T) {
|
||||
tt := &testTerm{input: []byte(">..Qy")}
|
||||
g := NewGame(Config{Seed: 7, Term: tt})
|
||||
t.Parallel()
|
||||
// '>' is a free action (After=false), so it is followed by a paying
|
||||
// rest ('.') to end the command() call; without a paying action the
|
||||
// turn loop would spin forever on the auto-fed prompt input.
|
||||
tt := &testTerm{input: []byte(">.")}
|
||||
g := New(Params{Seed: 7, Term: tt})
|
||||
g.NewLevel()
|
||||
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
||||
g.restored = true // keep Run from regenerating the level
|
||||
g.restored = true // keep startLevel from regenerating
|
||||
g.Daemons = DaemonList{} // and give it a fresh daemon table
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
if err := g.Run(); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
driveTurns(t, g, 1)
|
||||
|
||||
if g.Depth != 2 {
|
||||
t.Errorf("depth = %d after descending, want 2", g.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveCommandRoundTrip saves via the 'S' command (as a player would)
|
||||
// and restores the game.
|
||||
func TestSaveCommandRoundTrip(t *testing.T) {
|
||||
// The C get_str caps input at MAXINP=50 characters, so the save path
|
||||
// must be short: work from the temp directory.
|
||||
t.Chdir(t.TempDir())
|
||||
path := "cmd.save"
|
||||
// 'S' with no default file name goes straight to the name prompt.
|
||||
script := "S" + path + "\n"
|
||||
tt := &testTerm{input: []byte(script)}
|
||||
g := NewGame(Config{Seed: 55, Term: tt})
|
||||
g.FileName = "" // force the name prompt
|
||||
if err := g.Run(); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
// TestScoreRendersList checks that the scoreboard is drawn on the screen
|
||||
// (in C it went to stdout after endwin; here it stays on the screen). The
|
||||
// quit and death paths that normally show it now exit the process, so the
|
||||
// display is exercised through score() directly.
|
||||
func TestScoreRendersList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 1, Term: &testTerm{}})
|
||||
g.Player.Purse = 100
|
||||
|
||||
g.score(g.Player.Purse, 1, 0) // flags 1 = quit; posts the top-ten list
|
||||
|
||||
found := false
|
||||
|
||||
for y := range NumLines {
|
||||
if strings.Contains(g.scr.Std.Line(y), "Top Ten") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
||||
if !found {
|
||||
t.Error("score list not on screen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeepPlaythrough drives a fortified hero through the real command loop:
|
||||
// quaff/read/zap on the first level, then descend through the staircase to
|
||||
// depth 8, saving and restoring mid-way. It is a crash sweep of the turn
|
||||
// engine, deep level generation, item effects, and mid-game save/restore.
|
||||
// The hero is fortified so no death exits the process (step 8), and the fixed
|
||||
// seed keeps it deterministic.
|
||||
func TestDeepPlaythrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 4242, Wizard: true, Term: &testTerm{}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
fortify(g)
|
||||
|
||||
// Stock and use one of each consumable through the command dispatch.
|
||||
pot := give(g, &Object{Kind: KindPotion, Which: int(PotionHealing)})
|
||||
scr := give(g, &Object{Kind: KindScroll, Which: int(ScrollMagicMapping)})
|
||||
|
||||
wand := newObject()
|
||||
wand.Kind = KindWand
|
||||
wand.Which = int(WandLight)
|
||||
wand.Charges = 5
|
||||
zap := give(g, wand)
|
||||
|
||||
setInput(t, g, 'q', pot) // quaff healing
|
||||
g.command()
|
||||
setInput(t, g, 'r', scr) // read magic mapping
|
||||
g.command()
|
||||
setInput(t, g, 'z', 'h', zap) // zap the light wand west
|
||||
g.command()
|
||||
fortify(g)
|
||||
|
||||
// Each consumable identifies itself on use, confirming the q/r/z
|
||||
// commands actually ran through dispatch (not aborted on a bad prompt).
|
||||
if !g.Items.Potions[PotionHealing].Know {
|
||||
t.Error("quaff command did not identify the healing potion")
|
||||
}
|
||||
|
||||
if !g.Items.Scrolls[ScrollMagicMapping].Know {
|
||||
t.Error("read command did not identify the magic-mapping scroll")
|
||||
}
|
||||
|
||||
if !g.Items.Sticks[WandLight].Know {
|
||||
t.Error("zap command did not identify the light wand")
|
||||
}
|
||||
|
||||
const wantDepth = 8
|
||||
|
||||
for g.Depth < wantDepth {
|
||||
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
||||
setInput(t, g, '>', '.') // '>' descends (free), '.' pays the turn
|
||||
g.command()
|
||||
fortify(g)
|
||||
|
||||
if g.Depth == 4 {
|
||||
g = saveAndRestore(t, g)
|
||||
fortify(g)
|
||||
}
|
||||
}
|
||||
|
||||
if g.Depth != wantDepth {
|
||||
t.Errorf("depth = %d after descending, want %d", g.Depth, wantDepth)
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP <= 0 {
|
||||
t.Error("hero died during the playthrough")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnLoopCrashSweep mashes movement, search, and rest through the real
|
||||
// turn loop for many turns on several seeds, exercising combat, monster AI,
|
||||
// and traps. The hero is fortified each turn so nothing exits the process,
|
||||
// and the fixed seeds keep it deterministic; the point is to surface panics.
|
||||
func TestTurnLoopCrashSweep(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A generous mix of movement, search, and rest. The spaces between
|
||||
// commands double as answers to any --More-- prompt (wait_for eats
|
||||
// everything up to a space); without them one prompt would swallow the
|
||||
// rest of the script. The script is long enough that the bounded drive
|
||||
// never exhausts it (which would spin on the auto-fed prompt input).
|
||||
script := []byte(strings.Repeat("h j k l y u b n s . ", 400))
|
||||
|
||||
for _, seed := range []int32{1, 99, 2026, 31337} {
|
||||
g := New(Params{Seed: seed, Term: &testTerm{input: script}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
for range 200 {
|
||||
fortify(g)
|
||||
g.command()
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP <= 0 {
|
||||
t.Errorf("seed %d: hero died despite fortify", seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// saveAndRestore snapshots the game to a file, restores it, checks the key
|
||||
// state survived, and returns the restored game ready to keep playing.
|
||||
func saveAndRestore(t *testing.T, g *RogueGame) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "deep.save")
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr != nil {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
h, err := Restore(path, Params{Wizard: true, Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
|
||||
t.Error("restored game does not match saved game")
|
||||
}
|
||||
// The restored game must be playable.
|
||||
h.scr.term.(*testTerm).input = []byte("..Qy")
|
||||
if err := h.Run(); err != nil {
|
||||
t.Fatalf("restored Run: %v", err)
|
||||
t.Errorf("restored game diverged: depth %d/%d purse %d/%d",
|
||||
h.Depth, g.Depth, h.Player.Purse, g.Player.Purse)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
698
game/save.go
698
game/save.go
@@ -1,9 +1,13 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// save.c + state.c — game persistence. The hand-written, XOR-encrypted C
|
||||
@@ -131,16 +135,19 @@ func (g *RogueGame) roomIdx(rp *Room) int {
|
||||
if rp == nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i := range g.Level.Rooms {
|
||||
if rp == &g.Level.Rooms[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
for i := range g.Level.Passages {
|
||||
if rp == &g.Level.Passages[i] {
|
||||
return 100 + i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -152,6 +159,7 @@ func (g *RogueGame) roomAt(i int) *Room {
|
||||
case i >= 0:
|
||||
return &g.Level.Rooms[i]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -160,18 +168,60 @@ func (g *RogueGame) packIdx(obj *Object) int {
|
||||
if obj == nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
for i, o := range g.Player.Pack {
|
||||
if o == obj {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// snapshot captures the complete game state.
|
||||
func (g *RogueGame) snapshot() *SaveState {
|
||||
p := &g.Player
|
||||
st := &SaveState{
|
||||
st := g.snapshotHeader()
|
||||
|
||||
// the map, sans monster pointers (rebuilt on load)
|
||||
st.Places = make([]savedPlace, len(g.Level.Places))
|
||||
for i := range g.Level.Places {
|
||||
st.Places[i] = savedPlace{
|
||||
Ch: g.Level.Places[i].Ch,
|
||||
Flags: g.Level.Places[i].Flags,
|
||||
}
|
||||
}
|
||||
|
||||
// level objects by value; remember their pointers for dest encoding
|
||||
objAt := make(map[*Object]int, len(g.Level.Objects))
|
||||
for i, o := range g.Level.Objects {
|
||||
st.Objects = append(st.Objects, *o)
|
||||
objAt[o] = i
|
||||
}
|
||||
|
||||
st.Player = g.snapshotPlayer()
|
||||
|
||||
// monsters, with chase targets as (kind, index) references
|
||||
for _, m := range g.Level.Monsters {
|
||||
sc := savedCreature{
|
||||
Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise,
|
||||
OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats,
|
||||
RoomIdx: g.roomIdx(m.Room),
|
||||
}
|
||||
for _, o := range m.Pack {
|
||||
sc.Pack = append(sc.Pack, *o)
|
||||
}
|
||||
|
||||
st.Monsters = append(st.Monsters, sc)
|
||||
st.Dests = append(st.Dests, g.destRefFor(m, objAt))
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
// snapshotHeader captures the scalar game state (the field list of
|
||||
// state.c rs_save_file).
|
||||
func (g *RogueGame) snapshotHeader() *SaveState {
|
||||
return &SaveState{
|
||||
Version: saveFormatVersion,
|
||||
Seed: g.Rng.Seed,
|
||||
Dnum: g.Dnum,
|
||||
@@ -219,25 +269,14 @@ func (g *RogueGame) snapshot() *SaveState {
|
||||
AllScore: g.AllScore,
|
||||
Screen: g.scr.Std.Contents(),
|
||||
}
|
||||
}
|
||||
|
||||
// the map, sans monster pointers (rebuilt on load)
|
||||
st.Places = make([]savedPlace, len(g.Level.Places))
|
||||
for i := range g.Level.Places {
|
||||
st.Places[i] = savedPlace{
|
||||
Ch: g.Level.Places[i].Ch,
|
||||
Flags: g.Level.Places[i].Flags,
|
||||
}
|
||||
}
|
||||
// snapshotPlayer captures the player, equipment as pack indices (the
|
||||
// player half of snapshot).
|
||||
func (g *RogueGame) snapshotPlayer() savedPlayer {
|
||||
p := &g.Player
|
||||
|
||||
// level objects by value; remember their pointers for dest encoding
|
||||
objAt := make(map[*Object]int, len(g.Level.Objects))
|
||||
for i, o := range g.Level.Objects {
|
||||
st.Objects = append(st.Objects, *o)
|
||||
objAt[o] = i
|
||||
}
|
||||
|
||||
// the player
|
||||
st.Player = savedPlayer{
|
||||
sp := savedPlayer{
|
||||
Body: savedCreature{
|
||||
Pos: p.Pos, Turn: p.Turn, Type: p.Type, Disguise: p.Disguise,
|
||||
OldCh: p.OldCh, Flags: p.Flags, Stats: p.Stats,
|
||||
@@ -253,55 +292,72 @@ func (g *RogueGame) snapshot() *SaveState {
|
||||
MaxStats: p.MaxStats, VfHit: p.VfHit,
|
||||
}
|
||||
for _, o := range p.Pack {
|
||||
st.Player.Body.Pack = append(st.Player.Body.Pack, *o)
|
||||
sp.Body.Pack = append(sp.Body.Pack, *o)
|
||||
}
|
||||
|
||||
// monsters, with chase targets as (kind, index) references
|
||||
for _, m := range g.Level.Monsters {
|
||||
sc := savedCreature{
|
||||
Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise,
|
||||
OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats,
|
||||
RoomIdx: g.roomIdx(m.Room),
|
||||
}
|
||||
for _, o := range m.Pack {
|
||||
sc.Pack = append(sc.Pack, *o)
|
||||
}
|
||||
st.Monsters = append(st.Monsters, sc)
|
||||
return sp
|
||||
}
|
||||
|
||||
ref := destRef{}
|
||||
switch {
|
||||
case m.Dest == nil:
|
||||
case m.Dest == &p.Pos:
|
||||
ref = destRef{Kind: 1}
|
||||
default:
|
||||
for mi, om := range g.Level.Monsters {
|
||||
if m.Dest == &om.Pos {
|
||||
ref = destRef{Kind: 2, Idx: mi}
|
||||
}
|
||||
}
|
||||
if ref.Kind == 0 {
|
||||
for _, oo := range g.Level.Objects {
|
||||
if m.Dest == &oo.Pos {
|
||||
ref = destRef{Kind: 3, Idx: objAt[oo]}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ref.Kind == 0 {
|
||||
for ri := range g.Level.Rooms {
|
||||
if m.Dest == &g.Level.Rooms[ri].Gold {
|
||||
ref = destRef{Kind: 4, Idx: ri}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
st.Dests = append(st.Dests, ref)
|
||||
// destRefFor encodes a monster's chase target as a (kind, index)
|
||||
// reference: the hero, another monster, a level object, or room gold
|
||||
// (state.c rs_write_thing).
|
||||
func (g *RogueGame) destRefFor(m *Monster, objAt map[*Object]int) destRef {
|
||||
switch {
|
||||
case m.Dest == nil:
|
||||
return destRef{}
|
||||
case m.Dest == &g.Player.Pos:
|
||||
return destRef{Kind: 1}
|
||||
}
|
||||
return st
|
||||
|
||||
for mi, om := range g.Level.Monsters {
|
||||
if m.Dest == &om.Pos {
|
||||
return destRef{Kind: 2, Idx: mi}
|
||||
}
|
||||
}
|
||||
|
||||
for _, oo := range g.Level.Objects {
|
||||
if m.Dest == &oo.Pos {
|
||||
return destRef{Kind: 3, Idx: objAt[oo]}
|
||||
}
|
||||
}
|
||||
|
||||
for ri := range g.Level.Rooms {
|
||||
if m.Dest == &g.Level.Rooms[ri].Gold {
|
||||
return destRef{Kind: 4, Idx: ri}
|
||||
}
|
||||
}
|
||||
|
||||
return destRef{}
|
||||
}
|
||||
|
||||
// applySnapshot rebuilds live game state from a snapshot.
|
||||
func (g *RogueGame) applySnapshot(st *SaveState) {
|
||||
p := &g.Player
|
||||
g.applyHeader(st)
|
||||
|
||||
for i := range g.Level.Places {
|
||||
g.Level.Places[i] = Place{
|
||||
Ch: st.Places[i].Ch,
|
||||
Flags: st.Places[i].Flags,
|
||||
}
|
||||
}
|
||||
|
||||
// level objects
|
||||
g.Level.Objects = nil
|
||||
for i := range st.Objects {
|
||||
o := st.Objects[i]
|
||||
g.Level.Objects = append(g.Level.Objects, &o)
|
||||
}
|
||||
|
||||
g.applyPlayer(st)
|
||||
g.applyMonsters(st)
|
||||
g.applyDests(st)
|
||||
|
||||
g.scr.Std.SetContents(st.Screen)
|
||||
}
|
||||
|
||||
// applyHeader restores the scalar game state (the field list of
|
||||
// applySnapshot).
|
||||
func (g *RogueGame) applyHeader(st *SaveState) {
|
||||
g.Rng.Seed = st.Seed
|
||||
g.Dnum = st.Dnum
|
||||
g.Whoami = st.Whoami
|
||||
@@ -316,6 +372,12 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
|
||||
g.Level.Passages = st.Passages
|
||||
g.Level.Stairs = st.Stairs
|
||||
g.Level.TrapCount = st.TrapCount
|
||||
g.applyTurnState(st)
|
||||
}
|
||||
|
||||
// applyTurnState restores the in-turn command state (the second half of
|
||||
// applyHeader).
|
||||
func (g *RogueGame) applyTurnState(st *SaveState) {
|
||||
g.After = st.After
|
||||
g.Again = st.Again
|
||||
g.NoScore = st.NoScoreF
|
||||
@@ -346,22 +408,12 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
|
||||
g.LastScore = st.LastScore
|
||||
g.AllScore = st.AllScore
|
||||
g.Playing = true
|
||||
}
|
||||
|
||||
for i := range g.Level.Places {
|
||||
g.Level.Places[i] = Place{
|
||||
Ch: st.Places[i].Ch,
|
||||
Flags: st.Places[i].Flags,
|
||||
}
|
||||
}
|
||||
|
||||
// level objects
|
||||
g.Level.Objects = nil
|
||||
for i := range st.Objects {
|
||||
o := st.Objects[i]
|
||||
g.Level.Objects = append(g.Level.Objects, &o)
|
||||
}
|
||||
|
||||
// the player
|
||||
// applyPlayer restores the player, resolving equipment pack indices
|
||||
// (the player half of applySnapshot).
|
||||
func (g *RogueGame) applyPlayer(st *SaveState) {
|
||||
p := &g.Player
|
||||
sp := &st.Player
|
||||
p.Pos = sp.Body.Pos
|
||||
p.Turn = sp.Body.Turn
|
||||
@@ -371,15 +423,18 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
|
||||
p.Flags = sp.Body.Flags
|
||||
p.Stats = sp.Body.Stats
|
||||
p.Room = g.roomAt(sp.Body.RoomIdx)
|
||||
|
||||
p.Pack = nil
|
||||
for i := range sp.Body.Pack {
|
||||
o := sp.Body.Pack[i]
|
||||
p.Pack = append(p.Pack, &o)
|
||||
}
|
||||
|
||||
pick := func(i int) *Object {
|
||||
if i < 0 || i >= len(p.Pack) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.Pack[i]
|
||||
}
|
||||
p.CurArmor = pick(sp.CurArmor)
|
||||
@@ -395,11 +450,15 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
|
||||
p.NoFood = sp.NoFood
|
||||
p.MaxStats = sp.MaxStats
|
||||
p.VfHit = sp.VfHit
|
||||
}
|
||||
|
||||
// monsters, their map index, and their chase targets
|
||||
// applyMonsters rebuilds the monster list and its map index from a
|
||||
// snapshot (the monster half of applySnapshot).
|
||||
func (g *RogueGame) applyMonsters(st *SaveState) {
|
||||
g.Level.Monsters = nil
|
||||
for i := range st.Monsters {
|
||||
sc := &st.Monsters[i]
|
||||
|
||||
m := &Monster{Creature: Creature{
|
||||
Pos: sc.Pos, Turn: sc.Turn, Type: sc.Type, Disguise: sc.Disguise,
|
||||
OldCh: sc.OldCh, Flags: sc.Flags, Stats: sc.Stats,
|
||||
@@ -409,14 +468,21 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
|
||||
o := sc.Pack[j]
|
||||
m.Pack = append(m.Pack, &o)
|
||||
}
|
||||
|
||||
g.Level.Monsters = append(g.Level.Monsters, m)
|
||||
g.Level.SetMonsterAt(m.Pos.Y, m.Pos.X, m)
|
||||
}
|
||||
}
|
||||
|
||||
// applyDests re-aims the monsters' chase targets from their (kind,
|
||||
// index) references (the fixup half of applySnapshot).
|
||||
func (g *RogueGame) applyDests(st *SaveState) {
|
||||
for i, ref := range st.Dests {
|
||||
m := g.Level.Monsters[i]
|
||||
|
||||
switch ref.Kind {
|
||||
case 1:
|
||||
m.Dest = &p.Pos
|
||||
m.Dest = &g.Player.Pos
|
||||
case 2:
|
||||
m.Dest = &g.Level.Monsters[ref.Idx].Pos
|
||||
case 3:
|
||||
@@ -425,136 +491,444 @@ func (g *RogueGame) applySnapshot(st *SaveState) {
|
||||
m.Dest = &g.Level.Rooms[ref.Idx].Gold
|
||||
}
|
||||
}
|
||||
|
||||
g.scr.Std.SetContents(st.Screen)
|
||||
}
|
||||
|
||||
// saveGame implements the "save game" command (save.c save_game). The C
|
||||
// goto over/gotfile flow becomes the useDefault flag.
|
||||
// saveAnswer is a yes/no/escape prompt result in the save-game flow.
|
||||
type saveAnswer int
|
||||
|
||||
// The saveGame prompt outcomes.
|
||||
const (
|
||||
saveYes saveAnswer = iota
|
||||
saveNo
|
||||
saveAbort
|
||||
)
|
||||
|
||||
// saveGame implements the "save game" command (save.c save_game). The
|
||||
// labeled prompt loop stands in for the C goto over/gotfile flow.
|
||||
func (g *RogueGame) saveGame() {
|
||||
g.Msgs.Mpos = 0
|
||||
over:
|
||||
useDefault := false
|
||||
if g.FileName != "" {
|
||||
var c byte
|
||||
for {
|
||||
g.msg("save file (%s)? ", g.FileName)
|
||||
c = g.readchar()
|
||||
g.Msgs.Mpos = 0
|
||||
if c == Escape {
|
||||
g.msg("")
|
||||
|
||||
prompt:
|
||||
for {
|
||||
useDefault := false
|
||||
|
||||
if g.FileName != "" {
|
||||
a := g.askDefaultSave()
|
||||
if a == saveAbort {
|
||||
return
|
||||
}
|
||||
if c == 'n' || c == 'N' || c == 'y' || c == 'Y' {
|
||||
break
|
||||
}
|
||||
g.msg("please answer Y or N")
|
||||
|
||||
useDefault = a == saveYes
|
||||
}
|
||||
if c == 'y' || c == 'Y' {
|
||||
g.addstr("Yes\n")
|
||||
g.refresh()
|
||||
useDefault = true
|
||||
|
||||
for {
|
||||
buf, ok := g.saveFileName(useDefault)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
useDefault = false
|
||||
|
||||
a := g.saveCheckOverwrite(buf)
|
||||
if a == saveAbort {
|
||||
return
|
||||
}
|
||||
|
||||
if a == saveNo {
|
||||
continue prompt // the C goto over: start again
|
||||
}
|
||||
|
||||
g.FileName = buf
|
||||
|
||||
err := g.saveFile(g.FileName)
|
||||
if err != nil {
|
||||
g.msg("%s", err.Error())
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break prompt
|
||||
}
|
||||
}
|
||||
|
||||
g.myExit()
|
||||
}
|
||||
|
||||
// askDefaultSave asks whether to save to the current file name (save.c
|
||||
// save_game).
|
||||
func (g *RogueGame) askDefaultSave() saveAnswer {
|
||||
for {
|
||||
var buf string
|
||||
if useDefault {
|
||||
buf = g.FileName
|
||||
useDefault = false
|
||||
} else {
|
||||
g.Msgs.Mpos = 0
|
||||
g.msg("file name: ")
|
||||
if g.getStr(&buf, g.scr.Std) == Quit {
|
||||
g.msg("")
|
||||
return
|
||||
}
|
||||
g.Msgs.Mpos = 0
|
||||
g.msg("save file (%s)? ", g.FileName)
|
||||
c := g.readchar()
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
switch c {
|
||||
case Escape:
|
||||
g.msg("")
|
||||
|
||||
return saveAbort
|
||||
case 'y', 'Y':
|
||||
g.addstr("Yes\n")
|
||||
g.refresh()
|
||||
|
||||
return saveYes
|
||||
case 'n', 'N':
|
||||
return saveNo
|
||||
}
|
||||
// test to see if the file exists
|
||||
if _, err := os.Stat(buf); err == nil {
|
||||
for {
|
||||
g.msg("File exists. Do you wish to overwrite it?")
|
||||
g.Msgs.Mpos = 0
|
||||
c := g.readchar()
|
||||
if c == Escape {
|
||||
g.msg("")
|
||||
return
|
||||
}
|
||||
if c == 'y' || c == 'Y' {
|
||||
break
|
||||
}
|
||||
if c == 'n' || c == 'N' {
|
||||
goto over
|
||||
}
|
||||
g.msg("Please answer Y or N")
|
||||
}
|
||||
g.msg("file name: %s", buf)
|
||||
os.Remove(g.FileName)
|
||||
}
|
||||
g.FileName = buf
|
||||
if err := g.saveFile(g.FileName); err != nil {
|
||||
g.msg("%s", err.Error())
|
||||
continue
|
||||
}
|
||||
break
|
||||
|
||||
g.msg("please answer Y or N")
|
||||
}
|
||||
}
|
||||
|
||||
// saveFileName picks the save path: the default, or a prompted one; ok
|
||||
// is false when the player quit the prompt (save.c save_game).
|
||||
func (g *RogueGame) saveFileName(useDefault bool) (string, bool) {
|
||||
if useDefault {
|
||||
return g.FileName, true
|
||||
}
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
g.msg("file name: ")
|
||||
|
||||
buf := ""
|
||||
if g.getStr(&buf, g.scr.Std) == Quit {
|
||||
g.msg("")
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
return buf, true
|
||||
}
|
||||
|
||||
// saveCheckOverwrite guards an existing file: saveNo restarts the whole
|
||||
// prompt, saveAbort quits (save.c save_game).
|
||||
func (g *RogueGame) saveCheckOverwrite(buf string) saveAnswer {
|
||||
// test to see if the file exists
|
||||
_, statErr := os.Stat(buf)
|
||||
if statErr != nil {
|
||||
return saveYes
|
||||
}
|
||||
|
||||
answer := g.askOverwrite()
|
||||
if answer != saveYes {
|
||||
return answer
|
||||
}
|
||||
|
||||
g.msg("file name: %s", buf)
|
||||
_ = os.Remove(g.FileName) // best effort, as in C (md_unlink)
|
||||
|
||||
return saveYes
|
||||
}
|
||||
|
||||
// askOverwrite asks whether to overwrite the existing file (save.c
|
||||
// save_game).
|
||||
func (g *RogueGame) askOverwrite() saveAnswer {
|
||||
for {
|
||||
g.msg("File exists. Do you wish to overwrite it?")
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
switch g.readchar() {
|
||||
case Escape:
|
||||
g.msg("")
|
||||
|
||||
return saveAbort
|
||||
case 'y', 'Y':
|
||||
return saveYes
|
||||
case 'n', 'N':
|
||||
return saveNo
|
||||
}
|
||||
|
||||
g.msg("Please answer Y or N")
|
||||
}
|
||||
g.myExit(0)
|
||||
}
|
||||
|
||||
// saveFile writes the saved game (save.c save_file).
|
||||
//
|
||||
// The snapshot goes to a temporary file in the target's own directory and
|
||||
// is renamed over the target, so there is no instant at which the player
|
||||
// has no save file: until the rename the old file is whole, and after it
|
||||
// the new one is. C wrote straight over the target, and this port did the
|
||||
// same with a remove in front of it (AutoSave), so a write that failed —
|
||||
// or a signal-time save cut short by the process dying — could leave the
|
||||
// player with neither the old save nor a usable new one (issue #24).
|
||||
//
|
||||
// The temporary file is fsynced before the rename so its contents reach
|
||||
// the disk ahead of the directory entry that will point at it. The
|
||||
// directory itself is not fsynced: that would only matter for a machine
|
||||
// that loses power in the same instant, and the old save survives that
|
||||
// case anyway. A process killed mid-encode leaves its temporary file
|
||||
// behind, which is litter next to a destroyed save file, and the dot
|
||||
// prefix keeps it out of the way.
|
||||
func (g *RogueGame) saveFile(path string) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400)
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".rogue-save-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := gob.NewEncoder(f).Encode(g.snapshot()); err != nil {
|
||||
os.Remove(path)
|
||||
return err
|
||||
|
||||
tmp := f.Name()
|
||||
|
||||
writeErr := writeSnapshotFile(f, g.snapshot())
|
||||
if writeErr != nil {
|
||||
_ = os.Remove(tmp) // never leave a half-written file behind
|
||||
|
||||
return writeErr
|
||||
}
|
||||
return os.Chmod(path, 0o400)
|
||||
|
||||
renErr := os.Rename(tmp, path)
|
||||
if renErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
|
||||
return renErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM
|
||||
// (save.c auto_save).
|
||||
func (g *RogueGame) AutoSave() {
|
||||
if g.FileName != "" {
|
||||
os.Remove(g.FileName)
|
||||
g.saveFile(g.FileName)
|
||||
// writeSnapshotFile writes the snapshot into an open temporary file: it
|
||||
// encodes, fsyncs so the bytes reach the disk before the caller renames
|
||||
// the file into place, chmods it read-only as the C game's saves were
|
||||
// (save.c save_file), and closes it. It never removes the file: its
|
||||
// caller owns the cleanup, so that one place decides what happens to a
|
||||
// failed write.
|
||||
func writeSnapshotFile(f *os.File, st *SaveState) error {
|
||||
encErr := gob.NewEncoder(f).Encode(st)
|
||||
if encErr == nil {
|
||||
encErr = f.Sync()
|
||||
}
|
||||
|
||||
if encErr == nil {
|
||||
encErr = f.Chmod(0o400)
|
||||
}
|
||||
|
||||
closeErr := f.Close()
|
||||
|
||||
if encErr != nil {
|
||||
return encErr
|
||||
}
|
||||
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// autoSaveRequest is one signal-triggered autosave in flight: the signal
|
||||
// goroutine posts it and waits, the game goroutine performs the save and
|
||||
// closes done. ok is written before done is closed and read only after,
|
||||
// so the close is the happens-before edge that publishes it.
|
||||
type autoSaveRequest struct {
|
||||
done chan struct{}
|
||||
ok bool
|
||||
}
|
||||
|
||||
// AutoSaveOnSignal asks the game goroutine to autosave and waits up to
|
||||
// timeout for it to finish, reporting whether the save actually ran
|
||||
// (save.c auto_save, the SIGHUP/SIGTERM handler). It is the only entry
|
||||
// point the signal goroutine may use, and it deliberately touches no game
|
||||
// state: the gob encoder used to walk the live game tree from the signal
|
||||
// goroutine while the game goroutine was mid-turn mutating it (issue
|
||||
// #24).
|
||||
//
|
||||
// Blocked on input is the case that matters, since a dropped connection
|
||||
// is the whole reason the handler exists: the request is posted first and
|
||||
// the input read is then interrupted, so a game goroutine parked in
|
||||
// ReadChar wakes, saves in readchar, and reads again. A game goroutine
|
||||
// that is running turns instead picks the request up between turns, in
|
||||
// command; one parked in the `!` shell escape picks it up in
|
||||
// runShellEscape.
|
||||
//
|
||||
// The wait is bounded because the signal goroutine's job is to get the
|
||||
// process out. If the game goroutine is somewhere with no service point
|
||||
// at all, the deadline expires, this reports false, and the caller
|
||||
// restores the terminal and exits — leaving the player's previous save
|
||||
// file exactly as it was, which is the point of the rename in saveFile.
|
||||
func (g *RogueGame) AutoSaveOnSignal(timeout time.Duration) bool {
|
||||
req := &autoSaveRequest{done: make(chan struct{})}
|
||||
|
||||
select {
|
||||
case g.sigSave <- req:
|
||||
default:
|
||||
// A request is already queued and unserviced, or there is no
|
||||
// game loop to service one; either way this one would not be
|
||||
// answered either.
|
||||
return false
|
||||
}
|
||||
|
||||
g.scr.Interrupt()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-req.done:
|
||||
return req.ok
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// serviceAutoSaveRequest performs a pending signal-triggered autosave, if
|
||||
// one is waiting, and otherwise returns at once. It runs on the game
|
||||
// goroutine — that is the whole design — so it must only be called where
|
||||
// that goroutine is not itself inside the encode: between turns, or while
|
||||
// parked waiting for input or for the shell escape.
|
||||
//
|
||||
// What is guaranteed, exactly: the encode runs on the one goroutine that
|
||||
// owns the state, so the snapshot is internally consistent and always
|
||||
// restorable. It is *not* guaranteed to be a between-commands snapshot.
|
||||
// Only one of the three service points gives that: the check at the top
|
||||
// of command, which runs after the previous command returned and before
|
||||
// this turn's DoDaemons(Before)/DoFuses(Before). The other two are both
|
||||
// reached from inside a command call already under way, and both cost
|
||||
// the same on restore.
|
||||
//
|
||||
// readchar is reached from prompts raised part-way through a command —
|
||||
// --More-- on the second message of a turn, askOverwrite, getStr, the
|
||||
// direction and pack prompts — and by then the command has already
|
||||
// mutated state: fight sets g.Count and g.Quiet and runs runTo before
|
||||
// any message, revealXeroc writes tp.Disguise before emitting one. The
|
||||
// ordinary top-of-turn key read in readCommand is inside command too,
|
||||
// after that turn's BEFORE daemons and turnUpkeep.
|
||||
//
|
||||
// runShellEscape is no safer. shell is an ordinary command handler ('!'
|
||||
// in the tables.go dispatch table), reached through executeCommand, so a
|
||||
// goroutine parked in the shell escape has already run this turn's
|
||||
// DoDaemons(Before), DoFuses(Before), turnUpkeep and the last-command
|
||||
// bookkeeping, and has not yet run DoDaemons(After), DoFuses(After) or
|
||||
// ringTurnEffects.
|
||||
//
|
||||
// The cost, at both: restoring re-enters playit at the top of command,
|
||||
// so the rest of that command never runs — its AFTER daemons and fuses
|
||||
// and its ring effects are lost — and the restored game opens with a
|
||||
// fresh BEFORE pass on top of the one already in the snapshot. That
|
||||
// second BEFORE pass is not free: rollwand, a live Before daemon once
|
||||
// swander has fired, ticks again and draws from the RNG every fourth
|
||||
// tick, and any Before fuse is decremented again.
|
||||
//
|
||||
// Not every consequence of that pass is shared by both, though. visuals
|
||||
// returns immediately unless g.After, and After is part of the snapshot,
|
||||
// so DVisuals never re-ticks after a shell-escape save: shell sets
|
||||
// g.After = false as its first statement, before it parks. After a
|
||||
// readchar save it usually does re-tick, because turnUpkeep sets
|
||||
// g.After = true just before the top-of-turn read; the exception is a
|
||||
// handler that clears After before prompting, as identifyTrapCommand
|
||||
// does ahead of promptDirection.
|
||||
//
|
||||
// The result is still a coherent game state, one turn's worth of effects
|
||||
// off — strictly better than the torn encode this replaced, and the cost
|
||||
// of being able to save a player whose line dropped mid-prompt, or who
|
||||
// is away in a shell, at all.
|
||||
func (g *RogueGame) serviceAutoSaveRequest() {
|
||||
select {
|
||||
case req := <-g.sigSave:
|
||||
g.runAutoSaveRequest(req)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// runAutoSaveRequest answers one request: save, then release the waiter.
|
||||
func (g *RogueGame) runAutoSaveRequest(req *autoSaveRequest) {
|
||||
req.ok = g.autoSave()
|
||||
|
||||
close(req.done)
|
||||
}
|
||||
|
||||
// autoSave silently saves to the current file name (save.c auto_save),
|
||||
// reporting whether it wrote a save. Game-goroutine only — reach it
|
||||
// through AutoSaveOnSignal from anywhere else.
|
||||
//
|
||||
// The error is not surfaced: there is no player to tell, since the
|
||||
// terminal is on its way out, and nothing sensible to do about it. It is
|
||||
// reported to the waiting signal goroutine as a failed save rather than
|
||||
// discarded outright, which is what the old `_ =` here used to do.
|
||||
func (g *RogueGame) autoSave() bool {
|
||||
if g.FileName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return g.saveFile(g.FileName) == nil
|
||||
}
|
||||
|
||||
// ErrSaveOutOfDate reports a save file from an incompatible version.
|
||||
var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
|
||||
|
||||
// ErrSaveCorrupt reports a save file whose contents are structurally
|
||||
// impossible for a game this code could have written.
|
||||
var ErrSaveCorrupt = errors.New("sorry, saved game is corrupt")
|
||||
|
||||
// validateSnapshotObjects rejects a snapshot carrying an item whose Which
|
||||
// would index past the end of its kind's tables. The file is written by
|
||||
// this program, so such a value can only come from corruption or
|
||||
// tampering; refusing it at the door is what keeps a malformed object
|
||||
// from reaching the effect tables in doZap, quaff, and readScroll, where
|
||||
// the wizard-create bug (issue #10) used to put one.
|
||||
func validateSnapshotObjects(st *SaveState) error {
|
||||
lists := make([][]Object, 0, 2+len(st.Monsters))
|
||||
lists = append(lists, st.Objects, st.Player.Body.Pack)
|
||||
|
||||
for i := range st.Monsters {
|
||||
lists = append(lists, st.Monsters[i].Pack)
|
||||
}
|
||||
|
||||
for _, list := range lists {
|
||||
for i := range list {
|
||||
obj := &list[i]
|
||||
if !obj.hasValidWhich() {
|
||||
return fmt.Errorf("%w: %s has out-of-range which %d",
|
||||
ErrSaveCorrupt, obj.Kind, obj.Which)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore restores a saved game from a file (save.c restore). The file is
|
||||
// deleted, as in C, to defeat restarting from the same save.
|
||||
func Restore(path string, cfg Config) (*RogueGame, error) {
|
||||
f, err := os.Open(path)
|
||||
func Restore(path string, params Params) (*RogueGame, error) {
|
||||
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
defer func() { _ = f.Close() }() // read-only handle
|
||||
|
||||
var st SaveState
|
||||
if err := gob.NewDecoder(f).Decode(&st); err != nil {
|
||||
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w", path, err)
|
||||
|
||||
decErr := gob.NewDecoder(f).Decode(&st)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w",
|
||||
path, decErr)
|
||||
}
|
||||
|
||||
if st.Version != saveFormatVersion {
|
||||
return nil, fmt.Errorf("sorry, saved game is out of date")
|
||||
return nil, ErrSaveOutOfDate
|
||||
}
|
||||
|
||||
valErr := validateSnapshotObjects(&st)
|
||||
if valErr != nil {
|
||||
return nil, valErr
|
||||
}
|
||||
|
||||
g := &RogueGame{
|
||||
data: newGameData(),
|
||||
Rng: &Rng{},
|
||||
Playing: true,
|
||||
ScorePath: cfg.ScorePath,
|
||||
ScorePath: params.ScorePath,
|
||||
FileName: path,
|
||||
rogueOpts: cfg.RogueOpts,
|
||||
rogueOpts: params.RogueOpts,
|
||||
restored: true,
|
||||
sigSave: make(chan *autoSaveRequest, 1),
|
||||
}
|
||||
g.scr = NewScreen(cfg.Term)
|
||||
g.scr = NewScreen(params.Term)
|
||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||
g.applySnapshot(&st)
|
||||
|
||||
// defeat multiple restarting from the same place
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, fmt.Errorf("cannot unlink file: %w", err)
|
||||
rmErr := os.Remove(path)
|
||||
if rmErr != nil {
|
||||
return nil, fmt.Errorf("cannot unlink file: %w", rmErr)
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func TestSaveRestoreRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 4242)
|
||||
// Dirty up some state so the round trip is meaningful.
|
||||
g.Player.Purse = 123
|
||||
@@ -15,6 +18,7 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
|
||||
g.HasAmulet = true
|
||||
g.Items.Potions[PotionHealing].Know = true
|
||||
g.Items.Scrolls[ScrollMagicMapping].Guess = "map???"
|
||||
|
||||
g.Monsters['F'-'A'].Stats.Dmg = dice("3x1") // mutated bestiary must survive
|
||||
if len(g.Level.Monsters) > 0 {
|
||||
g.Level.Monsters[0].Flags.Set(Awake)
|
||||
@@ -22,91 +26,141 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "rogue.save")
|
||||
if err := g.saveFile(path); err != nil {
|
||||
t.Fatalf("saveFile: %v", err)
|
||||
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr != nil {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
||||
h, err := Restore(path, Params{Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
_, statErr := os.Stat(path)
|
||||
if !os.IsNotExist(statErr) {
|
||||
t.Error("save file not deleted on restore (C anti-restart rule)")
|
||||
}
|
||||
|
||||
checkRestoredState(t, g, h)
|
||||
checkRestoredMonsters(t, g, h)
|
||||
checkEquipmentAliasing(t, g, h)
|
||||
}
|
||||
|
||||
// checkRestoredState verifies the scalar state survived the round trip.
|
||||
func checkRestoredState(t *testing.T, g, h *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
if h.Player.Purse != 123 || h.Player.FoodLeft != 777 {
|
||||
t.Errorf("player state lost: purse=%d food=%d",
|
||||
h.Player.Purse, h.Player.FoodLeft)
|
||||
}
|
||||
|
||||
if !h.HasAmulet {
|
||||
t.Error("amulet flag lost")
|
||||
}
|
||||
|
||||
if !h.Items.Potions[PotionHealing].Know {
|
||||
t.Error("potion identification lost")
|
||||
}
|
||||
|
||||
if h.Items.Scrolls[ScrollMagicMapping].Guess != "map???" {
|
||||
t.Error("scroll guess lost")
|
||||
}
|
||||
|
||||
if h.Monsters['F'-'A'].Stats.Dmg.String() != "3x1" {
|
||||
t.Error("mutated bestiary lost")
|
||||
}
|
||||
|
||||
if h.Rng.Seed != g.Rng.Seed {
|
||||
t.Error("RNG state lost")
|
||||
}
|
||||
|
||||
if renderMap(h) != renderMap(g) {
|
||||
t.Error("restored level map differs")
|
||||
}
|
||||
}
|
||||
|
||||
// checkRestoredMonsters verifies the monster list and its pointer fixups
|
||||
// survived the round trip.
|
||||
func checkRestoredMonsters(t *testing.T, g, h *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
if len(h.Level.Monsters) != len(g.Level.Monsters) {
|
||||
t.Fatalf("monster count %d != %d",
|
||||
len(h.Level.Monsters), len(g.Level.Monsters))
|
||||
}
|
||||
if len(g.Level.Monsters) > 0 {
|
||||
m := h.Level.Monsters[0]
|
||||
if m.Dest != &h.Player.Pos {
|
||||
t.Error("monster chase target not re-aliased to the hero")
|
||||
}
|
||||
if h.Level.MonsterAt(m.Pos.Y, m.Pos.X) != m {
|
||||
t.Error("map monster index not rebuilt")
|
||||
}
|
||||
if m.Room == nil {
|
||||
t.Error("monster room pointer not rebuilt")
|
||||
}
|
||||
|
||||
if len(g.Level.Monsters) == 0 {
|
||||
return
|
||||
}
|
||||
// Equipment aliasing: the wielded mace must be the same *Object as the
|
||||
// one in the pack.
|
||||
|
||||
m := h.Level.Monsters[0]
|
||||
if m.Dest != &h.Player.Pos {
|
||||
t.Error("monster chase target not re-aliased to the hero")
|
||||
}
|
||||
|
||||
if h.Level.MonsterAt(m.Pos.Y, m.Pos.X) != m {
|
||||
t.Error("map monster index not rebuilt")
|
||||
}
|
||||
|
||||
if m.Room == nil {
|
||||
t.Error("monster room pointer not rebuilt")
|
||||
}
|
||||
}
|
||||
|
||||
// checkEquipmentAliasing verifies the wielded mace is the same *Object as
|
||||
// the one in the pack.
|
||||
func checkEquipmentAliasing(t *testing.T, g, h *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
st := g.snapshot()
|
||||
t.Logf("snapshot indices: weapon=%d armor=%d rings=%v packlen=%d",
|
||||
st.Player.CurWeapon, st.Player.CurArmor, st.Player.CurRing,
|
||||
len(st.Player.Body.Pack))
|
||||
t.Logf("restored: CurWeapon=%p pack has %d items", h.Player.CurWeapon,
|
||||
len(h.Player.Pack))
|
||||
|
||||
found := false
|
||||
|
||||
for i, o := range h.Player.Pack {
|
||||
t.Logf(" pack[%d]=%p type=%v which=%d", i, o, o.Kind, o.Which)
|
||||
|
||||
if o == h.Player.CurWeapon {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("restored CurWeapon is not aliased into the pack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRejectsWrongVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 1)
|
||||
path := filepath.Join(t.TempDir(), "rogue.save")
|
||||
st := g.snapshot()
|
||||
st.Version = "0.0.0"
|
||||
f, err := os.Create(path)
|
||||
|
||||
f, err := os.Create(path) //nolint:gosec // G304: test temp path
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gob.NewEncoder(f).Encode(st); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
encErr := gob.NewEncoder(f).Encode(st)
|
||||
if encErr != nil {
|
||||
t.Fatal(encErr)
|
||||
}
|
||||
f.Close()
|
||||
if _, err := Restore(path, Config{}); err == nil {
|
||||
|
||||
closeErr := f.Close()
|
||||
if closeErr != nil {
|
||||
t.Fatal(closeErr)
|
||||
}
|
||||
|
||||
_, restoreErr := Restore(path, Params{})
|
||||
if restoreErr == nil {
|
||||
t.Error("restore accepted an out-of-date save")
|
||||
}
|
||||
}
|
||||
|
||||
274
game/score.go
274
game/score.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -25,29 +26,29 @@ type ScoreEnt struct {
|
||||
Time int64
|
||||
}
|
||||
|
||||
var scoreReasons = [4]string{
|
||||
"killed",
|
||||
"quit",
|
||||
"A total winner",
|
||||
"killed with Amulet",
|
||||
}
|
||||
|
||||
// rdScore reads the scoreboard file (save.c rd_score).
|
||||
func (g *RogueGame) rdScore() []ScoreEnt {
|
||||
topTen := make([]ScoreEnt, numScores)
|
||||
if g.ScorePath == "" {
|
||||
return topTen
|
||||
}
|
||||
|
||||
f, err := os.Open(g.ScorePath)
|
||||
if err != nil {
|
||||
return topTen
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
defer func() { _ = f.Close() }() // read-only handle
|
||||
|
||||
var onDisk []ScoreEnt
|
||||
if err := gob.NewDecoder(f).Decode(&onDisk); err != nil {
|
||||
return topTen
|
||||
|
||||
decErr := gob.NewDecoder(f).Decode(&onDisk)
|
||||
if decErr != nil {
|
||||
return topTen // unreadable scoreboard reads as empty, as in C
|
||||
}
|
||||
|
||||
copy(topTen, onDisk)
|
||||
|
||||
return topTen
|
||||
}
|
||||
|
||||
@@ -57,29 +58,45 @@ func (g *RogueGame) wrScore(topTen []ScoreEnt) {
|
||||
return
|
||||
}
|
||||
// lock_sc/unlock_sc: exclusive-create lock file with stale takeover.
|
||||
// The whole scoreboard write is best effort, as it was in C: a shared
|
||||
// scoreboard must never take the game down.
|
||||
lock := g.ScorePath + ".lck"
|
||||
for range 5 {
|
||||
lf, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
lf, err := os.OpenFile(lock, //nolint:gosec // G304: configured path
|
||||
os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
lf.Close()
|
||||
defer os.Remove(lock)
|
||||
_ = lf.Close()
|
||||
|
||||
defer func() { _ = os.Remove(lock) }()
|
||||
|
||||
break
|
||||
}
|
||||
if fi, serr := os.Stat(lock); serr == nil &&
|
||||
time.Since(fi.ModTime()) > 10*time.Second {
|
||||
os.Remove(lock)
|
||||
|
||||
fi, statErr := os.Stat(lock)
|
||||
if statErr == nil && time.Since(fi.ModTime()) > staleLockAge {
|
||||
_ = os.Remove(lock)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
f, err := os.OpenFile(g.ScorePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
|
||||
f, err := os.OpenFile(g.ScorePath,
|
||||
os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
gob.NewEncoder(f).Encode(topTen)
|
||||
|
||||
_ = gob.NewEncoder(f).Encode(topTen)
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
// staleLockAge is how old a scoreboard lock file may be before another
|
||||
// process assumes its owner died and takes it over (mach_dep.c lock_sc
|
||||
// aged its lock the same way).
|
||||
const staleLockAge = 10 * time.Second
|
||||
|
||||
// score figures the score and posts it (rip.c score). flags -1 means just
|
||||
// display the list (the -s command line option).
|
||||
func (g *RogueGame) score(amount, flags int, monst byte) {
|
||||
@@ -93,94 +110,11 @@ func (g *RogueGame) score(amount, flags int, monst byte) {
|
||||
// Insert her in list if need be
|
||||
ins := -1
|
||||
if !g.NoScore && flags >= 0 {
|
||||
uid := os.Getuid()
|
||||
scp := len(topTen)
|
||||
for i := range topTen {
|
||||
if amount > topTen[i].Score {
|
||||
scp = i
|
||||
break
|
||||
} else if !g.AllScore && flags != 2 &&
|
||||
topTen[i].UID == uid && topTen[i].Flags != 2 {
|
||||
// only one score per nowin uid
|
||||
scp = len(topTen)
|
||||
break
|
||||
}
|
||||
}
|
||||
if scp < len(topTen) {
|
||||
sc2 := len(topTen) - 1
|
||||
if flags != 2 && !g.AllScore {
|
||||
for i := scp; i < len(topTen); i++ {
|
||||
if topTen[i].UID == uid && topTen[i].Flags != 2 {
|
||||
sc2 = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for sc2 > scp {
|
||||
topTen[sc2] = topTen[sc2-1]
|
||||
sc2--
|
||||
}
|
||||
lvl := g.Depth
|
||||
if flags == 2 {
|
||||
lvl = g.MaxDepth
|
||||
}
|
||||
topTen[scp] = ScoreEnt{
|
||||
UID: uid,
|
||||
Score: amount,
|
||||
Flags: flags,
|
||||
Monster: monst,
|
||||
Name: g.Whoami,
|
||||
Level: lvl,
|
||||
Time: time.Now().Unix(),
|
||||
}
|
||||
ins = scp
|
||||
}
|
||||
ins = g.scoreInsert(topTen, amount, flags, monst)
|
||||
}
|
||||
|
||||
// Build the list display
|
||||
label := "Rogueists"
|
||||
if g.AllScore {
|
||||
label = "Scores"
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("Top Ten %s:", label),
|
||||
" Score Name",
|
||||
}
|
||||
highlight := -1
|
||||
for i := range topTen {
|
||||
scp := &topTen[i]
|
||||
if scp.Score == 0 {
|
||||
break
|
||||
}
|
||||
line := fmt.Sprintf("%2d %5d %s: %s on level %d", i+1,
|
||||
scp.Score, scp.Name, scoreReasons[scp.Flags], scp.Level)
|
||||
if scp.Flags == 0 || scp.Flags == 3 {
|
||||
line += fmt.Sprintf(" by %s", g.killname(scp.Monster, true))
|
||||
}
|
||||
line += "."
|
||||
if i == ins {
|
||||
highlight = len(lines)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
if g.scr != nil && g.scr.term != nil {
|
||||
g.clear()
|
||||
for i, line := range lines {
|
||||
if i == highlight {
|
||||
g.standout()
|
||||
}
|
||||
g.mvaddstr(i, 0, line)
|
||||
if i == highlight {
|
||||
g.standend()
|
||||
}
|
||||
}
|
||||
g.refresh()
|
||||
} else {
|
||||
for _, line := range lines {
|
||||
fmt.Println(line)
|
||||
}
|
||||
}
|
||||
lines, highlight := g.scoreLines(topTen, ins)
|
||||
g.showScores(lines, highlight)
|
||||
|
||||
// Update the list file
|
||||
if ins >= 0 {
|
||||
@@ -188,6 +122,136 @@ func (g *RogueGame) score(amount, flags int, monst byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// scoreInsert slots the new score into the top ten, honoring the
|
||||
// one-score-per-losing-uid rule; -1 means it did not place (the
|
||||
// insertion half of rip.c score).
|
||||
func (g *RogueGame) scoreInsert(topTen []ScoreEnt, amount, flags int, monst byte) int {
|
||||
uid := os.Getuid()
|
||||
|
||||
scp := g.scoreSlot(topTen, amount, flags, uid)
|
||||
if scp >= len(topTen) {
|
||||
return -1
|
||||
}
|
||||
|
||||
sc2 := len(topTen) - 1
|
||||
if flags != 2 && !g.AllScore {
|
||||
for i := scp; i < len(topTen); i++ {
|
||||
if topTen[i].UID == uid && topTen[i].Flags != 2 {
|
||||
sc2 = i
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for sc2 > scp {
|
||||
topTen[sc2] = topTen[sc2-1]
|
||||
sc2--
|
||||
}
|
||||
|
||||
lvl := g.Depth
|
||||
if flags == 2 {
|
||||
lvl = g.MaxDepth
|
||||
}
|
||||
|
||||
topTen[scp] = ScoreEnt{
|
||||
UID: uid,
|
||||
Score: amount,
|
||||
Flags: flags,
|
||||
Monster: monst,
|
||||
Name: g.Whoami,
|
||||
Level: lvl,
|
||||
Time: time.Now().Unix(),
|
||||
}
|
||||
|
||||
return scp
|
||||
}
|
||||
|
||||
// scoreSlot finds where the new score lands: len(topTen) when it does
|
||||
// not place, or when this uid already holds a losing score (the scan of
|
||||
// rip.c score).
|
||||
func (g *RogueGame) scoreSlot(topTen []ScoreEnt, amount, flags, uid int) int {
|
||||
for i := range topTen {
|
||||
if amount > topTen[i].Score {
|
||||
return i
|
||||
}
|
||||
|
||||
if !g.AllScore && flags != 2 &&
|
||||
topTen[i].UID == uid && topTen[i].Flags != 2 {
|
||||
// only one score per nowin uid
|
||||
return len(topTen)
|
||||
}
|
||||
}
|
||||
|
||||
return len(topTen)
|
||||
}
|
||||
|
||||
// scoreLines formats the scoreboard, noting which display line holds
|
||||
// the freshly inserted score (the display half of rip.c score).
|
||||
func (g *RogueGame) scoreLines(topTen []ScoreEnt, ins int) ([]string, int) {
|
||||
label := "Rogueists"
|
||||
if g.AllScore {
|
||||
label = "Scores"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("Top Ten %s:", label),
|
||||
" Score Name",
|
||||
}
|
||||
highlight := -1
|
||||
|
||||
for i := range topTen {
|
||||
scp := &topTen[i]
|
||||
if scp.Score == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%2d %5d %s: %s on level %d", i+1,
|
||||
scp.Score, scp.Name, g.data.scoreReasons[scp.Flags], scp.Level)
|
||||
if scp.Flags == 0 || scp.Flags == 3 {
|
||||
line += " by " + g.killname(scp.Monster, true)
|
||||
}
|
||||
|
||||
line += "."
|
||||
|
||||
if i == ins {
|
||||
highlight = len(lines)
|
||||
}
|
||||
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
return lines, highlight
|
||||
}
|
||||
|
||||
// showScores prints the scoreboard on the screen when there is one,
|
||||
// else to standard output (rip.c score).
|
||||
func (g *RogueGame) showScores(lines []string, highlight int) {
|
||||
if g.scr == nil || g.scr.term == nil {
|
||||
for _, line := range lines {
|
||||
_, _ = fmt.Fprintln(os.Stdout, line) // CLI output
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
g.clear()
|
||||
|
||||
for i, line := range lines {
|
||||
if i == highlight {
|
||||
g.standout()
|
||||
}
|
||||
|
||||
g.mvaddstr(i, 0, line)
|
||||
|
||||
if i == highlight {
|
||||
g.standend()
|
||||
}
|
||||
}
|
||||
|
||||
g.refresh()
|
||||
}
|
||||
|
||||
// ShowScores implements the -s command line option: print the scoreboard
|
||||
// and nothing else.
|
||||
func (g *RogueGame) ShowScores() {
|
||||
|
||||
@@ -14,8 +14,27 @@ type Terminal interface {
|
||||
// Render blits the window to the device.
|
||||
Render(w *Window)
|
||||
// ReadChar blocks for the next key, translated to Rogue's input bytes
|
||||
// (arrows become hjkl, control keys their C0 codes).
|
||||
ReadChar() byte
|
||||
// (arrows become hjkl, control keys their C0 codes). ok is false when
|
||||
// the read was woken by Interrupt instead of by a key, which is how a
|
||||
// signal-triggered autosave reaches a game parked on input; the byte
|
||||
// is meaningless then.
|
||||
ReadChar() (ch byte, ok bool)
|
||||
// Interrupt wakes a ReadChar that is blocked waiting for a key. It is
|
||||
// the one Terminal method called from another goroutine, so an
|
||||
// implementation must be safe to call concurrently with ReadChar.
|
||||
Interrupt()
|
||||
// Repaint forces the device to redraw every cell it is showing, the
|
||||
// redraw command's whole point (curses clearok(curscr, TRUE) followed
|
||||
// by wrefresh(curscr)). Render cannot stand in for it: a device that
|
||||
// diffs against its own idea of what is on screen will do nothing at
|
||||
// all when the screen has been corrupted by something else's output,
|
||||
// which is the case the player types CTRL-R for. It repaints what was
|
||||
// last rendered — C repainted curscr, not stdscr — so it neither
|
||||
// needs nor takes a window.
|
||||
Repaint()
|
||||
// Fini restores the device to its pre-game state (curses endwin). The
|
||||
// game calls it on its way out, since one game run is one process.
|
||||
Fini()
|
||||
}
|
||||
|
||||
// cell is one screen position.
|
||||
@@ -37,26 +56,28 @@ type Window struct {
|
||||
func NewWindow(rows, cols int) *Window {
|
||||
w := &Window{rows: rows, cols: cols, cells: make([]cell, rows*cols)}
|
||||
w.Clear()
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] }
|
||||
|
||||
// Move positions the cursor (curses move/wmove).
|
||||
func (w *Window) Move(y, x int) { w.cy, w.cx = y, x }
|
||||
|
||||
// GetYX reports the cursor position (curses getyx).
|
||||
func (w *Window) GetYX() (y, x int) { return w.cy, w.cx }
|
||||
func (w *Window) GetYX() (int, int) { return w.cy, w.cx }
|
||||
|
||||
// AddCh writes a character at the cursor and advances it (curses addch).
|
||||
func (w *Window) AddCh(ch byte) {
|
||||
if ch == '\n' {
|
||||
w.cy, w.cx = w.cy+1, 0
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
|
||||
return
|
||||
}
|
||||
|
||||
*w.at(w.cy, w.cx) = cell{ch: ch, standout: w.standout}
|
||||
if w.cx++; w.cx >= w.cols {
|
||||
w.cx = 0
|
||||
@@ -68,7 +89,7 @@ func (w *Window) AddCh(ch byte) {
|
||||
|
||||
// AddStr writes a string at the cursor (curses addstr).
|
||||
func (w *Window) AddStr(s string) {
|
||||
for i := 0; i < len(s); i++ {
|
||||
for i := range len(s) {
|
||||
w.AddCh(s[i])
|
||||
}
|
||||
}
|
||||
@@ -85,15 +106,15 @@ func (w *Window) MvAddStr(y, x int, s string) {
|
||||
w.AddStr(s)
|
||||
}
|
||||
|
||||
// Printw writes formatted text at the cursor (curses printw).
|
||||
func (w *Window) Printw(format string, a ...any) {
|
||||
// Printwf writes formatted text at the cursor (curses printw).
|
||||
func (w *Window) Printwf(format string, a ...any) {
|
||||
w.AddStr(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// MvPrintw moves then writes formatted text (curses mvprintw).
|
||||
func (w *Window) MvPrintw(y, x int, format string, a ...any) {
|
||||
// MvPrintwf moves then writes formatted text (curses mvprintw).
|
||||
func (w *Window) MvPrintwf(y, x int, format string, a ...any) {
|
||||
w.Move(y, x)
|
||||
w.Printw(format, a...)
|
||||
w.Printwf(format, a...)
|
||||
}
|
||||
|
||||
// Inch returns the character under the cursor (curses inch, sans
|
||||
@@ -102,12 +123,14 @@ func (w *Window) Inch() byte {
|
||||
if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols {
|
||||
return ' '
|
||||
}
|
||||
|
||||
return w.at(w.cy, w.cx).ch
|
||||
}
|
||||
|
||||
// MvInch moves then reads (curses mvinch).
|
||||
func (w *Window) MvInch(y, x int) byte {
|
||||
w.Move(y, x)
|
||||
|
||||
return w.Inch()
|
||||
}
|
||||
|
||||
@@ -120,6 +143,7 @@ func (w *Window) Clear() {
|
||||
for i := range w.cells {
|
||||
w.cells[i] = cell{ch: ' '}
|
||||
}
|
||||
|
||||
w.cy, w.cx = 0, 0
|
||||
}
|
||||
|
||||
@@ -128,6 +152,7 @@ func (w *Window) Clrtoeol() {
|
||||
if w.cy < 0 || w.cy >= w.rows {
|
||||
return
|
||||
}
|
||||
|
||||
for x := w.cx; x < w.cols; x++ {
|
||||
*w.at(w.cy, x) = cell{ch: ' '}
|
||||
}
|
||||
@@ -138,13 +163,14 @@ func (w *Window) CopyFrom(src *Window) {
|
||||
copy(w.cells, src.cells)
|
||||
}
|
||||
|
||||
// Size reports the window dimensions.
|
||||
func (w *Window) Size() (rows, cols int) { return w.rows, w.cols }
|
||||
// Size reports the window dimensions as rows, columns.
|
||||
func (w *Window) Size() (int, int) { return w.rows, w.cols }
|
||||
|
||||
// CellAt reports the character and standout attribute at a position; used
|
||||
// by Terminal implementations to render the window.
|
||||
func (w *Window) CellAt(y, x int) (ch byte, standout bool) {
|
||||
func (w *Window) CellAt(y, x int) (byte, bool) {
|
||||
c := w.at(y, x)
|
||||
|
||||
return c.ch, c.standout
|
||||
}
|
||||
|
||||
@@ -155,6 +181,7 @@ func (w *Window) Contents() []byte {
|
||||
for i, c := range w.cells {
|
||||
out[i] = c.ch
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -171,12 +198,16 @@ func (w *Window) SetContents(data []byte) {
|
||||
// victory screens.
|
||||
func (w *Window) Line(y int) string {
|
||||
buf := make([]byte, w.cols)
|
||||
for x := 0; x < w.cols; x++ {
|
||||
for x := range w.cols {
|
||||
buf[x] = w.at(y, x).ch
|
||||
}
|
||||
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// at addresses the cell at (y, x) in the backing array.
|
||||
func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] }
|
||||
|
||||
// Screen bundles the two windows the game draws on with the device that
|
||||
// shows them.
|
||||
type Screen struct {
|
||||
@@ -201,6 +232,30 @@ func (s *Screen) Refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
// Repaint forces the device to redraw everything it is showing, if there
|
||||
// is a device (curses clearok(curscr, TRUE) + wrefresh(curscr)).
|
||||
func (s *Screen) Repaint() {
|
||||
if s.term != nil {
|
||||
s.term.Repaint()
|
||||
}
|
||||
}
|
||||
|
||||
// Fini restores the terminal device, if there is one (curses endwin).
|
||||
func (s *Screen) Fini() {
|
||||
if s.term != nil {
|
||||
s.term.Fini()
|
||||
}
|
||||
}
|
||||
|
||||
// Interrupt wakes a device read that is blocked waiting for a key, if
|
||||
// there is a device. Called from the signal goroutine; everything else on
|
||||
// Screen belongs to the game goroutine.
|
||||
func (s *Screen) Interrupt() {
|
||||
if s.term != nil {
|
||||
s.term.Interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
|
||||
func (s *Screen) RefreshWin(w *Window) {
|
||||
if s.term != nil {
|
||||
@@ -217,7 +272,7 @@ func (g *RogueGame) mvaddch(y, x int, c byte) { g.scr.Std.MvAddCh(y, x, c) }
|
||||
func (g *RogueGame) mvaddstr(y, x int, s string) {
|
||||
g.scr.Std.MvAddStr(y, x, s)
|
||||
}
|
||||
func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printw(f, a...) }
|
||||
func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printwf(f, a...) }
|
||||
func (g *RogueGame) inch() byte { return g.scr.Std.Inch() }
|
||||
func (g *RogueGame) mvinch(y, x int) byte { return g.scr.Std.MvInch(y, x) }
|
||||
func (g *RogueGame) standout() { g.scr.Std.Standout(true) }
|
||||
@@ -225,3 +280,4 @@ func (g *RogueGame) standend() { g.scr.Std.Standout(false) }
|
||||
func (g *RogueGame) clear() { g.scr.Std.Clear() }
|
||||
func (g *RogueGame) clrtoeol() { g.scr.Std.Clrtoeol() }
|
||||
func (g *RogueGame) refresh() { g.scr.Refresh() }
|
||||
func (g *RogueGame) repaint() { g.scr.Repaint() }
|
||||
|
||||
571
game/scrolls.go
571
game/scrolls.go
@@ -1,31 +1,25 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// scrolls.c — read a scroll and let it happen.
|
||||
|
||||
// idType maps identify scrolls to the kind of item they identify
|
||||
// (scrolls.c static id_type).
|
||||
var idType = [ScrollIdentifyRingOrStick + 1]ObjectKind{
|
||||
ScrollIdentifyPotion: KindPotion,
|
||||
ScrollIdentifyScroll: KindScroll,
|
||||
ScrollIdentifyWeapon: KindWeapon,
|
||||
ScrollIdentifyArmor: KindArmor,
|
||||
ScrollIdentifyRingOrStick: KindRingOrStick,
|
||||
}
|
||||
|
||||
// readScroll reads a scroll from the pack and does the appropriate thing
|
||||
// (scrolls.c read_scroll).
|
||||
func (g *RogueGame) readScroll() {
|
||||
p := &g.Player
|
||||
obj := g.getItem("read", KindScroll)
|
||||
if obj == nil {
|
||||
|
||||
obj, ok := g.promptPackItem("read", KindScroll)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Kind != KindScroll {
|
||||
if !g.Options.Terse {
|
||||
g.msg("there is nothing on it to read")
|
||||
} else {
|
||||
g.msg("nothing to read")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
// Calculate the effect it has on the poor guy.
|
||||
@@ -35,223 +29,350 @@ func (g *RogueGame) readScroll() {
|
||||
// Get rid of the thing
|
||||
g.leavePack(obj, false, false)
|
||||
|
||||
switch obj.ScrollKind() {
|
||||
case ScrollMonsterConfusion:
|
||||
// Scroll of monster confusion. Give him that power.
|
||||
p.Flags.Set(CanConfuse)
|
||||
g.msg("your hands begin to glow %s", g.pickColor("red"))
|
||||
case ScrollEnchantArmor:
|
||||
if p.CurArmor != nil {
|
||||
p.CurArmor.ArmorClass--
|
||||
p.CurArmor.Flags.Clear(Cursed)
|
||||
g.msg("your armor glows %s for a moment", g.pickColor("silver"))
|
||||
}
|
||||
case ScrollHoldMonster:
|
||||
// Hold monster scroll. Stop all monsters within two spaces from
|
||||
// chasing after the hero.
|
||||
held := 0
|
||||
for x := p.Pos.X - 2; x <= p.Pos.X+2; x++ {
|
||||
if x < 0 || x >= NumCols {
|
||||
continue
|
||||
}
|
||||
for y := p.Pos.Y - 2; y <= p.Pos.Y+2; y++ {
|
||||
if y < 0 || y > NumLines-1 {
|
||||
continue
|
||||
}
|
||||
if mp := g.Level.MonsterAt(y, x); mp != nil && mp.On(Awake) {
|
||||
mp.Flags.Clear(Awake)
|
||||
mp.Flags.Set(Held)
|
||||
held++
|
||||
}
|
||||
}
|
||||
}
|
||||
if held > 0 {
|
||||
g.addmsg("the monster")
|
||||
if held > 1 {
|
||||
g.addmsg("s around you")
|
||||
}
|
||||
g.addmsg(" freeze")
|
||||
if held == 1 {
|
||||
g.addmsg("s")
|
||||
}
|
||||
g.endmsg()
|
||||
g.Items.Scrolls[ScrollHoldMonster].Know = true
|
||||
} else {
|
||||
g.msg("you feel a strange sense of loss")
|
||||
}
|
||||
case ScrollSleep:
|
||||
// Scroll which makes you fall asleep
|
||||
g.Items.Scrolls[ScrollSleep].Know = true
|
||||
g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME
|
||||
p.Flags.Clear(Awake)
|
||||
g.msg("you fall asleep")
|
||||
case ScrollCreateMonster:
|
||||
// Create a monster: first look in a circle around him, next try
|
||||
// his room, otherwise give up
|
||||
i := 0
|
||||
var mp Coord
|
||||
for y := p.Pos.Y - 1; y <= p.Pos.Y+1; y++ {
|
||||
for x := p.Pos.X - 1; x <= p.Pos.X+1; x++ {
|
||||
// Don't put a monster on top of the player.
|
||||
if y == p.Pos.Y && x == p.Pos.X {
|
||||
continue
|
||||
}
|
||||
// Or anything else nasty
|
||||
if ch := g.Level.VisibleChar(y, x); stepOk(ch) {
|
||||
if ch == Scroll {
|
||||
if fo := g.findObj(y, x); fo != nil && fo.ScrollKind() == ScrollScareMonster {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if i++; g.rnd(i) == 0 {
|
||||
mp = Coord{Y: y, X: x}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if i == 0 {
|
||||
g.msg("you hear a faint cry of anguish in the distance")
|
||||
} else {
|
||||
tp := &Monster{}
|
||||
g.newMonster(tp, g.randMonster(false), mp)
|
||||
}
|
||||
case ScrollIdentifyPotion, ScrollIdentifyScroll, ScrollIdentifyWeapon, ScrollIdentifyArmor, ScrollIdentifyRingOrStick:
|
||||
// Identify, let him figure something out
|
||||
g.Items.Scrolls[obj.Which].Know = true
|
||||
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
|
||||
g.whatis(true, idType[obj.ScrollKind()])
|
||||
case ScrollMagicMapping:
|
||||
// Scroll of magic mapping.
|
||||
g.Items.Scrolls[ScrollMagicMapping].Know = true
|
||||
g.msg("oh, now this scroll has a map on it")
|
||||
// take all the things we want to keep hidden out of the window
|
||||
for y := 1; y < NumLines-1; y++ {
|
||||
for x := 0; x < NumCols; x++ {
|
||||
pp := g.Level.At(y, x)
|
||||
ch := pp.Ch
|
||||
pass := false
|
||||
switch ch {
|
||||
case Door, Stairs:
|
||||
case '-', '|':
|
||||
if !pp.Flags.Has(FReal) {
|
||||
ch = Door
|
||||
pp.Ch = Door
|
||||
pp.Flags.Set(FReal)
|
||||
}
|
||||
case ' ':
|
||||
if pp.Flags.Has(FReal) {
|
||||
// def: hidden things in walls stay hidden
|
||||
if pp.Flags.Has(FPassage) {
|
||||
pass = true
|
||||
} else {
|
||||
ch = ' '
|
||||
}
|
||||
} else {
|
||||
pp.Flags.Set(FReal)
|
||||
pp.Ch = Passage
|
||||
pass = true
|
||||
}
|
||||
case Passage:
|
||||
pass = true
|
||||
case Floor:
|
||||
if pp.Flags.Has(FReal) {
|
||||
ch = ' '
|
||||
} else {
|
||||
ch = Trap
|
||||
pp.Ch = Trap
|
||||
pp.Flags.Set(FSeen | FReal)
|
||||
}
|
||||
default:
|
||||
if pp.Flags.Has(FPassage) {
|
||||
pass = true
|
||||
} else {
|
||||
ch = ' '
|
||||
}
|
||||
}
|
||||
if pass {
|
||||
if !pp.Flags.Has(FReal) {
|
||||
pp.Ch = Passage
|
||||
}
|
||||
pp.Flags.Set(FSeen | FReal)
|
||||
ch = Passage
|
||||
}
|
||||
if ch != ' ' {
|
||||
if tp := pp.Monst; tp != nil {
|
||||
tp.OldCh = ch
|
||||
if !p.On(SenseMonsters) {
|
||||
g.mvaddch(y, x, ch)
|
||||
}
|
||||
} else {
|
||||
g.mvaddch(y, x, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case ScrollFoodDetection:
|
||||
// Food detection
|
||||
found := false
|
||||
g.scr.Hw.Clear()
|
||||
for _, fo := range g.Level.Objects {
|
||||
if fo.Kind == KindFood {
|
||||
found = true
|
||||
g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food)
|
||||
}
|
||||
}
|
||||
if found {
|
||||
g.Items.Scrolls[ScrollFoodDetection].Know = true
|
||||
g.showWin("Your nose tingles and you smell food.--More--")
|
||||
} else {
|
||||
g.msg("your nose tingles")
|
||||
}
|
||||
case ScrollTeleportation:
|
||||
// Scroll of teleportation: make him disappear and reappear
|
||||
curRoom := p.Room
|
||||
g.teleport()
|
||||
if curRoom != p.Room {
|
||||
g.Items.Scrolls[ScrollTeleportation].Know = true
|
||||
}
|
||||
case ScrollEnchantWeapon:
|
||||
if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon {
|
||||
g.msg("you feel a strange sense of loss")
|
||||
} else {
|
||||
p.CurWeapon.Flags.Clear(Cursed)
|
||||
if g.rnd(2) == 0 {
|
||||
p.CurWeapon.HPlus++
|
||||
} else {
|
||||
p.CurWeapon.DPlus++
|
||||
}
|
||||
g.msg("your %s glows %s for a moment",
|
||||
g.Items.Weapons[p.CurWeapon.Which].Name, g.pickColor("blue"))
|
||||
}
|
||||
case ScrollScareMonster:
|
||||
// Reading it is a mistake and produces laughter at her poor boo
|
||||
// boo.
|
||||
g.msg("you hear maniacal laughter in the distance")
|
||||
case ScrollRemoveCurse:
|
||||
uncurse(p.CurArmor)
|
||||
uncurse(p.CurWeapon)
|
||||
uncurse(p.CurRing[Left])
|
||||
uncurse(p.CurRing[Right])
|
||||
g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes",
|
||||
"you feel as if somebody is watching over you"))
|
||||
case ScrollAggravateMonsters:
|
||||
// This scroll aggravates all the monsters on the current level
|
||||
// and sets them running towards the hero
|
||||
g.aggravate()
|
||||
g.msg("you hear a high pitched humming noise")
|
||||
case ScrollProtectArmor:
|
||||
if p.CurArmor != nil {
|
||||
p.CurArmor.Flags.Set(Protected)
|
||||
g.msg("your armor is covered by a shimmering %s shield",
|
||||
g.pickColor("gold"))
|
||||
} else {
|
||||
g.msg("you feel a strange sense of loss")
|
||||
}
|
||||
if h := g.data.readHandler(obj); h != nil {
|
||||
h(g, obj)
|
||||
}
|
||||
|
||||
g.look(true) // put the result of the scroll on the screen
|
||||
g.status()
|
||||
|
||||
g.callIt(&g.Items.Scrolls[obj.Which])
|
||||
// A malformed scroll has no lore entry to name (see quaff).
|
||||
if obj.hasValidWhich() {
|
||||
g.callIt(&g.Items.Scrolls[obj.Which])
|
||||
}
|
||||
}
|
||||
|
||||
// The per-scroll effect handlers, dispatched through
|
||||
// gameData.readHandlers. Each is one case of the C read_scroll switch.
|
||||
|
||||
func (g *RogueGame) readMonsterConfusion(*Object) {
|
||||
// Scroll of monster confusion. Give him that power.
|
||||
g.Player.Flags.Set(CanConfuse)
|
||||
g.msg("your hands begin to glow %s", g.pickColor("red"))
|
||||
}
|
||||
|
||||
func (g *RogueGame) readEnchantArmor(*Object) {
|
||||
p := &g.Player
|
||||
if p.CurArmor != nil {
|
||||
p.CurArmor.ArmorClass--
|
||||
p.CurArmor.Flags.Clear(Cursed)
|
||||
g.msg("your armor glows %s for a moment", g.pickColor("silver"))
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) readHoldMonster(*Object) {
|
||||
// Hold monster scroll. Stop all monsters within two spaces from
|
||||
// chasing after the hero.
|
||||
held := g.holdMonstersNear()
|
||||
|
||||
if held > 0 {
|
||||
g.addmsgf("the monster")
|
||||
|
||||
if held > 1 {
|
||||
g.addmsgf("s around you")
|
||||
}
|
||||
|
||||
g.addmsgf(" freeze")
|
||||
|
||||
if held == 1 {
|
||||
g.addmsgf("s")
|
||||
}
|
||||
|
||||
g.endmsg()
|
||||
g.Items.Scrolls[ScrollHoldMonster].Know = true
|
||||
} else {
|
||||
g.msg("you feel a strange sense of loss")
|
||||
}
|
||||
}
|
||||
|
||||
// holdMonstersNear freezes every awake monster within two spaces of the
|
||||
// hero and reports how many froze (the scan of the C S_HOLD case).
|
||||
func (g *RogueGame) holdMonstersNear() int {
|
||||
p := &g.Player
|
||||
held := 0
|
||||
|
||||
for x := p.Pos.X - 2; x <= p.Pos.X+2; x++ {
|
||||
if x < 0 || x >= NumCols {
|
||||
continue
|
||||
}
|
||||
|
||||
for y := p.Pos.Y - 2; y <= p.Pos.Y+2; y++ {
|
||||
if y < 0 || y > NumLines-1 {
|
||||
continue
|
||||
}
|
||||
|
||||
if mp := g.Level.MonsterAt(y, x); mp != nil && mp.On(Awake) {
|
||||
mp.Flags.Clear(Awake)
|
||||
mp.Flags.Set(Held)
|
||||
|
||||
held++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
func (g *RogueGame) readSleep(*Object) {
|
||||
// Scroll which makes you fall asleep
|
||||
g.Items.Scrolls[ScrollSleep].Know = true
|
||||
g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME
|
||||
|
||||
g.Player.Flags.Clear(Awake)
|
||||
g.msg("you fall asleep")
|
||||
}
|
||||
|
||||
func (g *RogueGame) readCreateMonster(*Object) {
|
||||
// Create a monster: first look in a circle around him, next try
|
||||
// his room, otherwise give up
|
||||
mp, ok := g.createMonsterSpot()
|
||||
if !ok {
|
||||
g.msg("you hear a faint cry of anguish in the distance")
|
||||
} else {
|
||||
tp := &Monster{}
|
||||
g.newMonster(tp, g.randMonster(false), mp)
|
||||
}
|
||||
}
|
||||
|
||||
// createMonsterSpot reservoir-samples a legal spot around the hero for a
|
||||
// created monster; ok is false when every neighbor is blocked (the scan
|
||||
// of the C S_CREATE case).
|
||||
func (g *RogueGame) createMonsterSpot() (Coord, bool) {
|
||||
p := &g.Player
|
||||
i := 0
|
||||
|
||||
var mp Coord
|
||||
|
||||
for y := p.Pos.Y - 1; y <= p.Pos.Y+1; y++ {
|
||||
for x := p.Pos.X - 1; x <= p.Pos.X+1; x++ {
|
||||
// Don't put a monster on top of the player.
|
||||
if y == p.Pos.Y && x == p.Pos.X {
|
||||
continue
|
||||
}
|
||||
// Or anything else nasty
|
||||
if ch := g.Level.VisibleChar(y, x); stepOk(ch) {
|
||||
if ch == Scroll {
|
||||
if fo := g.Level.ObjectAt(y, x); fo != nil &&
|
||||
fo.ScrollKind() == ScrollScareMonster {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if i++; g.rnd(i) == 0 {
|
||||
mp = Coord{Y: y, X: x}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mp, i != 0
|
||||
}
|
||||
|
||||
func (g *RogueGame) readIdentify(obj *Object) {
|
||||
// Identify, let him figure something out. idType is shorter than the
|
||||
// scroll table keying it (it stops after the last identify scroll),
|
||||
// so the filter lookup carries its own bound. That bound is not
|
||||
// reachable today: readHandlers registers readIdentify only for the
|
||||
// identify scrolls, all of which sit inside idType. It is kept as
|
||||
// defense-in-depth against a future table resize.
|
||||
g.Items.Scrolls[obj.Which].Know = true
|
||||
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
|
||||
g.whatis(true, g.data.identifyType(obj.ScrollKind()))
|
||||
}
|
||||
|
||||
func (g *RogueGame) readMagicMapping(*Object) {
|
||||
// Scroll of magic mapping.
|
||||
g.Items.Scrolls[ScrollMagicMapping].Know = true
|
||||
g.msg("oh, now this scroll has a map on it")
|
||||
// take all the things we want to keep hidden out of the window
|
||||
for y := 1; y < NumLines-1; y++ {
|
||||
for x := range NumCols {
|
||||
g.revealSpot(y, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revealSpot uncovers one map cell for magic mapping and draws it (the
|
||||
// loop body of the C SCR_MAP case).
|
||||
func (g *RogueGame) revealSpot(y, x int) {
|
||||
pp := g.Level.At(y, x)
|
||||
|
||||
ch := revealChar(pp)
|
||||
if ch != ' ' {
|
||||
if tp := pp.Monst; tp != nil {
|
||||
tp.OldCh = ch
|
||||
if !g.Player.On(SenseMonsters) {
|
||||
g.mvaddch(y, x, ch)
|
||||
}
|
||||
} else {
|
||||
g.mvaddch(y, x, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revealChar decides what magic mapping shows at a cell, making secret
|
||||
// doors, hidden passages, and hidden traps real as a side effect; ' '
|
||||
// means show nothing (the switch of the C SCR_MAP loop).
|
||||
func revealChar(pp *Place) byte {
|
||||
ch := pp.Ch
|
||||
pass := false
|
||||
|
||||
switch ch {
|
||||
case Door, Stairs:
|
||||
case '-', '|':
|
||||
ch = revealWall(pp)
|
||||
case ' ':
|
||||
pass = revealSolid(pp)
|
||||
case Passage:
|
||||
pass = true
|
||||
case Floor:
|
||||
ch = revealFloor(pp)
|
||||
default:
|
||||
if pp.Flags.Has(FPassage) {
|
||||
pass = true
|
||||
} else {
|
||||
ch = ' '
|
||||
}
|
||||
}
|
||||
|
||||
if pass {
|
||||
if !pp.Flags.Has(FReal) {
|
||||
pp.Ch = Passage
|
||||
}
|
||||
|
||||
pp.Flags.Set(FSeen | FReal)
|
||||
|
||||
ch = Passage
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// revealWall handles a wall cell for magic mapping: a secret door
|
||||
// becomes a real door (the '-'/'|' arm).
|
||||
func revealWall(pp *Place) byte {
|
||||
if !pp.Flags.Has(FReal) {
|
||||
pp.Ch = Door
|
||||
pp.Flags.Set(FReal)
|
||||
|
||||
return Door
|
||||
}
|
||||
|
||||
return pp.Ch
|
||||
}
|
||||
|
||||
// revealSolid handles a blank cell for magic mapping, reporting whether
|
||||
// it is passage: hidden passages become real; hidden things in walls
|
||||
// stay hidden (the ' ' arm).
|
||||
func revealSolid(pp *Place) bool {
|
||||
if pp.Flags.Has(FReal) {
|
||||
return pp.Flags.Has(FPassage)
|
||||
}
|
||||
|
||||
pp.Flags.Set(FReal)
|
||||
pp.Ch = Passage
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// revealFloor handles a floor cell for magic mapping: a hidden trap
|
||||
// becomes a real, seen trap; real floor shows nothing (the FLOOR arm).
|
||||
func revealFloor(pp *Place) byte {
|
||||
if pp.Flags.Has(FReal) {
|
||||
return ' '
|
||||
}
|
||||
|
||||
pp.Ch = Trap
|
||||
pp.Flags.Set(FSeen | FReal)
|
||||
|
||||
return Trap
|
||||
}
|
||||
|
||||
func (g *RogueGame) readFoodDetection(*Object) {
|
||||
// Food detection
|
||||
found := false
|
||||
|
||||
g.scr.Hw.Clear()
|
||||
|
||||
for _, fo := range g.Level.Objects {
|
||||
if fo.Kind == KindFood {
|
||||
found = true
|
||||
|
||||
g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food)
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
g.Items.Scrolls[ScrollFoodDetection].Know = true
|
||||
g.showWin("Your nose tingles and you smell food.--More--")
|
||||
} else {
|
||||
g.msg("your nose tingles")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) readTeleportation(*Object) {
|
||||
// Scroll of teleportation: make him disappear and reappear
|
||||
p := &g.Player
|
||||
curRoom := p.Room
|
||||
|
||||
g.teleport()
|
||||
|
||||
if curRoom != p.Room {
|
||||
g.Items.Scrolls[ScrollTeleportation].Know = true
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) readEnchantWeapon(*Object) {
|
||||
p := &g.Player
|
||||
if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon {
|
||||
g.msg("you feel a strange sense of loss")
|
||||
} else {
|
||||
p.CurWeapon.Flags.Clear(Cursed)
|
||||
|
||||
if g.rnd(2) == 0 {
|
||||
p.CurWeapon.HPlus++
|
||||
} else {
|
||||
p.CurWeapon.DPlus++
|
||||
}
|
||||
|
||||
g.msg("your %s glows %s for a moment",
|
||||
g.Items.Weapons[p.CurWeapon.Which].Name, g.pickColor("blue"))
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RogueGame) readScareMonster(*Object) {
|
||||
// Reading it is a mistake and produces laughter at her poor boo
|
||||
// boo.
|
||||
g.msg("you hear maniacal laughter in the distance")
|
||||
}
|
||||
|
||||
func (g *RogueGame) readRemoveCurse(*Object) {
|
||||
p := &g.Player
|
||||
|
||||
uncurse(p.CurArmor)
|
||||
uncurse(p.CurWeapon)
|
||||
uncurse(p.CurRing[Left])
|
||||
uncurse(p.CurRing[Right])
|
||||
g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes",
|
||||
"you feel as if somebody is watching over you"))
|
||||
}
|
||||
|
||||
func (g *RogueGame) readAggravateMonsters(*Object) {
|
||||
// This scroll aggravates all the monsters on the current level
|
||||
// and sets them running towards the hero
|
||||
g.aggravate()
|
||||
g.msg("you hear a high pitched humming noise")
|
||||
}
|
||||
|
||||
func (g *RogueGame) readProtectArmor(*Object) {
|
||||
p := &g.Player
|
||||
if p.CurArmor != nil {
|
||||
p.CurArmor.Flags.Set(Protected)
|
||||
g.msg("your armor is covered by a shimmering %s shield",
|
||||
g.pickColor("gold"))
|
||||
} else {
|
||||
g.msg("you feel a strange sense of loss")
|
||||
}
|
||||
}
|
||||
|
||||
// uncurse uncurses an item (scrolls.c uncurse).
|
||||
|
||||
101
game/seedcompat_test.go
Normal file
101
game/seedcompat_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dumpItemTables formats a game's per-seed item appearance tables in the
|
||||
// same layout the instrumented C reference prints: the potion colors,
|
||||
// scroll names, ring stones, and wand/staff materials, each generated by
|
||||
// consuming the RNG in a fixed order during New().
|
||||
func dumpItemTables(seed int32, g *RogueGame) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "SEED %d\n", seed)
|
||||
|
||||
fmt.Fprintln(&b, "POTIONS")
|
||||
|
||||
for _, c := range g.Items.PotColors {
|
||||
fmt.Fprintln(&b, c)
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b, "SCROLLS")
|
||||
|
||||
for _, s := range g.Items.ScrNames {
|
||||
fmt.Fprintln(&b, s)
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b, "RINGS")
|
||||
|
||||
for _, s := range g.Items.RingStones {
|
||||
fmt.Fprintln(&b, s)
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b, "STICKS")
|
||||
|
||||
for i := range g.Items.WandType {
|
||||
fmt.Fprintf(&b, "%s %s\n", g.Items.WandType[i], g.Items.WandMade[i])
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestSeedCompatItemTables proves the port's seed-compatibility claim: for
|
||||
// the same seed, the Go game generates the exact per-seed item appearance
|
||||
// tables as the C reference on modern-rogue. That requires the LCG and its
|
||||
// consumption order through the whole init sequence (init_probs →
|
||||
// init_player → init_names → init_colors → init_stones → init_materials) to
|
||||
// match C byte for byte. The golden is captured from an instrumented build
|
||||
// of the C game (testdata/README.md).
|
||||
func TestSeedCompatItemTables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
golden, err := os.ReadFile("testdata/item_tables.golden")
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
|
||||
// These must match the seeds the golden was generated from
|
||||
// (testdata/README.md).
|
||||
seeds := []int32{1, 42, 12345, 99999}
|
||||
|
||||
var got strings.Builder
|
||||
|
||||
for _, seed := range seeds {
|
||||
g := New(Params{Seed: seed, Wizard: true})
|
||||
got.WriteString(dumpItemTables(seed, g))
|
||||
}
|
||||
|
||||
if got.String() != string(golden) {
|
||||
t.Errorf("Go item tables diverge from the C reference at %s",
|
||||
firstDiff(string(golden), got.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// firstDiff returns a description of the first line where want and got
|
||||
// differ, for a readable failure.
|
||||
func firstDiff(want, got string) string {
|
||||
wl := strings.Split(want, "\n")
|
||||
gl := strings.Split(got, "\n")
|
||||
|
||||
for i := 0; i < len(wl) || i < len(gl); i++ {
|
||||
w, g := "", ""
|
||||
if i < len(wl) {
|
||||
w = wl[i]
|
||||
}
|
||||
|
||||
if i < len(gl) {
|
||||
g = gl[i]
|
||||
}
|
||||
|
||||
if w != g {
|
||||
return fmt.Sprintf("line %d: C=%q Go=%q", i+1, w, g)
|
||||
}
|
||||
}
|
||||
|
||||
return "no line difference (trailing content?)"
|
||||
}
|
||||
647
game/sticks.go
647
game/sticks.go
@@ -1,175 +1,314 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
|
||||
// sticks.c — zap wands and staffs.
|
||||
|
||||
// The two ws_type strings a stick can be made as.
|
||||
const (
|
||||
wandName = "wand"
|
||||
staffName = "staff"
|
||||
)
|
||||
|
||||
// doZap performs a zap with a wand (sticks.c do_zap).
|
||||
func (g *RogueGame) doZap() {
|
||||
p := &g.Player
|
||||
obj := g.getItem("zap with", KindWand)
|
||||
if obj == nil {
|
||||
obj, ok := g.promptPackItem("zap with", KindWand)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Kind != KindWand {
|
||||
g.After = false
|
||||
g.msg("you can't zap with that!")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Charges == 0 {
|
||||
g.msg("nothing happens")
|
||||
|
||||
return
|
||||
}
|
||||
switch obj.WandKind() {
|
||||
case WandLight:
|
||||
// Reddy Kilowatt wand. Light up the room
|
||||
g.Items.Sticks[WandLight].Know = true
|
||||
if p.Room.Flags.Has(Gone) {
|
||||
g.msg("the corridor glows and then fades")
|
||||
} else {
|
||||
p.Room.Flags.Clear(Dark)
|
||||
// Light the room and put the player back up
|
||||
g.enterRoom(p.Pos)
|
||||
g.addmsg("the room is lit")
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(" by a shimmering %s light", g.pickColor("blue"))
|
||||
}
|
||||
g.endmsg()
|
||||
|
||||
// C's switch has a case for every one of the 14 WS_ kinds, so its
|
||||
// closing "otherwise: msg(...)" arm is reachable only for an o_which
|
||||
// outside the table — the malformed objects hasValidWhich screens
|
||||
// for, which is why no handler and a legal Which can only mean WS_NOP.
|
||||
//
|
||||
// The message is under #ifdef MASTER, not under a wizard test: C
|
||||
// printed it for every player of a MASTER build, which is the build
|
||||
// this port is (see the '+' command, issue #11). Do not gate it on
|
||||
// g.Wizard.
|
||||
//
|
||||
// WS_NOP is a case of its own ("when WS_NOP: break;"): the wand that
|
||||
// deliberately does nothing says nothing either. All three arms fall
|
||||
// out of the switch into o_charges--, so even the bizarre schtick
|
||||
// costs a charge.
|
||||
h := g.data.zapHandler(obj)
|
||||
|
||||
switch {
|
||||
case h != nil:
|
||||
if !h(g, obj) {
|
||||
return // the zap aborted; no charge is used
|
||||
}
|
||||
case WandDrainLife:
|
||||
// take away 1/2 of hero's hit points, then take it away evenly
|
||||
// from the monsters in the room (or next to hero if he is in a
|
||||
// passage)
|
||||
if p.Stats.HP < 2 {
|
||||
g.msg("you are too weak to use it")
|
||||
return
|
||||
}
|
||||
g.drain()
|
||||
case WandInvisibility, WandPolymorph, WandTeleportAway, WandTeleportTo, WandCancellation:
|
||||
y := p.Pos.Y
|
||||
x := p.Pos.X
|
||||
for stepOk(g.Level.VisibleChar(y, x)) {
|
||||
y += g.Delta.Y
|
||||
x += g.Delta.X
|
||||
}
|
||||
if tp := g.Level.MonsterAt(y, x); tp != nil {
|
||||
monster := tp.Type
|
||||
if monster == 'F' {
|
||||
p.Flags.Clear(Held)
|
||||
}
|
||||
switch obj.WandKind() {
|
||||
case WandInvisibility:
|
||||
tp.Flags.Set(Invisible)
|
||||
if g.cansee(y, x) {
|
||||
g.mvaddch(y, x, tp.OldCh)
|
||||
}
|
||||
case WandPolymorph:
|
||||
pp := tp.Pack
|
||||
detachMon(&g.Level.Monsters, tp)
|
||||
if g.seeMonst(tp) {
|
||||
g.mvaddch(y, x, g.Level.Char(y, x))
|
||||
}
|
||||
oldch := tp.OldCh
|
||||
g.Delta.Y = y
|
||||
g.Delta.X = x
|
||||
monster = byte(g.rnd(26) + 'A')
|
||||
g.newMonster(tp, monster, g.Delta)
|
||||
if g.seeMonst(tp) {
|
||||
g.mvaddch(y, x, monster)
|
||||
}
|
||||
tp.OldCh = oldch
|
||||
tp.Pack = pp
|
||||
if g.seeMonst(tp) {
|
||||
g.Items.Sticks[WandPolymorph].Know = true
|
||||
}
|
||||
case WandCancellation:
|
||||
tp.Flags.Set(Cancelled)
|
||||
tp.Flags.Clear(Invisible | CanConfuse)
|
||||
tp.Disguise = tp.Type
|
||||
if g.seeMonst(tp) {
|
||||
g.mvaddch(y, x, tp.Disguise)
|
||||
}
|
||||
case WandTeleportAway, WandTeleportTo:
|
||||
var newPos Coord
|
||||
if obj.WandKind() == WandTeleportAway {
|
||||
for {
|
||||
newPos, _ = g.findFloor(nil, 0, true)
|
||||
if newPos != p.Pos {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newPos.Y = p.Pos.Y + g.Delta.Y
|
||||
newPos.X = p.Pos.X + g.Delta.X
|
||||
}
|
||||
tp.Dest = &p.Pos
|
||||
tp.Flags.Set(Awake)
|
||||
g.relocate(tp, newPos)
|
||||
}
|
||||
}
|
||||
case WandMagicMissile:
|
||||
g.Items.Sticks[WandMagicMissile].Know = true
|
||||
bolt := newObject()
|
||||
bolt.Kind = KindGold // C set o_type='*': draws a '*' and is not a weapon
|
||||
bolt.HurlDmg = dice("1x4")
|
||||
bolt.HPlus = 100
|
||||
bolt.DPlus = 1
|
||||
bolt.Flags = Missile
|
||||
if p.CurWeapon != nil {
|
||||
bolt.Launch = WeaponKind(p.CurWeapon.Which)
|
||||
}
|
||||
g.doMotion(bolt, g.Delta.Y, g.Delta.X)
|
||||
if tp := g.Level.MonsterAt(bolt.Pos.Y, bolt.Pos.X); tp != nil &&
|
||||
!g.saveThrow(VsMagic, &tp.Stats) {
|
||||
g.hitMonster(bolt.Pos, bolt)
|
||||
} else if g.Options.Terse {
|
||||
g.msg("missle vanishes")
|
||||
} else {
|
||||
g.msg("the missle vanishes with a puff of smoke")
|
||||
}
|
||||
case WandHasteMonster, WandSlowMonster:
|
||||
y := p.Pos.Y
|
||||
x := p.Pos.X
|
||||
for stepOk(g.Level.VisibleChar(y, x)) {
|
||||
y += g.Delta.Y
|
||||
x += g.Delta.X
|
||||
}
|
||||
if tp := g.Level.MonsterAt(y, x); tp != nil {
|
||||
if obj.WandKind() == WandHasteMonster {
|
||||
if tp.On(Slowed) {
|
||||
tp.Flags.Clear(Slowed)
|
||||
} else {
|
||||
tp.Flags.Set(Hasted)
|
||||
}
|
||||
} else {
|
||||
if tp.On(Hasted) {
|
||||
tp.Flags.Clear(Hasted)
|
||||
} else {
|
||||
tp.Flags.Set(Slowed)
|
||||
}
|
||||
tp.Turn = true
|
||||
}
|
||||
g.Delta.Y = y
|
||||
g.Delta.X = x
|
||||
g.runto(g.Delta)
|
||||
}
|
||||
case WandLightning, WandFire, WandCold:
|
||||
var name string
|
||||
switch obj.WandKind() {
|
||||
case WandLightning:
|
||||
name = "bolt"
|
||||
case WandFire:
|
||||
name = "flame"
|
||||
default:
|
||||
name = "ice"
|
||||
}
|
||||
g.fireBolt(p.Pos, &g.Delta, name)
|
||||
g.Items.Sticks[obj.Which].Know = true
|
||||
case WandNothing:
|
||||
case obj.hasValidWhich(): // WS_NOP
|
||||
default:
|
||||
g.msg("what a bizarre schtick!")
|
||||
}
|
||||
|
||||
obj.Charges--
|
||||
}
|
||||
|
||||
// zapRayMonster walks the zap ray from the hero to the first blocking
|
||||
// spot and returns the monster standing there, if any (the shared
|
||||
// preamble of the C monster-affecting zap cases).
|
||||
func (g *RogueGame) zapRayMonster() *Monster {
|
||||
p := &g.Player
|
||||
|
||||
y := p.Pos.Y
|
||||
|
||||
x := p.Pos.X
|
||||
for stepOk(g.Level.VisibleChar(y, x)) {
|
||||
y += g.Delta.Y
|
||||
x += g.Delta.X
|
||||
}
|
||||
|
||||
return g.Level.MonsterAt(y, x)
|
||||
}
|
||||
|
||||
// zapVictim is zapRayMonster plus the flytrap release the C code does
|
||||
// before the invisibility-family effects.
|
||||
func (g *RogueGame) zapVictim() *Monster {
|
||||
tp := g.zapRayMonster()
|
||||
if tp != nil && tp.Type == 'F' {
|
||||
g.Player.Flags.Clear(Held)
|
||||
}
|
||||
|
||||
return tp
|
||||
}
|
||||
|
||||
// The per-wand effect handlers, dispatched through gameData.zapHandlers.
|
||||
// Each is one case of the C do_zap switch; returning false aborts the
|
||||
// zap without using a charge.
|
||||
|
||||
func (g *RogueGame) zapLight(*Object) bool {
|
||||
// Reddy Kilowatt wand. Light up the room
|
||||
p := &g.Player
|
||||
|
||||
g.Items.Sticks[WandLight].Know = true
|
||||
if p.Room.Flags.Has(Gone) {
|
||||
g.msg("the corridor glows and then fades")
|
||||
} else {
|
||||
p.Room.Flags.Clear(Dark)
|
||||
// Light the room and put the player back up
|
||||
g.enterRoom(p.Pos)
|
||||
g.addmsgf("the room is lit")
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsgf(" by a shimmering %s light", g.pickColor("blue"))
|
||||
}
|
||||
|
||||
g.endmsg()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapDrainLife(*Object) bool {
|
||||
// take away 1/2 of hero's hit points, then take it away evenly
|
||||
// from the monsters in the room (or next to hero if he is in a
|
||||
// passage)
|
||||
if g.Player.Stats.HP < 2 {
|
||||
g.msg("you are too weak to use it")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
g.drain()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapInvisibility(*Object) bool {
|
||||
if tp := g.zapVictim(); tp != nil {
|
||||
tp.Flags.Set(Invisible)
|
||||
|
||||
if g.canSee(tp.Pos.Y, tp.Pos.X) {
|
||||
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.OldCh)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapPolymorph(*Object) bool {
|
||||
tp := g.zapVictim()
|
||||
if tp == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
y, x := tp.Pos.Y, tp.Pos.X
|
||||
|
||||
pp := tp.Pack
|
||||
g.Level.RemoveMonster(tp)
|
||||
|
||||
if g.seeMonst(tp) {
|
||||
g.mvaddch(y, x, g.Level.Char(y, x))
|
||||
}
|
||||
|
||||
oldch := tp.OldCh
|
||||
g.Delta.Y = y
|
||||
g.Delta.X = x
|
||||
monster := g.randomMonsterLetter()
|
||||
g.newMonster(tp, monster, g.Delta)
|
||||
|
||||
if g.seeMonst(tp) {
|
||||
g.mvaddch(y, x, monster)
|
||||
}
|
||||
|
||||
tp.OldCh = oldch
|
||||
|
||||
tp.Pack = pp
|
||||
if g.seeMonst(tp) {
|
||||
g.Items.Sticks[WandPolymorph].Know = true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapCancellation(*Object) bool {
|
||||
if tp := g.zapVictim(); tp != nil {
|
||||
tp.Flags.Set(Cancelled)
|
||||
tp.Flags.Clear(Invisible | CanConfuse)
|
||||
|
||||
tp.Disguise = tp.Type
|
||||
if g.seeMonst(tp) {
|
||||
g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.Disguise)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapTeleport(obj *Object) bool {
|
||||
p := &g.Player
|
||||
|
||||
tp := g.zapVictim()
|
||||
if tp == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
var newPos Coord
|
||||
|
||||
if obj.WandKind() == WandTeleportAway {
|
||||
for {
|
||||
newPos, _ = g.findFloor(true)
|
||||
if newPos != p.Pos {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newPos.Y = p.Pos.Y + g.Delta.Y
|
||||
newPos.X = p.Pos.X + g.Delta.X
|
||||
}
|
||||
|
||||
tp.Dest = &p.Pos
|
||||
tp.Flags.Set(Awake)
|
||||
g.relocate(tp, newPos)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapMagicMissile(*Object) bool {
|
||||
p := &g.Player
|
||||
|
||||
g.Items.Sticks[WandMagicMissile].Know = true
|
||||
bolt := newObject()
|
||||
bolt.Kind = KindGold // C set o_type='*': draws a '*' and is not a weapon
|
||||
bolt.HurlDmg = dice("1x4")
|
||||
bolt.HPlus = 100
|
||||
bolt.DPlus = 1
|
||||
|
||||
bolt.Flags = Missile
|
||||
if p.CurWeapon != nil {
|
||||
bolt.Launch = WeaponKind(p.CurWeapon.Which)
|
||||
}
|
||||
|
||||
g.doMotion(bolt, g.Delta.Y, g.Delta.X)
|
||||
|
||||
if tp := g.Level.MonsterAt(bolt.Pos.Y, bolt.Pos.X); tp != nil &&
|
||||
!g.saveThrow(VsMagic, &tp.Stats) {
|
||||
g.hitMonster(bolt.Pos, bolt)
|
||||
} else if g.Options.Terse {
|
||||
g.msg("missle vanishes") //nolint:misspell // C's spelling, kept faithfully
|
||||
} else {
|
||||
g.msg("the missle vanishes with a puff of smoke") //nolint:misspell // C's spelling
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapSpeed(obj *Object) bool {
|
||||
tp := g.zapRayMonster()
|
||||
if tp == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if obj.WandKind() == WandHasteMonster {
|
||||
hasteTarget(tp)
|
||||
} else {
|
||||
slowTarget(tp)
|
||||
}
|
||||
|
||||
g.Delta.Y = tp.Pos.Y
|
||||
g.Delta.X = tp.Pos.X
|
||||
g.runTo(g.Delta)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// hasteTarget cancels a slow or applies a haste (the WS_HASTE_M arm of
|
||||
// do_zap).
|
||||
func hasteTarget(tp *Monster) {
|
||||
if tp.On(Slowed) {
|
||||
tp.Flags.Clear(Slowed)
|
||||
} else {
|
||||
tp.Flags.Set(Hasted)
|
||||
}
|
||||
}
|
||||
|
||||
// slowTarget cancels a haste or applies a slow (the WS_SLOW_M arm of
|
||||
// do_zap).
|
||||
func slowTarget(tp *Monster) {
|
||||
if tp.On(Hasted) {
|
||||
tp.Flags.Clear(Hasted)
|
||||
} else {
|
||||
tp.Flags.Set(Slowed)
|
||||
}
|
||||
|
||||
tp.Turn = true
|
||||
}
|
||||
|
||||
func (g *RogueGame) zapBolt(obj *Object) bool {
|
||||
var name string
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.WandKind() {
|
||||
case WandLightning:
|
||||
name = "bolt"
|
||||
case WandFire:
|
||||
name = "flame"
|
||||
default:
|
||||
name = "ice"
|
||||
}
|
||||
|
||||
g.fireBolt(g.Player.Pos, &g.Delta, name)
|
||||
g.Items.Sticks[obj.Which].Know = true
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// drain does the drain-hit-points-from-player schtick (sticks.c drain).
|
||||
func (g *RogueGame) drain() {
|
||||
p := &g.Player
|
||||
@@ -178,20 +317,24 @@ func (g *RogueGame) drain() {
|
||||
if g.Level.Char(p.Pos.Y, p.Pos.X) == Door {
|
||||
corp = &g.Level.Passages[*g.Level.FlagsAt(p.Pos.Y, p.Pos.X)&FPassNum]
|
||||
}
|
||||
|
||||
inpass := p.Room.Flags.Has(Gone)
|
||||
|
||||
var drainee []*Monster
|
||||
|
||||
for _, mp := range g.Level.Monsters {
|
||||
if mp.Room == p.Room || mp.Room == corp ||
|
||||
(inpass && g.Level.Char(mp.Pos.Y, mp.Pos.X) == Door &&
|
||||
&g.Level.Passages[*g.Level.FlagsAt(mp.Pos.Y, mp.Pos.X)&FPassNum] == p.Room) {
|
||||
if g.drainReaches(mp, corp, inpass) {
|
||||
drainee = append(drainee, mp)
|
||||
}
|
||||
}
|
||||
|
||||
cnt := len(drainee)
|
||||
if cnt == 0 {
|
||||
g.msg("you have a tingling feeling")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.Stats.HP /= 2
|
||||
cnt = p.Stats.HP / cnt
|
||||
// Now zot all of the monsters
|
||||
@@ -199,11 +342,23 @@ func (g *RogueGame) drain() {
|
||||
if mp.Stats.HP -= cnt; mp.Stats.HP <= 0 {
|
||||
g.killed(mp, g.seeMonst(mp))
|
||||
} else {
|
||||
g.runto(mp.Pos)
|
||||
g.runTo(mp.Pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainReaches reports whether the drain-life wand reaches this monster:
|
||||
// the hero's room, the passage behind the door he stands on, or — when
|
||||
// he is in a passage — a door of that same passage (the drainee
|
||||
// condition of sticks.c drain).
|
||||
func (g *RogueGame) drainReaches(mp *Monster, corp *Room, inpass bool) bool {
|
||||
p := &g.Player
|
||||
|
||||
return mp.Room == p.Room || mp.Room == corp ||
|
||||
(inpass && g.Level.Char(mp.Pos.Y, mp.Pos.X) == Door &&
|
||||
&g.Level.Passages[*g.Level.FlagsAt(mp.Pos.Y, mp.Pos.X)&FPassNum] == p.Room)
|
||||
}
|
||||
|
||||
// fireBolt fires a bolt in a given direction from a specific starting
|
||||
// place (sticks.c fire_bolt).
|
||||
func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
|
||||
@@ -217,99 +372,45 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
|
||||
bolt.HPlus = 100
|
||||
bolt.DPlus = 0
|
||||
g.Items.Weapons[WeaponFlame].Name = name
|
||||
var dirch byte
|
||||
switch dir.Y + dir.X {
|
||||
case 0:
|
||||
dirch = '/'
|
||||
case 1, -1:
|
||||
if dir.Y == 0 {
|
||||
dirch = '-'
|
||||
} else {
|
||||
dirch = '|'
|
||||
}
|
||||
case 2, -2:
|
||||
dirch = '\\'
|
||||
}
|
||||
|
||||
dirch := boltDirChar(*dir)
|
||||
pos := start
|
||||
hitHero := !fromHero
|
||||
used := false
|
||||
changed := false
|
||||
|
||||
var spotpos []Coord
|
||||
for len(spotpos) < BoltLength && !used {
|
||||
pos.Y += dir.Y
|
||||
pos.X += dir.X
|
||||
spotpos = append(spotpos, pos)
|
||||
|
||||
ch := g.Level.VisibleChar(pos.Y, pos.X)
|
||||
bounce := false
|
||||
switch ch {
|
||||
case Door:
|
||||
// this code is necessary if the hero is on a door and he
|
||||
// fires at the wall the door is in, it would otherwise loop
|
||||
// infinitely
|
||||
if p.Pos != pos {
|
||||
bounce = true
|
||||
}
|
||||
case '|', '-', ' ':
|
||||
bounce = true
|
||||
}
|
||||
if bounce {
|
||||
if boltBounces(ch, p.Pos, pos) {
|
||||
if !changed {
|
||||
hitHero = !hitHero
|
||||
}
|
||||
|
||||
changed = false
|
||||
dir.Y = -dir.Y
|
||||
dir.X = -dir.X
|
||||
spotpos = spotpos[:len(spotpos)-1]
|
||||
|
||||
g.msg("the %s bounces", name)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if tp := g.Level.MonsterAt(pos.Y, pos.X); !hitHero && tp != nil {
|
||||
hitHero = true
|
||||
changed = !changed
|
||||
tp.OldCh = g.Level.Char(pos.Y, pos.X)
|
||||
if !g.saveThrow(VsMagic, &tp.Stats) {
|
||||
bolt.Pos = pos
|
||||
used = true
|
||||
if tp.Type == 'D' && name == "flame" {
|
||||
g.addmsg("the flame bounces")
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(" off the dragon")
|
||||
}
|
||||
g.endmsg()
|
||||
} else {
|
||||
g.hitMonster(pos, bolt)
|
||||
}
|
||||
} else if ch != 'M' || tp.Disguise == 'M' {
|
||||
if fromHero {
|
||||
g.runto(pos)
|
||||
}
|
||||
if g.Options.Terse {
|
||||
g.msg("%s misses", name)
|
||||
} else {
|
||||
g.msg("the %s whizzes past %s", name, g.setMname(tp))
|
||||
}
|
||||
}
|
||||
used = g.boltStrikesMonster(tp, bolt, pos, ch, name, fromHero)
|
||||
} else if hitHero && pos == p.Pos {
|
||||
hitHero = false
|
||||
changed = !changed
|
||||
if !g.save(VsMagic) {
|
||||
if p.Stats.HP -= g.roll(6, 6); p.Stats.HP <= 0 {
|
||||
if fromHero {
|
||||
g.death('b')
|
||||
} else {
|
||||
g.death(g.Level.MonsterAt(start.Y, start.X).Type)
|
||||
}
|
||||
}
|
||||
used = true
|
||||
if g.Options.Terse {
|
||||
g.msg("the %s hits", name)
|
||||
} else {
|
||||
g.msg("you are hit by the %s", name)
|
||||
}
|
||||
} else {
|
||||
g.msg("the %s whizzes by you", name)
|
||||
}
|
||||
used = g.boltStrikesHero(start, name, fromHero)
|
||||
}
|
||||
|
||||
g.mvaddch(pos.Y, pos.X, dirch)
|
||||
g.refresh()
|
||||
}
|
||||
@@ -319,15 +420,121 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// boltDirChar picks the character a traveling bolt is drawn with for its
|
||||
// direction (the dirch switch of sticks.c fire_bolt).
|
||||
func boltDirChar(dir Coord) byte {
|
||||
switch dir.Y + dir.X {
|
||||
case 0:
|
||||
return '/'
|
||||
case 1, -1:
|
||||
if dir.Y == 0 {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return '|'
|
||||
case 2, -2:
|
||||
return '\\'
|
||||
}
|
||||
|
||||
return 0 // unreachable for the eight legal directions, as in C
|
||||
}
|
||||
|
||||
// boltBounces reports whether a bolt bounces off this spot: walls, and
|
||||
// any door except the one the hero stands on (which would otherwise loop
|
||||
// infinitely, per the C comment in fire_bolt).
|
||||
func boltBounces(ch byte, heroPos, pos Coord) bool {
|
||||
switch ch {
|
||||
case Door:
|
||||
return heroPos != pos
|
||||
case '|', '-', ' ':
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// boltStrikesMonster resolves a bolt arriving on a monster's square (the
|
||||
// monster arm of the fire_bolt loop). It reports whether the bolt was
|
||||
// used up.
|
||||
func (g *RogueGame) boltStrikesMonster(
|
||||
tp *Monster, bolt *Object, pos Coord, ch byte, name string, fromHero bool,
|
||||
) bool {
|
||||
tp.OldCh = g.Level.Char(pos.Y, pos.X)
|
||||
if !g.saveThrow(VsMagic, &tp.Stats) {
|
||||
bolt.Pos = pos
|
||||
|
||||
if tp.Type == 'D' && name == "flame" {
|
||||
g.addmsgf("the flame bounces")
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsgf(" off the dragon")
|
||||
}
|
||||
|
||||
g.endmsg()
|
||||
} else {
|
||||
g.hitMonster(pos, bolt)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if ch != 'M' || tp.Disguise == 'M' {
|
||||
if fromHero {
|
||||
g.runTo(pos)
|
||||
}
|
||||
|
||||
if g.Options.Terse {
|
||||
g.msg("%s misses", name)
|
||||
} else {
|
||||
g.msg("the %s whizzes past %s", name, g.setMname(tp))
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// boltStrikesHero resolves a bolt arriving on the hero (the hero arm of
|
||||
// the fire_bolt loop). It reports whether the bolt was used up.
|
||||
func (g *RogueGame) boltStrikesHero(start Coord, name string, fromHero bool) bool {
|
||||
p := &g.Player
|
||||
if g.save(VsMagic) {
|
||||
g.msg("the %s whizzes by you", name)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if p.Stats.HP -= g.roll(6, 6); p.Stats.HP <= 0 {
|
||||
if fromHero {
|
||||
g.death('b')
|
||||
} else {
|
||||
g.death(g.Level.MonsterAt(start.Y, start.X).Type)
|
||||
}
|
||||
}
|
||||
|
||||
if g.Options.Terse {
|
||||
g.msg("the %s hits", name)
|
||||
} else {
|
||||
g.msg("you are hit by the %s", name)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// fixStick sets up a new wand or staff (sticks.c fix_stick).
|
||||
func (g *RogueGame) fixStick(cur *Object) {
|
||||
if g.Items.WandType[cur.Which] == "staff" {
|
||||
// ws_type[] is indexed by Which; a malformed one is treated as a wand,
|
||||
// which is the branch the C string compare would take against any
|
||||
// value that is not literally "staff". The charge switch below already
|
||||
// funnels everything but WandLight into its default arm.
|
||||
if cur.hasValidWhich() && g.Items.WandType[cur.Which] == staffName {
|
||||
cur.Damage = dice("2x3")
|
||||
} else {
|
||||
cur.Damage = dice("1x1")
|
||||
}
|
||||
|
||||
cur.HurlDmg = dice("1x1")
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch cur.WandKind() {
|
||||
case WandLight:
|
||||
cur.Charges = g.rnd(10) + 10
|
||||
@@ -342,8 +549,10 @@ func chargeStr(g *RogueGame, obj *Object) string {
|
||||
if !obj.Flags.Has(Known) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if g.Options.Terse {
|
||||
return fmt.Sprintf(" [%d]", obj.Charges)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(" [%d charges]", obj.Charges)
|
||||
}
|
||||
|
||||
1225
game/tables.go
1225
game/tables.go
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,31 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
|
||||
// badcheck from init.c: every probability table must sum to exactly 100.
|
||||
func TestProbabilitiesSumTo100(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sum := func(info []ObjInfo) int {
|
||||
s := 0
|
||||
for _, oi := range info {
|
||||
s += oi.Prob
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
data := newGameData()
|
||||
|
||||
tables := map[string][]ObjInfo{
|
||||
"things": baseThings[:],
|
||||
"potions": basePotInfo[:],
|
||||
"scrolls": baseScrInfo[:],
|
||||
"rings": baseRingInfo[:],
|
||||
"sticks": baseWsInfo[:],
|
||||
"weapons": baseWeapInfo[:NumWeaponTypes], // excludes the flame entry
|
||||
"armor": baseArmInfo[:],
|
||||
"things": data.baseThings[:],
|
||||
"potions": data.basePotInfo[:],
|
||||
"scrolls": data.baseScrInfo[:],
|
||||
"rings": data.baseRingInfo[:],
|
||||
"sticks": data.baseWsInfo[:],
|
||||
"weapons": data.baseWeapInfo[:NumWeaponTypes], // excludes the flame entry
|
||||
"armor": data.baseArmInfo[:],
|
||||
}
|
||||
for name, tab := range tables {
|
||||
if s := sum(tab); s != 100 {
|
||||
@@ -28,11 +35,15 @@ func TestProbabilitiesSumTo100(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInitProbsCumulative(t *testing.T) {
|
||||
g := NewGame(Config{Seed: 1})
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 1})
|
||||
|
||||
last := g.Items.Potions[NumPotionTypes-1].Prob
|
||||
if last != 100 {
|
||||
t.Errorf("cumulative potion probability ends at %d, want 100", last)
|
||||
}
|
||||
|
||||
for i := PotionKind(1); i < NumPotionTypes; i++ {
|
||||
if g.Items.Potions[i].Prob < g.Items.Potions[i-1].Prob {
|
||||
t.Errorf("potion probs not nondecreasing at %d", i)
|
||||
@@ -41,46 +52,81 @@ func TestInitProbsCumulative(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGameRandomizesAppearances(t *testing.T) {
|
||||
g := NewGame(Config{Seed: 12345})
|
||||
seen := map[string]bool{}
|
||||
for i, c := range g.Items.PotColors {
|
||||
if c == "" {
|
||||
t.Fatalf("potion %d has no color", i)
|
||||
}
|
||||
if seen[c] {
|
||||
t.Errorf("potion color %q assigned twice", c)
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
for i, n := range g.Items.ScrNames {
|
||||
if n == "" {
|
||||
t.Fatalf("scroll %d has no name", i)
|
||||
}
|
||||
if len(n) > MaxNameLen+1 {
|
||||
t.Errorf("scroll name %q longer than C buffer allows", n)
|
||||
}
|
||||
}
|
||||
for i := range g.Items.WandType {
|
||||
if g.Items.WandType[i] != "wand" && g.Items.WandType[i] != "staff" {
|
||||
t.Errorf("stick %d has type %q", i, g.Items.WandType[i])
|
||||
}
|
||||
if g.Items.WandMade[i] == "" {
|
||||
t.Errorf("stick %d has no material", i)
|
||||
}
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
g := New(Params{Seed: 12345})
|
||||
|
||||
checkPotionColors(t, g)
|
||||
checkScrollNames(t, g)
|
||||
checkWandMaterials(t, g)
|
||||
|
||||
// Determinism: same seed, same appearances.
|
||||
h := NewGame(Config{Seed: 12345})
|
||||
h := New(Params{Seed: 12345})
|
||||
if h.Items != g.Items {
|
||||
t.Error("two games with the same seed produced different item lore")
|
||||
}
|
||||
}
|
||||
|
||||
// checkPotionColors verifies every potion has a distinct color.
|
||||
func checkPotionColors(t *testing.T, g *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
seen := map[string]bool{}
|
||||
|
||||
for i, c := range g.Items.PotColors {
|
||||
if c == "" {
|
||||
t.Fatalf("potion %d has no color", i)
|
||||
}
|
||||
|
||||
if seen[c] {
|
||||
t.Errorf("potion color %q assigned twice", c)
|
||||
}
|
||||
|
||||
seen[c] = true
|
||||
}
|
||||
}
|
||||
|
||||
// checkScrollNames verifies every scroll has a name within the C buffer
|
||||
// limit.
|
||||
func checkScrollNames(t *testing.T, g *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
for i, n := range g.Items.ScrNames {
|
||||
if n == "" {
|
||||
t.Fatalf("scroll %d has no name", i)
|
||||
}
|
||||
|
||||
if len(n) > MaxNameLen+1 {
|
||||
t.Errorf("scroll name %q longer than C buffer allows", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkWandMaterials verifies every stick has a wand/staff type and a
|
||||
// material.
|
||||
func checkWandMaterials(t *testing.T, g *RogueGame) {
|
||||
t.Helper()
|
||||
|
||||
for i := range g.Items.WandType {
|
||||
if g.Items.WandType[i] != wandName && g.Items.WandType[i] != staffName {
|
||||
t.Errorf("stick %d has type %q", i, g.Items.WandType[i])
|
||||
}
|
||||
|
||||
if g.Items.WandMade[i] == "" {
|
||||
t.Errorf("stick %d has no material", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsterTable(t *testing.T) {
|
||||
if monsterTable[0].Name != "aquator" || monsterTable[25].Name != "zombie" {
|
||||
t.Parallel()
|
||||
|
||||
data := newGameData()
|
||||
if data.monsterTable[0].Name != "aquator" || data.monsterTable[25].Name != "zombie" {
|
||||
t.Error("monster table order broken")
|
||||
}
|
||||
if monsterTable['D'-'A'].Name != "dragon" {
|
||||
|
||||
if data.monsterTable['D'-'A'].Name != "dragon" {
|
||||
t.Error("letter indexing broken")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// testTerm is a headless Terminal for tests: rendering is a no-op and
|
||||
@@ -7,19 +8,36 @@ type testTerm struct {
|
||||
input []byte
|
||||
pos int
|
||||
tick int
|
||||
// repaints counts forced full redraws. Rendering is a no-op here, so
|
||||
// counting is the only way a headless test can tell that CTRL-R asked
|
||||
// for a repaint rather than an ordinary refresh — the two are
|
||||
// indistinguishable in the window contents, which is the whole reason
|
||||
// the bug this replaces went unnoticed.
|
||||
repaints int
|
||||
}
|
||||
|
||||
func (t *testTerm) Render(*Window) {}
|
||||
|
||||
func (t *testTerm) ReadChar() byte {
|
||||
func (t *testTerm) Repaint() { t.repaints++ }
|
||||
|
||||
func (t *testTerm) Fini() {}
|
||||
|
||||
// Interrupt has nothing to wake: this terminal's ReadChar never blocks.
|
||||
// The blocking case has its own fake, blockingTerm in autosave_test.go.
|
||||
func (t *testTerm) Interrupt() {}
|
||||
|
||||
func (t *testTerm) ReadChar() (byte, bool) {
|
||||
if t.pos < len(t.input) {
|
||||
c := t.input[t.pos]
|
||||
t.pos++
|
||||
return c
|
||||
|
||||
return c, true
|
||||
}
|
||||
|
||||
t.tick++
|
||||
if t.tick%2 == 0 {
|
||||
return '\n'
|
||||
return '\n', true
|
||||
}
|
||||
return ' '
|
||||
|
||||
return ' ', true
|
||||
}
|
||||
|
||||
27
game/testdata/README.md
vendored
Normal file
27
game/testdata/README.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Seed-compatibility golden
|
||||
|
||||
`item_tables.golden` is the per-seed item appearance tables (potion colors,
|
||||
scroll names, ring stones, wand/staff materials) captured from the **C
|
||||
reference** on the `modern-rogue` branch, for the seeds in the `seeds` list in
|
||||
`TestSeedCompatItemTables`. That test regenerates the same tables from the Go
|
||||
port and checks they match byte for byte — proving the LCG and its consumption
|
||||
order through the whole init sequence (`init_probs` → `init_player` →
|
||||
`init_names` → `init_colors` → `init_stones` → `init_materials`) agree with C.
|
||||
|
||||
## Regenerating the golden
|
||||
|
||||
`c_seedcompat.patch` adds a `DUMP` mode to the C `main.c`: with `DUMP` set it
|
||||
forces the RNG seed from `SEED`, runs the item-table init in the normal order,
|
||||
prints the tables, and exits before `initscr` (so no terminal is needed).
|
||||
|
||||
```sh
|
||||
# from a checkout of the C reference (modern-rogue branch):
|
||||
git archive modern-rogue | tar -x -C /tmp/rogue-c
|
||||
cd /tmp/rogue-c
|
||||
patch -p1 < .../game/testdata/c_seedcompat.patch
|
||||
./configure && make
|
||||
for s in 1 42 12345 99999; do DUMP=1 SEED=$s ./rogue; done \
|
||||
> .../game/testdata/item_tables.golden
|
||||
```
|
||||
|
||||
The seed list must match the `seeds` slice in `TestSeedCompatItemTables`.
|
||||
37
game/testdata/c_seedcompat.patch
vendored
Normal file
37
game/testdata/c_seedcompat.patch
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
--- a/main.c 2026-07-24 03:02:38
|
||||
+++ b/main.c 2026-07-24 02:48:31
|
||||
@@ -63,6 +63,34 @@
|
||||
#endif
|
||||
dnum = lowtime + md_getpid();
|
||||
seed = dnum;
|
||||
+
|
||||
+ /* SEEDCOMPAT: dump the per-game item appearance tables for a fixed
|
||||
+ * seed and exit, without initscr. The init sequence and everything
|
||||
+ * it consumes from rnd() mirror the normal startup (main.c), so the
|
||||
+ * tables are exactly what a real game with SEED would show. */
|
||||
+ if (getenv("DUMP") != NULL)
|
||||
+ {
|
||||
+ int di;
|
||||
+ char *sv = getenv("SEED");
|
||||
+ if (sv != NULL)
|
||||
+ seed = atoi(sv);
|
||||
+ printf("SEED %d\n", seed);
|
||||
+ init_probs();
|
||||
+ init_player();
|
||||
+ init_names();
|
||||
+ init_colors();
|
||||
+ init_stones();
|
||||
+ init_materials();
|
||||
+ printf("POTIONS\n");
|
||||
+ for (di = 0; di < MAXPOTIONS; di++) printf("%s\n", p_colors[di]);
|
||||
+ printf("SCROLLS\n");
|
||||
+ for (di = 0; di < MAXSCROLLS; di++) printf("%s\n", s_names[di]);
|
||||
+ printf("RINGS\n");
|
||||
+ for (di = 0; di < MAXRINGS; di++) printf("%s\n", r_stones[di]);
|
||||
+ printf("STICKS\n");
|
||||
+ for (di = 0; di < MAXSTICKS; di++) printf("%s %s\n", ws_type[di], ws_made[di]);
|
||||
+ exit(0);
|
||||
+ }
|
||||
|
||||
open_score();
|
||||
|
||||
260
game/testdata/item_tables.golden
vendored
Normal file
260
game/testdata/item_tables.golden
vendored
Normal file
@@ -0,0 +1,260 @@
|
||||
SEED 1
|
||||
POTIONS
|
||||
tangerine
|
||||
white
|
||||
ecru
|
||||
gold
|
||||
amber
|
||||
violet
|
||||
vermilion
|
||||
pink
|
||||
aquamarine
|
||||
plaid
|
||||
clear
|
||||
orange
|
||||
cyan
|
||||
tan
|
||||
SCROLLS
|
||||
miwhon garsnanih
|
||||
xomimi roke eshwedshu
|
||||
potwexrol ipbjorod turs evsnelg
|
||||
bekornan oxyfatox
|
||||
iv wexpo wun
|
||||
ha sefnelgtue whon pay
|
||||
alari wedit
|
||||
zantmon umzonski umwhonjo yot
|
||||
bluoxun rokkho yottrol sta
|
||||
vomarg microgcomp iteulkshu mung
|
||||
jo urokeep yuskiun
|
||||
ox xozantaks klisstaevs ag
|
||||
ipnih bek
|
||||
shu ami erk
|
||||
nejti zim
|
||||
iprol mic ishoxyvom fagan
|
||||
reacreti oodrol
|
||||
bytsri solsa tabu fri
|
||||
RINGS
|
||||
agate
|
||||
zircon
|
||||
jade
|
||||
tiger eye
|
||||
onyx
|
||||
germanium
|
||||
lapis lazuli
|
||||
emerald
|
||||
taaffeite
|
||||
kryptonite
|
||||
garnet
|
||||
ruby
|
||||
turquoise
|
||||
pearl
|
||||
STICKS
|
||||
wand steel
|
||||
wand platinum
|
||||
staff redwood
|
||||
staff pine
|
||||
wand silicon
|
||||
staff spruce
|
||||
staff pecan
|
||||
wand bone
|
||||
staff maple
|
||||
wand zinc
|
||||
wand iron
|
||||
wand pewter
|
||||
wand electrum
|
||||
staff dogwood
|
||||
SEED 42
|
||||
POTIONS
|
||||
blue
|
||||
green
|
||||
grey
|
||||
amber
|
||||
violet
|
||||
gold
|
||||
pink
|
||||
tan
|
||||
purple
|
||||
yellow
|
||||
plaid
|
||||
magenta
|
||||
turquoise
|
||||
cyan
|
||||
SCROLLS
|
||||
bek itod oxytaod oxy
|
||||
tarhovzant cre sname oxroy
|
||||
plemik ganod hyd wergerkpot
|
||||
hyd sol um bekzok
|
||||
esh eep ganmung
|
||||
anera ishsa ingala mon
|
||||
alasniklech viv
|
||||
yunejorn garro con nej
|
||||
dotrolther gopum eltitrol trolmonsri
|
||||
nes alazum
|
||||
itegopmung ti
|
||||
ere haeta wergla
|
||||
nejerecre poipi iprea
|
||||
ha falechrhov
|
||||
monskiwex sabitla frido
|
||||
rhovmar sno
|
||||
mar bekurzant satbuzum
|
||||
somon sri
|
||||
RINGS
|
||||
carnelian
|
||||
onyx
|
||||
jade
|
||||
granite
|
||||
stibotantalite
|
||||
kryptonite
|
||||
lapis lazuli
|
||||
germanium
|
||||
garnet
|
||||
tiger eye
|
||||
opal
|
||||
topaz
|
||||
agate
|
||||
peridot
|
||||
STICKS
|
||||
staff birch
|
||||
staff ebony
|
||||
staff redwood
|
||||
wand gold
|
||||
wand copper
|
||||
wand aluminum
|
||||
wand titanium
|
||||
wand mercury
|
||||
staff cypress
|
||||
staff bamboo
|
||||
staff dogwood
|
||||
wand silicon
|
||||
staff zebrawood
|
||||
wand beryllium
|
||||
SEED 12345
|
||||
POTIONS
|
||||
purple
|
||||
black
|
||||
grey
|
||||
brown
|
||||
plaid
|
||||
violet
|
||||
vermilion
|
||||
ecru
|
||||
orange
|
||||
turquoise
|
||||
tan
|
||||
magenta
|
||||
silver
|
||||
gold
|
||||
SCROLLS
|
||||
readalf shuplu ivnin
|
||||
plelaiv solel skibyt monha
|
||||
xo wun
|
||||
wedyfri o ewhonxo favompay
|
||||
eep zantreanelg
|
||||
plu buxo
|
||||
un zontabdan
|
||||
bie snik
|
||||
ulkitzant bluri
|
||||
apporg ash posnevly dennepwex
|
||||
u urval rol
|
||||
arzepotsno snovly pay snoropay
|
||||
pottox erewed faoxro
|
||||
ther sun ulkipo mik
|
||||
argzebfri elgrekli tuenepzon sehturssef
|
||||
isheep blumur
|
||||
wedash yuzimsun
|
||||
plupofri ski rejo fa
|
||||
RINGS
|
||||
onyx
|
||||
tiger eye
|
||||
alexandrite
|
||||
turquoise
|
||||
pearl
|
||||
emerald
|
||||
germanium
|
||||
sapphire
|
||||
zircon
|
||||
ruby
|
||||
granite
|
||||
stibotantalite
|
||||
opal
|
||||
diamond
|
||||
STICKS
|
||||
wand silicon
|
||||
staff ironwood
|
||||
staff holly
|
||||
wand gold
|
||||
staff mahogany
|
||||
wand iron
|
||||
wand brass
|
||||
wand pewter
|
||||
staff hemlock
|
||||
staff cherry
|
||||
staff elm
|
||||
wand mercury
|
||||
staff banyan
|
||||
staff dogwood
|
||||
SEED 99999
|
||||
POTIONS
|
||||
aquamarine
|
||||
plaid
|
||||
gold
|
||||
black
|
||||
vermilion
|
||||
red
|
||||
cyan
|
||||
tan
|
||||
orange
|
||||
violet
|
||||
brown
|
||||
clear
|
||||
silver
|
||||
green
|
||||
SCROLLS
|
||||
zant jocompan vomervly
|
||||
mur shusat prok
|
||||
prokmurklis oxysriklis
|
||||
ingcre prokbu whonengarg kli
|
||||
kli bot
|
||||
rokcoswerg ipsolsan klisvlypay
|
||||
glen yot whontox
|
||||
lechme markho fazim
|
||||
dalfsunbie micjosef cre comp
|
||||
vlyfumi bjorzantbot werg
|
||||
po argfidcos klipones
|
||||
ashtemarg ycrezim dalfiv whon
|
||||
turs unmisa zimpo therdo
|
||||
miccompuni uni neswex sef
|
||||
odwexing elwergmur mung
|
||||
itcon rhov nejmic lech
|
||||
garturs engseh ganish
|
||||
oodta whonorgsno monabmik vomyeng
|
||||
RINGS
|
||||
obsidian
|
||||
moonstone
|
||||
jade
|
||||
carnelian
|
||||
tiger eye
|
||||
taaffeite
|
||||
turquoise
|
||||
stibotantalite
|
||||
agate
|
||||
ruby
|
||||
onyx
|
||||
topaz
|
||||
germanium
|
||||
granite
|
||||
STICKS
|
||||
wand titanium
|
||||
wand brass
|
||||
wand silicon
|
||||
staff zebrawood
|
||||
wand mercury
|
||||
staff dogwood
|
||||
wand pewter
|
||||
staff cinnibar
|
||||
staff kukui wood
|
||||
staff banyan
|
||||
wand magnesium
|
||||
wand gold
|
||||
staff maple
|
||||
wand nickel
|
||||
590
game/things.go
590
game/things.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
@@ -9,105 +10,175 @@ import (
|
||||
|
||||
// invName returns the name of something as it would appear in an inventory
|
||||
// (things.c inv_name).
|
||||
func (g *RogueGame) invName(obj *Object, drop bool) string {
|
||||
func (g *RogueGame) inventoryName(obj *Object, drop bool) string {
|
||||
var pb strings.Builder
|
||||
|
||||
// Every arm below reaches into a per-kind name table at obj.Which:
|
||||
// the potion colors, ring stones, wand material/type, scroll titles,
|
||||
// and the weapon and armor name tables. An object with an out-of-range
|
||||
// Which cannot reach here (createObj and Restore both reject one), but
|
||||
// if one ever did, naming it must not take the process down — so it
|
||||
// falls back to the bare category name from C's type_name() vocabulary.
|
||||
if !obj.hasValidWhich() {
|
||||
return obj.Kind.String()
|
||||
}
|
||||
|
||||
which := obj.Which
|
||||
it := &g.Items
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindPotion:
|
||||
g.nameit(&pb, obj, "potion", it.PotColors[which], &it.Potions[which], nullstr)
|
||||
g.nameit(&pb, obj, potionName, it.PotColors[which], &it.Potions[which], nullstr)
|
||||
case KindRing:
|
||||
g.nameit(&pb, obj, "ring", it.RingStones[which], &it.Rings[which], ringNum)
|
||||
g.nameit(&pb, obj, ringName, it.RingStones[which], &it.Rings[which], ringNum)
|
||||
case KindWand:
|
||||
g.nameit(&pb, obj, it.WandType[which], it.WandMade[which], &it.Sticks[which], chargeStr)
|
||||
g.nameit(&pb, obj, it.WandType[which], it.WandMade[which],
|
||||
&it.Sticks[which], chargeStr)
|
||||
case KindScroll:
|
||||
if obj.Count == 1 {
|
||||
pb.WriteString("A scroll ")
|
||||
} else {
|
||||
fmt.Fprintf(&pb, "%d scrolls ", obj.Count)
|
||||
}
|
||||
op := &it.Scrolls[which]
|
||||
if op.Know {
|
||||
fmt.Fprintf(&pb, "of %s", op.Name)
|
||||
} else if op.Guess != "" {
|
||||
fmt.Fprintf(&pb, "called %s", op.Guess)
|
||||
} else {
|
||||
fmt.Fprintf(&pb, "titled '%s'", it.ScrNames[which])
|
||||
}
|
||||
g.nameScroll(&pb, obj)
|
||||
case KindFood:
|
||||
if which == 1 {
|
||||
if obj.Count == 1 {
|
||||
fmt.Fprintf(&pb, "A%s %s", vowelstr(g.Fruit), g.Fruit)
|
||||
} else {
|
||||
fmt.Fprintf(&pb, "%d %ss", obj.Count, g.Fruit)
|
||||
}
|
||||
} else {
|
||||
if obj.Count == 1 {
|
||||
pb.WriteString("Some food")
|
||||
} else {
|
||||
fmt.Fprintf(&pb, "%d rations of food", obj.Count)
|
||||
}
|
||||
}
|
||||
g.nameFood(&pb, obj)
|
||||
case KindWeapon:
|
||||
sp := it.Weapons[which].Name
|
||||
if obj.Count > 1 {
|
||||
fmt.Fprintf(&pb, "%d ", obj.Count)
|
||||
} else {
|
||||
fmt.Fprintf(&pb, "A%s ", vowelstr(sp))
|
||||
}
|
||||
if obj.Flags.Has(Known) {
|
||||
fmt.Fprintf(&pb, "%s %s", num(obj.HPlus, obj.DPlus, Weapon), sp)
|
||||
} else {
|
||||
pb.WriteString(sp)
|
||||
}
|
||||
if obj.Count > 1 {
|
||||
pb.WriteString("s")
|
||||
}
|
||||
if obj.Label != "" {
|
||||
fmt.Fprintf(&pb, " called %s", obj.Label)
|
||||
}
|
||||
g.nameWeapon(&pb, obj)
|
||||
case KindArmor:
|
||||
sp := it.Armors[which].Name
|
||||
if obj.Flags.Has(Known) {
|
||||
fmt.Fprintf(&pb, "%s %s [", num(aClass[which]-obj.ArmorClass, 0, Armor), sp)
|
||||
if !g.Options.Terse {
|
||||
pb.WriteString("protection ")
|
||||
}
|
||||
fmt.Fprintf(&pb, "%d]", 10-obj.ArmorClass)
|
||||
} else {
|
||||
pb.WriteString(sp)
|
||||
}
|
||||
if obj.Label != "" {
|
||||
fmt.Fprintf(&pb, " called %s", obj.Label)
|
||||
}
|
||||
g.nameArmor(&pb, obj)
|
||||
case KindAmulet:
|
||||
pb.WriteString("The Amulet of Yendor")
|
||||
case KindGold:
|
||||
fmt.Fprintf(&pb, "%d Gold pieces", obj.GoldValue)
|
||||
}
|
||||
|
||||
out := pb.String()
|
||||
if g.InvDescribe {
|
||||
p := &g.Player
|
||||
if obj == p.CurArmor {
|
||||
out += " (being worn)"
|
||||
}
|
||||
if obj == p.CurWeapon {
|
||||
out += " (weapon in hand)"
|
||||
}
|
||||
if obj == p.CurRing[Left] {
|
||||
out += " (on left hand)"
|
||||
} else if obj == p.CurRing[Right] {
|
||||
out += " (on right hand)"
|
||||
}
|
||||
return fixNameCase(g.describeWorn(obj, pb.String()), drop)
|
||||
}
|
||||
|
||||
// nameScroll writes a scroll's inventory name (things.c inv_name).
|
||||
func (g *RogueGame) nameScroll(pb *strings.Builder, obj *Object) {
|
||||
if obj.Count == 1 {
|
||||
pb.WriteString("A scroll ")
|
||||
} else {
|
||||
fmt.Fprintf(pb, "%d scrolls ", obj.Count)
|
||||
}
|
||||
if out != "" {
|
||||
if drop && isUpper(out[0]) {
|
||||
out = string(toLower(out[0])) + out[1:]
|
||||
} else if !drop && isLower(out[0]) {
|
||||
out = string(toUpper(out[0])) + out[1:]
|
||||
}
|
||||
|
||||
op := &g.Items.Scrolls[obj.Which]
|
||||
|
||||
switch {
|
||||
case op.Know:
|
||||
fmt.Fprintf(pb, "of %s", op.Name)
|
||||
case op.Guess != "":
|
||||
fmt.Fprintf(pb, "called %s", op.Guess)
|
||||
default:
|
||||
fmt.Fprintf(pb, "titled '%s'", g.Items.ScrNames[obj.Which])
|
||||
}
|
||||
}
|
||||
|
||||
// nameFood writes a food item's inventory name; which 1 is the fruit
|
||||
// (things.c inv_name).
|
||||
func (g *RogueGame) nameFood(pb *strings.Builder, obj *Object) {
|
||||
if obj.Which == 1 {
|
||||
if obj.Count == 1 {
|
||||
fmt.Fprintf(pb, "A%s %s", vowelstr(g.Fruit), g.Fruit)
|
||||
} else {
|
||||
fmt.Fprintf(pb, "%d %ss", obj.Count, g.Fruit)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Count == 1 {
|
||||
pb.WriteString("Some food")
|
||||
} else {
|
||||
fmt.Fprintf(pb, "%d rations of food", obj.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// nameWeapon writes a weapon's inventory name (things.c inv_name).
|
||||
func (g *RogueGame) nameWeapon(pb *strings.Builder, obj *Object) {
|
||||
sp := g.Items.Weapons[obj.Which].Name
|
||||
|
||||
if obj.Count > 1 {
|
||||
fmt.Fprintf(pb, "%d ", obj.Count)
|
||||
} else {
|
||||
fmt.Fprintf(pb, "A%s ", vowelstr(sp))
|
||||
}
|
||||
|
||||
if obj.Flags.Has(Known) {
|
||||
fmt.Fprintf(pb, "%s %s", num(obj.HPlus, obj.DPlus, Weapon), sp)
|
||||
} else {
|
||||
pb.WriteString(sp)
|
||||
}
|
||||
|
||||
if obj.Count > 1 {
|
||||
pb.WriteString("s")
|
||||
}
|
||||
|
||||
if obj.Label != "" {
|
||||
fmt.Fprintf(pb, " called %s", obj.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// nameArmor writes an armor's inventory name (things.c inv_name).
|
||||
func (g *RogueGame) nameArmor(pb *strings.Builder, obj *Object) {
|
||||
sp := g.Items.Armors[obj.Which].Name
|
||||
if obj.Flags.Has(Known) {
|
||||
fmt.Fprintf(pb, "%s %s [",
|
||||
num(g.data.armorClass(obj.Which)-obj.ArmorClass, 0, Armor), sp)
|
||||
|
||||
if !g.Options.Terse {
|
||||
pb.WriteString("protection ")
|
||||
}
|
||||
|
||||
fmt.Fprintf(pb, "%d]", 10-obj.ArmorClass)
|
||||
} else {
|
||||
pb.WriteString(sp)
|
||||
}
|
||||
|
||||
if obj.Label != "" {
|
||||
fmt.Fprintf(pb, " called %s", obj.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// describeWorn appends the equipped-status notes to an inventory name
|
||||
// (things.c inv_name).
|
||||
func (g *RogueGame) describeWorn(obj *Object, out string) string {
|
||||
if !g.InvDescribe {
|
||||
return out
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
if obj == p.CurArmor {
|
||||
out += " (being worn)"
|
||||
}
|
||||
|
||||
if obj == p.CurWeapon {
|
||||
out += " (weapon in hand)"
|
||||
}
|
||||
|
||||
switch obj {
|
||||
case p.CurRing[Left]:
|
||||
out += " (on left hand)"
|
||||
case p.CurRing[Right]:
|
||||
out += " (on right hand)"
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// fixNameCase upper- or lowercases the leading letter to suit the
|
||||
// sentence it will land in (things.c inv_name).
|
||||
func fixNameCase(out string, drop bool) string {
|
||||
if out == "" {
|
||||
return out
|
||||
}
|
||||
|
||||
if drop && isUpper(out[0]) {
|
||||
return string(toLower(out[0])) + out[1:]
|
||||
}
|
||||
|
||||
if !drop && isLower(out[0]) {
|
||||
return string(toUpper(out[0])) + out[1:]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -115,29 +186,36 @@ func (g *RogueGame) invName(obj *Object, drop bool) string {
|
||||
// leavePack/detach vocabulary collision).
|
||||
func (g *RogueGame) dropIt() {
|
||||
p := &g.Player
|
||||
|
||||
ch := g.Level.Char(p.Pos.Y, p.Pos.X)
|
||||
if ch != Floor && ch != Passage {
|
||||
g.After = false
|
||||
g.msg("there is something there already")
|
||||
|
||||
return
|
||||
}
|
||||
obj := g.getItem("drop", KindNone)
|
||||
if obj == nil {
|
||||
|
||||
obj, ok := g.promptPackItem("drop", KindNone)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !g.dropCheck(obj) {
|
||||
return
|
||||
}
|
||||
|
||||
obj = g.leavePack(obj, true, !obj.Kind.MergesInPack())
|
||||
// Link it into the level object list
|
||||
attachObj(&g.Level.Objects, obj)
|
||||
g.Level.AddObject(obj)
|
||||
g.Level.SetChar(p.Pos.Y, p.Pos.X, obj.Kind.Glyph())
|
||||
g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Set(FDropped)
|
||||
|
||||
obj.Pos = p.Pos
|
||||
if obj.Kind == KindAmulet {
|
||||
g.HasAmulet = false
|
||||
}
|
||||
g.msg("dropped %s", g.invName(obj, true))
|
||||
|
||||
g.msg("dropped %s", g.inventoryName(obj, true))
|
||||
}
|
||||
|
||||
// dropCheck does special checks for dropping or unwielding|unwearing|
|
||||
@@ -146,37 +224,55 @@ func (g *RogueGame) dropCheck(obj *Object) bool {
|
||||
if obj == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
p := &g.Player
|
||||
if obj != p.CurArmor && obj != p.CurWeapon &&
|
||||
obj != p.CurRing[Left] && obj != p.CurRing[Right] {
|
||||
return true
|
||||
}
|
||||
|
||||
if obj.Flags.Has(Cursed) {
|
||||
g.msg("you can't. It appears to be cursed")
|
||||
|
||||
return false
|
||||
}
|
||||
if obj == p.CurWeapon {
|
||||
|
||||
switch obj {
|
||||
case p.CurWeapon:
|
||||
p.CurWeapon = nil
|
||||
} else if obj == p.CurArmor {
|
||||
case p.CurArmor:
|
||||
g.wasteTime()
|
||||
|
||||
p.CurArmor = nil
|
||||
} else {
|
||||
hand := Right
|
||||
if obj == p.CurRing[Left] {
|
||||
hand = Left
|
||||
}
|
||||
p.CurRing[hand] = nil
|
||||
switch obj.RingKind() {
|
||||
case RingAddStrength:
|
||||
g.chgStr(-obj.Bonus)
|
||||
case RingSeeInvisible:
|
||||
g.unsee(0)
|
||||
g.Extinguish(DUnsee)
|
||||
}
|
||||
default:
|
||||
g.dropRing(obj)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// dropRing takes a worn ring off with its side effects (things.c
|
||||
// dropcheck).
|
||||
func (g *RogueGame) dropRing(obj *Object) {
|
||||
p := &g.Player
|
||||
|
||||
hand := Right
|
||||
if obj == p.CurRing[Left] {
|
||||
hand = Left
|
||||
}
|
||||
|
||||
p.CurRing[hand] = nil
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingAddStrength:
|
||||
g.changeStrength(-obj.Bonus)
|
||||
case RingSeeInvisible:
|
||||
g.unsee(0)
|
||||
g.Extinguish(DUnsee)
|
||||
}
|
||||
}
|
||||
|
||||
// newThing returns a new random thing for the dungeon (things.c new_thing).
|
||||
func (g *RogueGame) newThing() *Object {
|
||||
cur := newObject()
|
||||
@@ -193,6 +289,7 @@ func (g *RogueGame) newThing() *Object {
|
||||
} else {
|
||||
kind = pickOne(g, g.Items.Things[:])
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case 0:
|
||||
cur.Kind = KindPotion
|
||||
@@ -201,51 +298,80 @@ func (g *RogueGame) newThing() *Object {
|
||||
cur.Kind = KindScroll
|
||||
cur.Which = pickOne(g, g.Items.Scrolls[:])
|
||||
case 2:
|
||||
cur.Kind = KindFood
|
||||
g.Player.NoFood = 0
|
||||
if g.rnd(10) != 0 {
|
||||
cur.Which = 0
|
||||
} else {
|
||||
cur.Which = 1
|
||||
}
|
||||
g.newFoodThing(cur)
|
||||
case 3:
|
||||
g.initWeapon(cur, WeaponKind(pickOne(g, g.Items.Weapons[:NumWeaponTypes])))
|
||||
if r := g.rnd(100); r < 10 {
|
||||
cur.Flags.Set(Cursed)
|
||||
cur.HPlus -= g.rnd(3) + 1
|
||||
} else if r < 15 {
|
||||
cur.HPlus += g.rnd(3) + 1
|
||||
}
|
||||
g.newWeaponThing(cur)
|
||||
case 4:
|
||||
cur.Kind = KindArmor
|
||||
cur.Which = pickOne(g, g.Items.Armors[:])
|
||||
cur.ArmorClass = aClass[cur.Which]
|
||||
if r := g.rnd(100); r < 20 {
|
||||
cur.Flags.Set(Cursed)
|
||||
cur.ArmorClass += g.rnd(3) + 1
|
||||
} else if r < 28 {
|
||||
cur.ArmorClass -= g.rnd(3) + 1
|
||||
}
|
||||
g.newArmorThing(cur)
|
||||
case 5:
|
||||
cur.Kind = KindRing
|
||||
cur.Which = pickOne(g, g.Items.Rings[:])
|
||||
switch cur.RingKind() {
|
||||
case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage:
|
||||
if cur.Bonus = g.rnd(3); cur.Bonus == 0 {
|
||||
cur.Bonus = -1
|
||||
cur.Flags.Set(Cursed)
|
||||
}
|
||||
case RingAggravateMonsters, RingTeleportation:
|
||||
cur.Flags.Set(Cursed)
|
||||
}
|
||||
g.newRingThing(cur)
|
||||
case 6:
|
||||
cur.Kind = KindWand
|
||||
cur.Which = pickOne(g, g.Items.Sticks[:])
|
||||
g.fixStick(cur)
|
||||
}
|
||||
|
||||
return cur
|
||||
}
|
||||
|
||||
// newFoodThing rolls food, one time in ten the fruit (things.c
|
||||
// new_thing).
|
||||
func (g *RogueGame) newFoodThing(cur *Object) {
|
||||
cur.Kind = KindFood
|
||||
|
||||
g.Player.NoFood = 0
|
||||
if g.rnd(10) != 0 {
|
||||
cur.Which = 0
|
||||
} else {
|
||||
cur.Which = 1
|
||||
}
|
||||
}
|
||||
|
||||
// newWeaponThing rolls a weapon, sometimes cursed or blessed (things.c
|
||||
// new_thing).
|
||||
func (g *RogueGame) newWeaponThing(cur *Object) {
|
||||
g.initWeapon(cur, WeaponKind(pickOne(g, g.Items.Weapons[:NumWeaponTypes])))
|
||||
|
||||
if r := g.rnd(100); r < 10 {
|
||||
cur.Flags.Set(Cursed)
|
||||
cur.HPlus -= g.rnd(3) + 1
|
||||
} else if r < 15 {
|
||||
cur.HPlus += g.rnd(3) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// newArmorThing rolls armor, sometimes cursed or blessed (things.c
|
||||
// new_thing).
|
||||
func (g *RogueGame) newArmorThing(cur *Object) {
|
||||
cur.Kind = KindArmor
|
||||
cur.Which = pickOne(g, g.Items.Armors[:])
|
||||
|
||||
cur.ArmorClass = g.data.aClass[cur.Which]
|
||||
if r := g.rnd(100); r < 20 {
|
||||
cur.Flags.Set(Cursed)
|
||||
cur.ArmorClass += g.rnd(3) + 1
|
||||
} else if r < 28 {
|
||||
cur.ArmorClass -= g.rnd(3) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// newRingThing rolls a ring, cursing the bad ones (things.c new_thing).
|
||||
func (g *RogueGame) newRingThing(cur *Object) {
|
||||
cur.Kind = KindRing
|
||||
|
||||
cur.Which = pickOne(g, g.Items.Rings[:])
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch cur.RingKind() {
|
||||
case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage:
|
||||
if cur.Bonus = g.rnd(3); cur.Bonus == 0 {
|
||||
cur.Bonus = -1
|
||||
cur.Flags.Set(Cursed)
|
||||
}
|
||||
case RingAggravateMonsters, RingTeleportation:
|
||||
cur.Flags.Set(Cursed)
|
||||
}
|
||||
}
|
||||
|
||||
// pickOne picks an item out of a list of possible objects using their
|
||||
// cumulative probabilities (things.c pick_one).
|
||||
func pickOne(g *RogueGame, info []ObjInfo) int {
|
||||
@@ -255,6 +381,7 @@ func pickOne(g *RogueGame, info []ObjInfo) int {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
|
||||
return 0 // bad pick_one: C resets to the start of the table
|
||||
}
|
||||
|
||||
@@ -272,20 +399,27 @@ type invPage struct {
|
||||
// (things.c discovered).
|
||||
func (g *RogueGame) discovered() {
|
||||
var ch byte
|
||||
|
||||
for {
|
||||
discList := false
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("for ")
|
||||
g.addmsgf("for ")
|
||||
}
|
||||
g.addmsg("what type")
|
||||
|
||||
g.addmsgf("what type")
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(" of object do you want a list")
|
||||
g.addmsgf(" of object do you want a list")
|
||||
}
|
||||
|
||||
g.msg("? (* for all)")
|
||||
|
||||
ch = g.readchar()
|
||||
switch ch {
|
||||
case Escape:
|
||||
g.msg("")
|
||||
|
||||
return
|
||||
case Potion, Scroll, Ring, Stick, '*':
|
||||
discList = true
|
||||
@@ -297,10 +431,12 @@ func (g *RogueGame) discovered() {
|
||||
Potion, Scroll, Ring, Stick)
|
||||
}
|
||||
}
|
||||
|
||||
if discList {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ch == '*' {
|
||||
g.printDisc(Potion)
|
||||
g.addLine("")
|
||||
@@ -320,6 +456,7 @@ func (g *RogueGame) discovered() {
|
||||
// (things.c print_disc).
|
||||
func (g *RogueGame) printDisc(typ byte) {
|
||||
var info []ObjInfo
|
||||
|
||||
switch typ {
|
||||
case Scroll:
|
||||
info = g.Items.Scrolls[:]
|
||||
@@ -330,18 +467,23 @@ func (g *RogueGame) printDisc(typ byte) {
|
||||
case Stick:
|
||||
info = g.Items.Sticks[:]
|
||||
}
|
||||
|
||||
order := make([]int, len(info))
|
||||
g.setOrder(order)
|
||||
|
||||
obj := Object{Count: 1}
|
||||
numFound := 0
|
||||
|
||||
for i := range info {
|
||||
if info[order[i]].Know || info[order[i]].Guess != "" {
|
||||
obj.Kind = objectKindForGlyph(typ)
|
||||
obj.Which = order[i]
|
||||
g.addLine("%s", g.invName(&obj, false))
|
||||
g.addLine("%s", g.inventoryName(&obj, false))
|
||||
|
||||
numFound++
|
||||
}
|
||||
}
|
||||
|
||||
if numFound == 0 {
|
||||
g.addLine("%s", g.nothing(typ))
|
||||
}
|
||||
@@ -353,6 +495,7 @@ func (g *RogueGame) setOrder(order []int) {
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
|
||||
for i := len(order); i > 0; i-- {
|
||||
r := g.rnd(i)
|
||||
order[i-1], order[r] = order[r], order[i-1]
|
||||
@@ -366,8 +509,8 @@ const flushSentinel = "\x00"
|
||||
|
||||
func (g *RogueGame) addLine(format string, a ...any) int {
|
||||
pg := &g.invPage
|
||||
prompt := "--Press space to continue--"
|
||||
isFlush := format == flushSentinel
|
||||
|
||||
var line string
|
||||
if !isFlush {
|
||||
line = fmt.Sprintf(format, a...)
|
||||
@@ -375,64 +518,107 @@ func (g *RogueGame) addLine(format string, a ...any) int {
|
||||
|
||||
if pg.lineCnt == 0 {
|
||||
g.scr.Hw.Clear()
|
||||
|
||||
if g.Options.InvType == InvSlow {
|
||||
g.Msgs.Mpos = 0
|
||||
}
|
||||
}
|
||||
|
||||
if g.Options.InvType == InvSlow {
|
||||
if !isFlush && line != "" {
|
||||
if g.msg("%s", line) == Escape {
|
||||
return Escape
|
||||
}
|
||||
}
|
||||
pg.lineCnt++
|
||||
} else {
|
||||
if !pg.init {
|
||||
pg.maxlen = len(prompt)
|
||||
pg.init = true
|
||||
}
|
||||
if pg.lineCnt >= NumLines-1 || isFlush {
|
||||
if g.Options.InvType == InvOver && isFlush && !pg.newpage {
|
||||
// Overlay the accumulated list in a box at the top right
|
||||
// of the screen, prompt, and restore what was beneath.
|
||||
g.msg("")
|
||||
g.refresh()
|
||||
saved := NewWindow(NumLines, NumCols)
|
||||
saved.CopyFrom(g.scr.Std)
|
||||
lx := NumCols - pg.maxlen - 2
|
||||
for y := 0; y <= pg.lineCnt; y++ {
|
||||
for x := 0; x <= pg.maxlen; x++ {
|
||||
g.scr.Std.MvAddCh(y, lx+x, g.scr.Hw.MvInch(y, x))
|
||||
}
|
||||
}
|
||||
g.scr.Std.MvAddStr(pg.lineCnt, lx, prompt)
|
||||
g.refresh()
|
||||
g.waitFor(' ')
|
||||
g.scr.Std.CopyFrom(saved)
|
||||
g.refresh()
|
||||
} else {
|
||||
g.scr.Hw.MvAddStr(NumLines-1, 0, prompt)
|
||||
g.scr.RefreshWin(g.scr.Hw)
|
||||
g.waitFor(' ')
|
||||
g.scr.Hw.Clear()
|
||||
g.refresh()
|
||||
}
|
||||
pg.newpage = true
|
||||
pg.lineCnt = 0
|
||||
pg.maxlen = len(prompt)
|
||||
}
|
||||
if !isFlush && !(pg.lineCnt == 0 && line == "") {
|
||||
g.scr.Hw.MvAddStr(pg.lineCnt, 0, line)
|
||||
pg.lineCnt++
|
||||
if pg.maxlen < len(line) {
|
||||
pg.maxlen = len(line)
|
||||
}
|
||||
pg.lastLine = line
|
||||
return g.addLineSlow(line, isFlush)
|
||||
}
|
||||
|
||||
g.addLinePaged(line, isFlush)
|
||||
|
||||
return ^Escape
|
||||
}
|
||||
|
||||
// addLineSlow shows one discovery line as a message (the slow-inventory
|
||||
// arm of things.c add_line).
|
||||
func (g *RogueGame) addLineSlow(line string, isFlush bool) int {
|
||||
if !isFlush && line != "" {
|
||||
if g.msg("%s", line) == Escape {
|
||||
return Escape
|
||||
}
|
||||
}
|
||||
|
||||
g.invPage.lineCnt++
|
||||
|
||||
return ^Escape
|
||||
}
|
||||
|
||||
// addLinePaged accumulates discovery lines into the paged window,
|
||||
// prompting between full pages (the windowed arm of things.c add_line).
|
||||
func (g *RogueGame) addLinePaged(line string, isFlush bool) {
|
||||
pg := &g.invPage
|
||||
prompt := "--Press space to continue--"
|
||||
|
||||
if !pg.init {
|
||||
pg.maxlen = len(prompt)
|
||||
pg.init = true
|
||||
}
|
||||
|
||||
if pg.lineCnt >= NumLines-1 || isFlush {
|
||||
g.addLinePageBreak(prompt, isFlush)
|
||||
}
|
||||
|
||||
if !isFlush && (pg.lineCnt != 0 || line != "") {
|
||||
g.scr.Hw.MvAddStr(pg.lineCnt, 0, line)
|
||||
|
||||
pg.lineCnt++
|
||||
if pg.maxlen < len(line) {
|
||||
pg.maxlen = len(line)
|
||||
}
|
||||
|
||||
pg.lastLine = line
|
||||
}
|
||||
}
|
||||
|
||||
// addLinePageBreak prompts at a full page and starts a fresh one
|
||||
// (things.c add_line).
|
||||
func (g *RogueGame) addLinePageBreak(prompt string, isFlush bool) {
|
||||
pg := &g.invPage
|
||||
if g.Options.InvType == InvOver && isFlush && !pg.newpage {
|
||||
g.addLineOverlay(prompt)
|
||||
} else {
|
||||
g.scr.Hw.MvAddStr(NumLines-1, 0, prompt)
|
||||
g.scr.RefreshWin(g.scr.Hw)
|
||||
g.waitFor(' ')
|
||||
g.scr.Hw.Clear()
|
||||
g.refresh()
|
||||
}
|
||||
|
||||
pg.newpage = true
|
||||
pg.lineCnt = 0
|
||||
pg.maxlen = len(prompt)
|
||||
}
|
||||
|
||||
// addLineOverlay draws the accumulated list in a box at the top right
|
||||
// of the screen, prompts, and restores what was beneath (things.c
|
||||
// add_line).
|
||||
func (g *RogueGame) addLineOverlay(prompt string) {
|
||||
pg := &g.invPage
|
||||
|
||||
g.msg("")
|
||||
g.refresh()
|
||||
|
||||
saved := NewWindow(NumLines, NumCols)
|
||||
saved.CopyFrom(g.scr.Std)
|
||||
|
||||
lx := NumCols - pg.maxlen - 2
|
||||
for y := 0; y <= pg.lineCnt; y++ {
|
||||
for x := 0; x <= pg.maxlen; x++ {
|
||||
g.scr.Std.MvAddCh(y, lx+x, g.scr.Hw.MvInch(y, x))
|
||||
}
|
||||
}
|
||||
|
||||
g.scr.Std.MvAddStr(pg.lineCnt, lx, prompt)
|
||||
g.refresh()
|
||||
g.waitFor(' ')
|
||||
g.scr.Std.CopyFrom(saved)
|
||||
g.refresh()
|
||||
}
|
||||
|
||||
// flushLine is add_line(NULL): force out the accumulated page.
|
||||
func (g *RogueGame) flushLine() int { return g.addLine(flushSentinel) }
|
||||
|
||||
@@ -447,6 +633,7 @@ func (g *RogueGame) endLine() {
|
||||
g.flushLine()
|
||||
}
|
||||
}
|
||||
|
||||
pg.lineCnt = 0
|
||||
pg.newpage = false
|
||||
}
|
||||
@@ -459,20 +646,24 @@ func (g *RogueGame) nothing(typ byte) string {
|
||||
} else {
|
||||
out = "Haven't discovered anything"
|
||||
}
|
||||
|
||||
if typ != '*' {
|
||||
var tystr string
|
||||
|
||||
switch typ {
|
||||
case Potion:
|
||||
tystr = "potion"
|
||||
tystr = potionName
|
||||
case Scroll:
|
||||
tystr = "scroll"
|
||||
tystr = scrollName
|
||||
case Ring:
|
||||
tystr = "ring"
|
||||
tystr = ringName
|
||||
case Stick:
|
||||
tystr = "stick"
|
||||
}
|
||||
|
||||
out += fmt.Sprintf(" about any %ss", tystr)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -480,20 +671,22 @@ func (g *RogueGame) nothing(typ byte) string {
|
||||
// (things.c nameit).
|
||||
func (g *RogueGame) nameit(pb *strings.Builder, obj *Object, typ, which string,
|
||||
op *ObjInfo, prfunc func(*RogueGame, *Object) string) {
|
||||
if op.Know || op.Guess != "" {
|
||||
switch {
|
||||
case op.Know || op.Guess != "":
|
||||
if obj.Count == 1 {
|
||||
fmt.Fprintf(pb, "A %s ", typ)
|
||||
} else {
|
||||
fmt.Fprintf(pb, "%d %ss ", obj.Count, typ)
|
||||
}
|
||||
|
||||
if op.Know {
|
||||
fmt.Fprintf(pb, "of %s%s(%s)", op.Name, prfunc(g, obj), which)
|
||||
} else {
|
||||
fmt.Fprintf(pb, "called %s%s(%s)", op.Guess, prfunc(g, obj), which)
|
||||
}
|
||||
} else if obj.Count == 1 {
|
||||
case obj.Count == 1:
|
||||
fmt.Fprintf(pb, "A%s %s %s", vowelstr(which), which, typ)
|
||||
} else {
|
||||
default:
|
||||
fmt.Fprintf(pb, "%d %s %ss", obj.Count, which, typ)
|
||||
}
|
||||
}
|
||||
@@ -505,13 +698,17 @@ func nullstr(*RogueGame, *Object) string { return "" }
|
||||
// pr_list).
|
||||
func (g *RogueGame) prList() {
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("for ")
|
||||
g.addmsgf("for ")
|
||||
}
|
||||
g.addmsg("what type")
|
||||
|
||||
g.addmsgf("what type")
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsg(" of object do you want a list")
|
||||
g.addmsgf(" of object do you want a list")
|
||||
}
|
||||
|
||||
g.msg("? ")
|
||||
|
||||
ch := g.readchar()
|
||||
switch ch {
|
||||
case Potion:
|
||||
@@ -533,14 +730,17 @@ func (g *RogueGame) prList() {
|
||||
// (things.c pr_spec).
|
||||
func (g *RogueGame) prSpec(info []ObjInfo) {
|
||||
lastprob := 0
|
||||
|
||||
i := byte('0')
|
||||
for idx := range info {
|
||||
if i == '9'+1 {
|
||||
i = 'a'
|
||||
}
|
||||
|
||||
g.addLine("%c: %s (%d%%)", i, info[idx].Name, info[idx].Prob-lastprob)
|
||||
lastprob = info[idx].Prob
|
||||
i++
|
||||
}
|
||||
|
||||
g.endLine()
|
||||
}
|
||||
|
||||
139
game/types.go
139
game/types.go
@@ -90,54 +90,68 @@ const (
|
||||
VsMagic = 3
|
||||
)
|
||||
|
||||
// Flags for rooms (rogue.h)
|
||||
// RoomFlags are the room state bits (rogue.h room flags).
|
||||
type RoomFlags int16
|
||||
|
||||
// Room state bits (rogue.h ISDARK/ISGONE/ISMAZE).
|
||||
const (
|
||||
Dark RoomFlags = 1 << iota // room is dark
|
||||
Gone // room is gone (a corridor)
|
||||
Maze // room is a maze
|
||||
)
|
||||
|
||||
func (f RoomFlags) Has(b RoomFlags) bool { return f&b != 0 }
|
||||
func (f *RoomFlags) Set(b RoomFlags) { *f |= b }
|
||||
func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b }
|
||||
// Has reports whether any of the given bits are set.
|
||||
func (f *RoomFlags) Has(b RoomFlags) bool { return *f&b != 0 }
|
||||
|
||||
// Flags for objects (rogue.h)
|
||||
// Set turns the given bits on.
|
||||
func (f *RoomFlags) Set(b RoomFlags) { *f |= b }
|
||||
|
||||
// Clear turns the given bits off.
|
||||
func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b }
|
||||
|
||||
// ObjFlags are the object state bits (rogue.h object flags).
|
||||
type ObjFlags int32
|
||||
|
||||
// Object state bits (rogue.h).
|
||||
const (
|
||||
Cursed ObjFlags = 1 << iota // ISCURSED: object is cursed
|
||||
Known // ISKNOW: player knows details about the object
|
||||
Missile // ISMISL: object is a missile type
|
||||
Stackable // ISMANY: object comes in groups
|
||||
WasFound // ISFOUND (objects): object has been seen (ISFOUND shares the bit with creatures)
|
||||
WasFound // ISFOUND (objects): seen; bit shared with creatures
|
||||
Protected // ISPROT: armor is permanently protected
|
||||
)
|
||||
|
||||
func (f ObjFlags) Has(b ObjFlags) bool { return f&b != 0 }
|
||||
func (f *ObjFlags) Set(b ObjFlags) { *f |= b }
|
||||
func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b }
|
||||
// Has reports whether any of the given bits are set.
|
||||
func (f *ObjFlags) Has(b ObjFlags) bool { return *f&b != 0 }
|
||||
|
||||
// Flags for creatures (rogue.h). The C bit collisions are deliberate and
|
||||
// preserved: one name of each pair applies to monsters, the other to the
|
||||
// hero, and they never coexist on one creature.
|
||||
// Set turns the given bits on.
|
||||
func (f *ObjFlags) Set(b ObjFlags) { *f |= b }
|
||||
|
||||
// Clear turns the given bits off.
|
||||
func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b }
|
||||
|
||||
// CreatureFlags are the creature state bits (rogue.h creature flags). The
|
||||
// C bit collisions are deliberate and preserved: one name of each pair
|
||||
// applies to monsters, the other to the hero, and they never coexist on
|
||||
// one creature.
|
||||
type CreatureFlags int32
|
||||
|
||||
// Creature state bits (rogue.h).
|
||||
const (
|
||||
CanConfuse CreatureFlags = 0o000001 // CANHUH: creature can confuse
|
||||
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: creature can see invisible creatures
|
||||
CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: can see invisible creatures
|
||||
Blind CreatureFlags = 0o000004 // ISBLIND: creature is blind
|
||||
Cancelled CreatureFlags = 0o000010 // ISCANC: creature has special qualities cancelled
|
||||
Cancelled CreatureFlags = 0o000010 // ISCANC: special qualities cancelled
|
||||
Levitating CreatureFlags = 0o000010 // ISLEVIT: hero is levitating
|
||||
Found CreatureFlags = 0o000020 // ISFOUND: creature has been seen
|
||||
Greedy CreatureFlags = 0o000040 // ISGREED: creature runs to protect gold
|
||||
Hasted CreatureFlags = 0o000100 // ISHASTE: creature has been hastened
|
||||
Targeted CreatureFlags = 0o000200 // ISTARGET: creature is the target of an 'f' command
|
||||
Targeted CreatureFlags = 0o000200 // ISTARGET: target of an 'f' command
|
||||
Held CreatureFlags = 0o000400 // ISHELD: creature has been held
|
||||
Confused CreatureFlags = 0o001000 // ISHUH: creature is confused
|
||||
Invisible CreatureFlags = 0o002000 // ISINVIS: creature is invisible
|
||||
Mean CreatureFlags = 0o004000 // ISMEAN: creature can wake when player enters room
|
||||
Mean CreatureFlags = 0o004000 // ISMEAN: wakes when player enters room
|
||||
Hallucinating CreatureFlags = 0o004000 // ISHALU: hero is on acid trip
|
||||
Regenerates CreatureFlags = 0o010000 // ISREGEN: creature can regenerate
|
||||
Awake CreatureFlags = 0o020000 // ISRUN: creature is running at the player
|
||||
@@ -146,13 +160,20 @@ const (
|
||||
Slowed CreatureFlags = 0o100000 // ISSLOW: creature has been slowed
|
||||
)
|
||||
|
||||
func (f CreatureFlags) Has(b CreatureFlags) bool { return f&b != 0 }
|
||||
func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b }
|
||||
func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b }
|
||||
// Has reports whether any of the given bits are set.
|
||||
func (f *CreatureFlags) Has(b CreatureFlags) bool { return *f&b != 0 }
|
||||
|
||||
// Flags for the level map (rogue.h)
|
||||
// Set turns the given bits on.
|
||||
func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b }
|
||||
|
||||
// Clear turns the given bits off.
|
||||
func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b }
|
||||
|
||||
// PlaceFlags are the per-map-cell bits (rogue.h level map flags). The low
|
||||
// bits double as the passage number (FPassNum) or trap kind (FTrapMask).
|
||||
type PlaceFlags uint8
|
||||
|
||||
// Map cell bits (rogue.h).
|
||||
const (
|
||||
FPassage PlaceFlags = 0x80 // F_PASS: is a passageway
|
||||
FSeen PlaceFlags = 0x40 // have seen this spot before
|
||||
@@ -163,14 +184,20 @@ const (
|
||||
FTrapMask PlaceFlags = 0x07 // F_TMASK: trap number mask
|
||||
)
|
||||
|
||||
func (f PlaceFlags) Has(b PlaceFlags) bool { return f&b != 0 }
|
||||
func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b }
|
||||
func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b }
|
||||
// Has reports whether any of the given bits are set.
|
||||
func (f *PlaceFlags) Has(b PlaceFlags) bool { return *f&b != 0 }
|
||||
|
||||
// Set turns the given bits on.
|
||||
func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b }
|
||||
|
||||
// Clear turns the given bits off.
|
||||
func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b }
|
||||
|
||||
// TrapKind identifies a trap (rogue.h trap types). The kind is stored in
|
||||
// the low bits of a map cell's PlaceFlags (FTrapMask).
|
||||
type TrapKind int
|
||||
|
||||
// Trap kinds (rogue.h T_* constants).
|
||||
const (
|
||||
TrapDoor TrapKind = 0
|
||||
TrapArrow TrapKind = 1
|
||||
@@ -183,15 +210,6 @@ const (
|
||||
NumTrapTypes = 8
|
||||
)
|
||||
|
||||
// String returns the trap's display name, article included, as the C
|
||||
// tr_name table had it.
|
||||
func (t TrapKind) String() string {
|
||||
if t < 0 || t >= NumTrapTypes {
|
||||
return "a bizarre trap"
|
||||
}
|
||||
return trName[t]
|
||||
}
|
||||
|
||||
// PotionKind identifies a potion (rogue.h potion types).
|
||||
type PotionKind int
|
||||
|
||||
@@ -214,14 +232,6 @@ const (
|
||||
NumPotionTypes
|
||||
)
|
||||
|
||||
// String returns the potion's true name ("healing", "haste self", ...).
|
||||
func (p PotionKind) String() string {
|
||||
if p < 0 || p >= NumPotionTypes {
|
||||
return "strange potion"
|
||||
}
|
||||
return basePotInfo[p].Name
|
||||
}
|
||||
|
||||
// ScrollKind identifies a scroll (rogue.h scroll types).
|
||||
type ScrollKind int
|
||||
|
||||
@@ -248,14 +258,6 @@ const (
|
||||
NumScrollTypes
|
||||
)
|
||||
|
||||
// String returns the scroll's true name ("magic mapping", ...).
|
||||
func (s ScrollKind) String() string {
|
||||
if s < 0 || s >= NumScrollTypes {
|
||||
return "strange scroll"
|
||||
}
|
||||
return baseScrInfo[s].Name
|
||||
}
|
||||
|
||||
// WeaponKind identifies a weapon (rogue.h weapon types).
|
||||
type WeaponKind int
|
||||
|
||||
@@ -270,17 +272,12 @@ const (
|
||||
WeaponDart
|
||||
WeaponShuriken
|
||||
WeaponSpear
|
||||
WeaponFlame // fake entry for dragon breath (ick)
|
||||
NumWeaponTypes = WeaponFlame
|
||||
WeaponFlame // fake entry for dragon breath (ick)
|
||||
)
|
||||
|
||||
// String returns the weapon's name ("mace", "two handed sword", ...).
|
||||
func (w WeaponKind) String() string {
|
||||
if w < 0 || w > WeaponFlame {
|
||||
return "strange weapon"
|
||||
}
|
||||
return baseWeapInfo[w].Name
|
||||
}
|
||||
// NumWeaponTypes counts the real weapons; the flame pseudo-weapon sits
|
||||
// just past them in the tables (C's MAXWEAPONS == FLAME).
|
||||
const NumWeaponTypes = WeaponFlame
|
||||
|
||||
// ArmorKind identifies a suit of armor (rogue.h armor types).
|
||||
type ArmorKind int
|
||||
@@ -298,14 +295,6 @@ const (
|
||||
NumArmorTypes
|
||||
)
|
||||
|
||||
// String returns the armor's name ("ring mail", "plate mail", ...).
|
||||
func (a ArmorKind) String() string {
|
||||
if a < 0 || a >= NumArmorTypes {
|
||||
return "strange armor"
|
||||
}
|
||||
return baseArmInfo[a].Name
|
||||
}
|
||||
|
||||
// RingKind identifies a ring (rogue.h ring types).
|
||||
type RingKind int
|
||||
|
||||
@@ -328,14 +317,6 @@ const (
|
||||
NumRingTypes
|
||||
)
|
||||
|
||||
// String returns the ring's true name ("add strength", "stealth", ...).
|
||||
func (r RingKind) String() string {
|
||||
if r < 0 || r >= NumRingTypes {
|
||||
return "strange ring"
|
||||
}
|
||||
return baseRingInfo[r].Name
|
||||
}
|
||||
|
||||
// WandKind identifies a wand or staff (rogue.h rod/wand/staff types).
|
||||
type WandKind int
|
||||
|
||||
@@ -358,14 +339,6 @@ const (
|
||||
NumWandTypes
|
||||
)
|
||||
|
||||
// String returns the wand/staff's true name ("lightning", ...).
|
||||
func (w WandKind) String() string {
|
||||
if w < 0 || w >= NumWandTypes {
|
||||
return "strange stick"
|
||||
}
|
||||
return baseWsInfo[w].Name
|
||||
}
|
||||
|
||||
// Coord is a position on the level (rogue.h coord). A value type: the C
|
||||
// ce(a,b) macro is plain == here.
|
||||
type Coord struct {
|
||||
@@ -419,12 +392,13 @@ type Stone struct {
|
||||
}
|
||||
|
||||
// CTRL maps a letter to its control character, as the C CTRL() macro.
|
||||
func CTRL(c byte) byte { return c & 0o37 }
|
||||
func CTRL(c byte) byte { return c & 0o37 } //nolint:mnd // the C CTRL() mask
|
||||
|
||||
// distance returns the squared distance between two points (chase.c dist).
|
||||
func distance(y1, x1, y2, x2 int) int {
|
||||
dx := x2 - x1
|
||||
dy := y2 - y1
|
||||
|
||||
return dx*dx + dy*dy
|
||||
}
|
||||
|
||||
@@ -439,5 +413,6 @@ func sign(nm int) int {
|
||||
case nm > 0:
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
127
game/weapons.go
127
game/weapons.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import "fmt"
|
||||
@@ -9,13 +10,15 @@ const noWeapon WeaponKind = -1
|
||||
// missile fires a missile in a given direction (weapons.c missile).
|
||||
func (g *RogueGame) missile(ydelta, xdelta int) {
|
||||
// Get which thing we are hurling
|
||||
obj := g.getItem("throw", KindWeapon)
|
||||
if obj == nil {
|
||||
obj, ok := g.promptPackItem("throw", KindWeapon)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !g.dropCheck(obj) || g.isCurrent(obj) {
|
||||
return
|
||||
}
|
||||
|
||||
obj = g.leavePack(obj, true, false)
|
||||
g.doMotion(obj, ydelta, xdelta)
|
||||
// AHA! Here it has hit something. If it is a wall or a door, or if
|
||||
@@ -33,51 +36,64 @@ func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) {
|
||||
// Come fly with us ...
|
||||
obj.Pos = p.Pos
|
||||
for {
|
||||
// Erase the old one
|
||||
if obj.Pos != p.Pos && g.cansee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
|
||||
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
|
||||
if ch == Floor && !g.showFloor() {
|
||||
ch = ' '
|
||||
}
|
||||
g.mvaddch(obj.Pos.Y, obj.Pos.X, ch)
|
||||
}
|
||||
g.eraseFlight(obj, p.Pos)
|
||||
// Get the new position
|
||||
obj.Pos.Y += ydelta
|
||||
obj.Pos.X += xdelta
|
||||
|
||||
ch := g.Level.VisibleChar(obj.Pos.Y, obj.Pos.X)
|
||||
if stepOk(ch) && ch != Door {
|
||||
// It hasn't hit anything yet, so display it if it's alright.
|
||||
if g.cansee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
|
||||
g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
|
||||
g.refresh()
|
||||
}
|
||||
continue
|
||||
if !stepOk(ch) || ch == Door {
|
||||
break
|
||||
}
|
||||
// It hasn't hit anything yet, so display it if it's alright.
|
||||
if g.canSee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse {
|
||||
g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph())
|
||||
g.refresh()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// eraseFlight erases a flying object from its current square, unless it
|
||||
// still sits on the hero (the erase step of weapons.c do_motion).
|
||||
func (g *RogueGame) eraseFlight(obj *Object, heroPos Coord) {
|
||||
if obj.Pos == heroPos || !g.canSee(obj.Pos.Y, obj.Pos.X) || g.Options.Terse {
|
||||
return
|
||||
}
|
||||
|
||||
ch := g.Level.Char(obj.Pos.Y, obj.Pos.X)
|
||||
if ch == Floor && !g.showFloor() {
|
||||
ch = ' '
|
||||
}
|
||||
|
||||
g.mvaddch(obj.Pos.Y, obj.Pos.X, ch)
|
||||
}
|
||||
|
||||
// fall drops an item someplace around here (weapons.c fall).
|
||||
func (g *RogueGame) fall(obj *Object, pr bool) {
|
||||
if fpos, ok := g.fallpos(obj.Pos); ok {
|
||||
pp := g.Level.At(fpos.Y, fpos.X)
|
||||
pp.Ch = obj.Kind.Glyph()
|
||||
|
||||
obj.Pos = fpos
|
||||
if g.cansee(fpos.Y, fpos.X) {
|
||||
if g.canSee(fpos.Y, fpos.X) {
|
||||
if pp.Monst != nil {
|
||||
pp.Monst.OldCh = obj.Kind.Glyph()
|
||||
} else {
|
||||
g.mvaddch(fpos.Y, fpos.X, obj.Kind.Glyph())
|
||||
}
|
||||
}
|
||||
attachObj(&g.Level.Objects, obj)
|
||||
|
||||
g.Level.AddObject(obj)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if pr {
|
||||
if g.HasHit {
|
||||
g.endmsg()
|
||||
g.HasHit = false
|
||||
}
|
||||
|
||||
g.msg("the %s vanishes as it hits the ground",
|
||||
g.Items.Weapons[obj.Which].Name)
|
||||
}
|
||||
@@ -92,56 +108,67 @@ func (g *RogueGame) hitMonster(mp Coord, obj *Object) bool {
|
||||
// wield pulls out a certain weapon (weapons.c wield).
|
||||
func (g *RogueGame) wield() {
|
||||
p := &g.Player
|
||||
|
||||
oweapon := p.CurWeapon
|
||||
if !g.dropCheck(p.CurWeapon) {
|
||||
p.CurWeapon = oweapon
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.CurWeapon = oweapon
|
||||
obj := g.getItem("wield", KindWeapon)
|
||||
if obj == nil {
|
||||
|
||||
obj, ok := g.promptPackItem("wield", KindWeapon)
|
||||
if !ok {
|
||||
g.After = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if obj.Kind == KindArmor {
|
||||
g.msg("you can't wield armor")
|
||||
g.After = false
|
||||
return
|
||||
}
|
||||
if g.isCurrent(obj) {
|
||||
g.After = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sp := g.invName(obj, true)
|
||||
p.CurWeapon = obj
|
||||
if !g.Options.Terse {
|
||||
g.addmsg("you are now ")
|
||||
if g.isCurrent(obj) {
|
||||
g.After = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sp := g.inventoryName(obj, true)
|
||||
p.CurWeapon = obj
|
||||
|
||||
if !g.Options.Terse {
|
||||
g.addmsgf("you are now ")
|
||||
}
|
||||
|
||||
g.msg("wielding %s (%c)", sp, obj.PackCh)
|
||||
}
|
||||
|
||||
// initWeaps is the weapons.c init_dam[] table.
|
||||
var initWeaps = [NumWeaponTypes]struct {
|
||||
// weaponSetup is one row of the weapons.c init_dam[] table (see
|
||||
// gameData.initWeaps).
|
||||
type weaponSetup struct {
|
||||
dam DiceSpec // damage when wielded
|
||||
hrl DiceSpec // damage when thrown
|
||||
launch WeaponKind // launching weapon
|
||||
flags ObjFlags
|
||||
}{
|
||||
{dice("2x4"), dice("1x3"), noWeapon, 0}, // WeaponMace
|
||||
{dice("3x4"), dice("1x2"), noWeapon, 0}, // Long sword
|
||||
{dice("1x1"), dice("1x1"), noWeapon, 0}, // WeaponBow
|
||||
{dice("1x1"), dice("2x3"), WeaponBow, Stackable | Missile}, // WeaponArrow
|
||||
{dice("1x6"), dice("1x4"), noWeapon, Missile}, // WeaponDagger
|
||||
{dice("4x4"), dice("1x2"), noWeapon, 0}, // 2h sword
|
||||
{dice("1x1"), dice("1x3"), noWeapon, Stackable | Missile}, // WeaponDart
|
||||
{dice("1x2"), dice("2x4"), noWeapon, Stackable | Missile}, // Shuriken
|
||||
{dice("2x3"), dice("1x6"), noWeapon, Missile}, // WeaponSpear
|
||||
}
|
||||
|
||||
// initWeapon sets up a new weapon (weapons.c init_weapon).
|
||||
func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) {
|
||||
iwp := &initWeaps[which]
|
||||
// init_dam[] has a row only for the real weapons: WeaponFlame (dragon
|
||||
// breath) and anything past it have none. createObj rejects such a
|
||||
// choice before calling here, so this arm is unreachable in practice;
|
||||
// it exists so a malformed kind leaves the weapon untouched instead of
|
||||
// panicking on the table read.
|
||||
if which < 0 || int(which) >= int(NumWeaponTypes) {
|
||||
return
|
||||
}
|
||||
|
||||
iwp := &g.data.initWeaps[which]
|
||||
weap.Kind = KindWeapon
|
||||
weap.Which = int(which)
|
||||
weap.Damage = iwp.dam
|
||||
@@ -149,16 +176,19 @@ func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) {
|
||||
weap.Launch = iwp.launch
|
||||
weap.Flags = iwp.flags
|
||||
weap.HPlus = 0
|
||||
|
||||
weap.DPlus = 0
|
||||
if which == WeaponDagger {
|
||||
|
||||
switch {
|
||||
case which == WeaponDagger:
|
||||
weap.Count = g.rnd(4) + 2
|
||||
weap.Group = g.Items.Group
|
||||
g.Items.Group++
|
||||
} else if weap.Flags.Has(Stackable) {
|
||||
case weap.Flags.Has(Stackable):
|
||||
weap.Count = g.rnd(8) + 8
|
||||
weap.Group = g.Items.Group
|
||||
g.Items.Group++
|
||||
} else {
|
||||
default:
|
||||
weap.Count = 1
|
||||
weap.Group = 0
|
||||
}
|
||||
@@ -170,6 +200,7 @@ func num(n1, n2 int, typ byte) string {
|
||||
if typ == Weapon {
|
||||
out += fmt.Sprintf(",%+d", n2)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -177,7 +208,9 @@ func num(n1, n2 int, typ byte) string {
|
||||
// (weapons.c fallpos).
|
||||
func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
|
||||
var newpos Coord
|
||||
|
||||
cnt := 0
|
||||
|
||||
for y := pos.Y - 1; y <= pos.Y+1; y++ {
|
||||
for x := pos.X - 1; x <= pos.X+1; x++ {
|
||||
// check to make certain the spot is empty, if it is, put the
|
||||
@@ -187,6 +220,7 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
|
||||
y < 0 || x < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
ch := g.Level.Char(y, x)
|
||||
if ch == Floor || ch == Passage {
|
||||
if cnt++; g.rnd(cnt) == 0 {
|
||||
@@ -196,5 +230,6 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newpos, cnt != 0
|
||||
}
|
||||
|
||||
209
game/wizard.go
209
game/wizard.go
@@ -1,3 +1,4 @@
|
||||
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
// wizard.c — special wizard commands, some of which are also non-wizard
|
||||
@@ -8,87 +9,136 @@ package game
|
||||
// create_obj).
|
||||
func (g *RogueGame) createObj() {
|
||||
obj := newObject()
|
||||
|
||||
g.msg("type of item: ")
|
||||
obj.Kind = objectKindForGlyph(g.readchar())
|
||||
g.Msgs.Mpos = 0
|
||||
g.msg("which %c do you want? (0-f)", obj.Kind.Glyph())
|
||||
|
||||
ch := g.readchar()
|
||||
if isDigit(ch) {
|
||||
obj.Which = int(ch - '0')
|
||||
} else {
|
||||
obj.Which = int(ch-'a') + 10
|
||||
}
|
||||
|
||||
obj.Group = 0
|
||||
obj.Count = 1
|
||||
g.Msgs.Mpos = 0
|
||||
switch {
|
||||
case obj.Kind == KindWeapon || obj.Kind == KindArmor:
|
||||
g.msg("blessing? (+,-,n)")
|
||||
bless := g.readchar()
|
||||
g.Msgs.Mpos = 0
|
||||
if bless == '-' {
|
||||
obj.Flags.Set(Cursed)
|
||||
}
|
||||
if obj.Kind == KindWeapon {
|
||||
g.initWeapon(obj, WeaponKind(obj.Which))
|
||||
if bless == '-' {
|
||||
obj.HPlus -= g.rnd(3) + 1
|
||||
}
|
||||
if bless == '+' {
|
||||
obj.HPlus += g.rnd(3) + 1
|
||||
}
|
||||
} else {
|
||||
obj.ArmorClass = aClass[obj.Which]
|
||||
if bless == '-' {
|
||||
obj.ArmorClass += g.rnd(3) + 1
|
||||
}
|
||||
if bless == '+' {
|
||||
obj.ArmorClass -= g.rnd(3) + 1
|
||||
}
|
||||
}
|
||||
case obj.Kind == KindRing:
|
||||
switch obj.RingKind() {
|
||||
case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage:
|
||||
g.msg("blessing? (+,-,n)")
|
||||
bless := g.readchar()
|
||||
g.Msgs.Mpos = 0
|
||||
if bless == '-' {
|
||||
obj.Flags.Set(Cursed)
|
||||
obj.Bonus = -1
|
||||
} else {
|
||||
obj.Bonus = g.rnd(2) + 1
|
||||
}
|
||||
case RingAggravateMonsters, RingTeleportation:
|
||||
obj.Flags.Set(Cursed)
|
||||
}
|
||||
case obj.Kind == KindWand:
|
||||
|
||||
// Deliberate divergence from 5.4.4: C stored this nibble unchecked, so
|
||||
// 'a'-'f' indexed straight past the ends of the per-kind static
|
||||
// tables. Input outside '0'-'9' and 'a'-'f' overshoots much further:
|
||||
// readchar returns a byte, so ch-'a' above is byte arithmetic and
|
||||
// wraps instead of going negative ('A' gives 234, '!' gives 202).
|
||||
// Reading past a static array was undefined behavior C happened to
|
||||
// survive by picking up adjacent memory; in Go it is a panic that
|
||||
// kills the process with the terminal still in raw mode. C had no
|
||||
// defined behavior here to be faithful to, so the choice is rejected
|
||||
// outright rather than emulating a garbage read. The check precedes
|
||||
// every rnd() call below, so the RNG sequence is untouched either way.
|
||||
if !obj.wizardCanCreate() {
|
||||
g.msg("there is no such %s", obj.Kind)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindWeapon, KindArmor:
|
||||
g.createWeaponArmor(obj)
|
||||
case KindRing:
|
||||
g.createRing(obj)
|
||||
case KindWand:
|
||||
g.fixStick(obj)
|
||||
case obj.Kind == KindGold:
|
||||
case KindGold:
|
||||
g.msg("how much?")
|
||||
|
||||
buf := ""
|
||||
if g.getStr(&buf, g.scr.Std) == Norm {
|
||||
obj.GoldValue = cAtoi(buf)
|
||||
}
|
||||
}
|
||||
|
||||
g.addPack(obj, false)
|
||||
}
|
||||
|
||||
// createWeaponArmor sets up a wizard-created weapon or armor with an
|
||||
// optional blessing (the weapon/armor arm of wizard.c create_obj).
|
||||
func (g *RogueGame) createWeaponArmor(obj *Object) {
|
||||
g.msg("blessing? (+,-,n)")
|
||||
bless := g.readchar()
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
if bless == '-' {
|
||||
obj.Flags.Set(Cursed)
|
||||
}
|
||||
|
||||
if obj.Kind == KindWeapon {
|
||||
g.initWeapon(obj, WeaponKind(obj.Which))
|
||||
|
||||
if bless == '-' {
|
||||
obj.HPlus -= g.rnd(3) + 1
|
||||
}
|
||||
|
||||
if bless == '+' {
|
||||
obj.HPlus += g.rnd(3) + 1
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
obj.ArmorClass = g.data.armorClass(obj.Which)
|
||||
if bless == '-' {
|
||||
obj.ArmorClass += g.rnd(3) + 1
|
||||
}
|
||||
|
||||
if bless == '+' {
|
||||
obj.ArmorClass -= g.rnd(3) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// createRing sets up a wizard-created ring, prompting for a bonus on
|
||||
// the bonus rings (the ring arm of wizard.c create_obj).
|
||||
func (g *RogueGame) createRing(obj *Object) {
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.RingKind() {
|
||||
case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage:
|
||||
g.msg("blessing? (+,-,n)")
|
||||
bless := g.readchar()
|
||||
g.Msgs.Mpos = 0
|
||||
|
||||
if bless == '-' {
|
||||
obj.Flags.Set(Cursed)
|
||||
obj.Bonus = -1
|
||||
} else {
|
||||
obj.Bonus = g.rnd(2) + 1
|
||||
}
|
||||
case RingAggravateMonsters, RingTeleportation:
|
||||
obj.Flags.Set(Cursed)
|
||||
}
|
||||
}
|
||||
|
||||
// showMap prints out the whole map for the wizard (wizard.c show_map).
|
||||
func (g *RogueGame) showMap() {
|
||||
hw := g.scr.Hw
|
||||
hw.Clear()
|
||||
|
||||
for y := 1; y < NumLines-1; y++ {
|
||||
for x := 0; x < NumCols; x++ {
|
||||
real := g.Level.FlagsAt(y, x).Has(FReal)
|
||||
if !real {
|
||||
for x := range NumCols {
|
||||
isReal := g.Level.FlagsAt(y, x).Has(FReal)
|
||||
if !isReal {
|
||||
hw.Standout(true)
|
||||
}
|
||||
|
||||
hw.MvAddCh(y, x, g.Level.Char(y, x))
|
||||
if !real {
|
||||
|
||||
if !isReal {
|
||||
hw.Standout(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.showWin("---More (level map)---")
|
||||
}
|
||||
|
||||
@@ -97,33 +147,16 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) {
|
||||
p := &g.Player
|
||||
if len(p.Pack) == 0 {
|
||||
g.msg("you don't have anything in your pack to identify")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var obj *Object
|
||||
for {
|
||||
obj = g.getItem("identify", kind)
|
||||
if !insist {
|
||||
break
|
||||
}
|
||||
if g.NObjs == 0 {
|
||||
return
|
||||
}
|
||||
if obj == nil {
|
||||
g.msg("you must identify something")
|
||||
} else if kind != KindNone && obj.Kind != kind &&
|
||||
!(kind == KindRingOrStick &&
|
||||
(obj.Kind == KindRing || obj.Kind == KindWand)) {
|
||||
g.msg("you must identify a %s", kind)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if obj == nil {
|
||||
obj, ok := g.whatisPick(insist, kind)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
||||
switch obj.Kind {
|
||||
case KindScroll:
|
||||
setKnow(obj, g.Items.Scrolls[:])
|
||||
@@ -136,7 +169,39 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) {
|
||||
case KindRing:
|
||||
setKnow(obj, g.Items.Rings[:])
|
||||
}
|
||||
g.msg("%s", g.invName(obj, false))
|
||||
|
||||
g.msg("%s", g.inventoryName(obj, false))
|
||||
}
|
||||
|
||||
// whatisPick prompts for the item to identify, re-asking until a
|
||||
// matching one is chosen when insist is set; ok is false when the
|
||||
// player gives up (the prompt loop of wizard.c whatis).
|
||||
func (g *RogueGame) whatisPick(insist bool, kind ObjectKind) (*Object, bool) {
|
||||
for {
|
||||
obj, _ := g.promptPackItem("identify", kind)
|
||||
|
||||
if !insist {
|
||||
return obj, obj != nil
|
||||
}
|
||||
|
||||
if g.NObjs == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if obj == nil {
|
||||
g.msg("you must identify something")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !matchesFilter(kind, obj) {
|
||||
g.msg("you must identify a %s", kind)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return obj, true
|
||||
}
|
||||
}
|
||||
|
||||
// setKnow sets things up when we really know what a thing is (wizard.c
|
||||
@@ -154,15 +219,18 @@ func setKnow(obj *Object, info []ObjInfo) {
|
||||
func (g *RogueGame) teleport() {
|
||||
p := &g.Player
|
||||
g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt())
|
||||
c, _ := g.findFloor(nil, 0, true)
|
||||
if g.roomin(c) != p.Room {
|
||||
|
||||
c, _ := g.findFloor(true)
|
||||
if g.roomIn(c) != p.Room {
|
||||
g.leaveRoom(p.Pos)
|
||||
p.Pos = c
|
||||
g.enterRoom(p.Pos)
|
||||
} else {
|
||||
p.Pos = c
|
||||
|
||||
g.look(true)
|
||||
}
|
||||
|
||||
g.mvaddch(p.Pos.Y, p.Pos.X, PlayerCh)
|
||||
// turn off ISHELD in case teleportation was done while fighting a
|
||||
// Flytrap
|
||||
@@ -171,6 +239,7 @@ func (g *RogueGame) teleport() {
|
||||
p.VfHit = 0
|
||||
g.Monsters['F'-'A'].Stats.Dmg = dice("000x0")
|
||||
}
|
||||
|
||||
g.NoMove = 0
|
||||
g.Count = 0
|
||||
g.Running = false
|
||||
|
||||
532
game/wizard_test.go
Normal file
532
game/wizard_test.go
Normal file
@@ -0,0 +1,532 @@
|
||||
//nolint:testpackage // white-box tests reach unexported state (approved 2026-07-07)
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mustNotPanic runs fn and turns a panic into an ordinary test failure.
|
||||
// The bug these tests cover (issue #10) panicked out of an array index,
|
||||
// and an unrecovered panic would take the whole test binary down instead
|
||||
// of reporting which dispatch regressed.
|
||||
func mustNotPanic(t *testing.T, what string, fn func()) {
|
||||
t.Helper()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("%s panicked: %v", what, r)
|
||||
}
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
// TestCreateObjWandFReproducer is the exact reported crash: wizard mode,
|
||||
// C, '/' for a wand, 'f' for which. 'f' is nibble 15 and there are only
|
||||
// NumWandTypes (14) wands, so fixStick used to index two past the end of
|
||||
// ws_type[] and panic.
|
||||
func TestCreateObjWandFReproducer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
g.Wizard = true
|
||||
before := len(g.Player.Pack)
|
||||
|
||||
setInput(t, g, '/', 'f')
|
||||
|
||||
mustNotPanic(t, "createObj with wand 'f'", g.createObj)
|
||||
|
||||
if len(g.Player.Pack) != before {
|
||||
t.Errorf("out-of-range wand was added to the pack: %d items, want %d",
|
||||
len(g.Player.Pack), before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateObjRejectsOutOfRangeWhich sweeps the rejection across every
|
||||
// kind whose Which is a table index, including input outside '0'-'f'.
|
||||
// isDigit is false for such input, so it takes the letter branch, where
|
||||
// ch-'a' is byte arithmetic and wraps rather than going negative: 'A'
|
||||
// (65) gives int(224)+10 == 234 and '!' (33) gives int(192)+10 == 202.
|
||||
// Those far-past-the-end values, not negative ones, are what the guard
|
||||
// has to catch on the keyboard path.
|
||||
func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
typ byte
|
||||
which byte
|
||||
}{
|
||||
{"wand f is past NumWandTypes", Stick, 'f'},
|
||||
{"potion f is past NumPotionTypes", Potion, 'f'},
|
||||
{"ring f is past NumRingTypes", Ring, 'f'},
|
||||
// NumScrollTypes is 18, past the 'f' the prompt tops out at, so a
|
||||
// scroll can only be driven out of range by input that wraps.
|
||||
{"scroll 'A' wraps to 234", Scroll, 'A'},
|
||||
{"armor 9 is past NumArmorTypes", Armor, '9'},
|
||||
{"weapon 9 is the flame pseudo-weapon", Weapon, '9'},
|
||||
{"wand 'A' wraps to 234", Stick, 'A'},
|
||||
{"wand '!' wraps to 202", Stick, '!'},
|
||||
{"armor 'A' wraps to 234", Armor, 'A'},
|
||||
{"weapon '!' wraps to 202", Weapon, '!'},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
g.Wizard = true
|
||||
before := len(g.Player.Pack)
|
||||
|
||||
setInput(t, g, tc.typ, tc.which)
|
||||
|
||||
mustNotPanic(t, tc.name, g.createObj)
|
||||
|
||||
if len(g.Player.Pack) != before {
|
||||
t.Errorf("pack grew to %d items, want %d: a rejected item was created",
|
||||
len(g.Player.Pack), before)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateObjAcceptsValidWhich pins the other half of the contract: the
|
||||
// bounds check must not touch any in-range choice.
|
||||
func TestCreateObjAcceptsValidWhich(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
typ byte
|
||||
which byte
|
||||
kind ObjectKind
|
||||
want int
|
||||
}{
|
||||
{"wand of light", Stick, '0', KindWand, int(WandLight)},
|
||||
{"potion 0", Potion, '0', KindPotion, int(PotionConfusion)},
|
||||
{"scroll 9", Scroll, '9', KindScroll, int(ScrollIdentifyRingOrStick)},
|
||||
{"ring d, the last ring", Ring, 'd', KindRing, int(NumRingTypes) - 1},
|
||||
{"armor 7, the last armor", Armor, '7', KindArmor, int(NumArmorTypes) - 1},
|
||||
{"weapon 8, the last real weapon", Weapon, '8', KindWeapon, int(WeaponSpear)},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
g.Wizard = true
|
||||
|
||||
before := make(map[*Object]bool, len(g.Player.Pack))
|
||||
for _, o := range g.Player.Pack {
|
||||
before[o] = true
|
||||
}
|
||||
|
||||
// The armor and weapon arms read one more character for the
|
||||
// blessing prompt; 'n' means neither cursed nor blessed.
|
||||
setInput(t, g, tc.typ, tc.which, 'n')
|
||||
g.createObj()
|
||||
|
||||
if len(g.Player.Pack) != len(before)+1 {
|
||||
t.Fatalf("pack has %d items, want %d: valid item not created",
|
||||
len(g.Player.Pack), len(before)+1)
|
||||
}
|
||||
|
||||
// addPack files the new item in kind order, so find it by
|
||||
// identity rather than assuming it landed at the end.
|
||||
var made *Object
|
||||
|
||||
for _, o := range g.Player.Pack {
|
||||
if !before[o] {
|
||||
made = o
|
||||
}
|
||||
}
|
||||
|
||||
if made.Kind != tc.kind || made.Which != tc.want {
|
||||
t.Errorf("created %v which %d, want %v which %d",
|
||||
made.Kind, made.Which, tc.kind, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateObjKeepsRNGSequence proves the guard costs no RNG draws: a
|
||||
// rejected creation must leave the generator exactly where it was, or
|
||||
// every later roll in the game would shift.
|
||||
func TestCreateObjKeepsRNGSequence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
g.Wizard = true
|
||||
before := g.Rng.Seed
|
||||
|
||||
setInput(t, g, Stick, 'f')
|
||||
g.createObj()
|
||||
|
||||
if g.Rng.Seed != before {
|
||||
t.Errorf("rejected creation consumed RNG: seed %d, want %d",
|
||||
g.Rng.Seed, before)
|
||||
}
|
||||
}
|
||||
|
||||
// malformed builds an object of the given kind whose Which sits one past
|
||||
// the end of that kind's table — the state the wizard bug used to leave
|
||||
// behind, and the state a corrupt save file could still describe.
|
||||
func malformed(kind ObjectKind) *Object {
|
||||
obj := newObject()
|
||||
obj.Kind = kind
|
||||
obj.Which = whichLimit(kind)
|
||||
obj.Count = 1
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// TestZapMalformedWandDoesNotPanic covers the sticks.go dispatch. C's
|
||||
// do_zap matched no case and still ran o_charges--, so the charge must be
|
||||
// spent even though the zap did nothing. What it says while doing nothing
|
||||
// belongs to TestZapUnhandledWandSaysBizarreSchtick.
|
||||
func TestZapMalformedWandDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
wand := malformed(KindWand)
|
||||
wand.Charges = 3
|
||||
ch := give(g, wand)
|
||||
|
||||
setInput(t, g, ch)
|
||||
|
||||
mustNotPanic(t, "doZap on a malformed wand", g.doZap)
|
||||
|
||||
if wand.Charges != 2 {
|
||||
t.Errorf("charges = %d after zapping, want 2", wand.Charges)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuaffMalformedPotionDoesNotPanic covers the potions.go dispatch and
|
||||
// the callIt lookup that follows it.
|
||||
func TestQuaffMalformedPotionDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
before := len(g.Player.Pack)
|
||||
ch := give(g, malformed(KindPotion))
|
||||
|
||||
setInput(t, g, ch)
|
||||
|
||||
mustNotPanic(t, "quaff of a malformed potion", g.quaff)
|
||||
|
||||
if len(g.Player.Pack) != before {
|
||||
t.Errorf("pack has %d items, want %d: the potion was not consumed",
|
||||
len(g.Player.Pack), before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadMalformedScrollDoesNotPanic covers the scrolls.go dispatch and
|
||||
// its callIt lookup.
|
||||
func TestReadMalformedScrollDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
before := len(g.Player.Pack)
|
||||
ch := give(g, malformed(KindScroll))
|
||||
|
||||
setInput(t, g, ch)
|
||||
|
||||
mustNotPanic(t, "readScroll of a malformed scroll", g.readScroll)
|
||||
|
||||
if len(g.Player.Pack) != before {
|
||||
t.Errorf("pack has %d items, want %d: the scroll was not consumed",
|
||||
len(g.Player.Pack), before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMalformedArmorDoesNotPanic covers the a_class[] reads: pricing at
|
||||
// death, the identified-armor name, and the detect-magic test.
|
||||
func TestMalformedArmorDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
armor := malformed(KindArmor)
|
||||
armor.Flags.Set(Known)
|
||||
|
||||
mustNotPanic(t, "naming a malformed suit of armor", func() {
|
||||
if got := g.inventoryName(armor, false); got != armor.Kind.String() {
|
||||
t.Errorf("inventoryName = %q, want %q", got, armor.Kind.String())
|
||||
}
|
||||
})
|
||||
|
||||
mustNotPanic(t, "isMagic on a malformed suit of armor", func() {
|
||||
g.isMagic(armor)
|
||||
})
|
||||
|
||||
mustNotPanic(t, "appraising a malformed suit of armor", func() {
|
||||
if worth := g.objectWorth(armor); worth != 0 {
|
||||
t.Errorf("objectWorth = %d, want 0", worth)
|
||||
}
|
||||
})
|
||||
|
||||
if got := g.data.armorClass(armor.Which); got != 0 {
|
||||
t.Errorf("armorClass(%d) = %d, want 0", armor.Which, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMalformedWeaponDoesNotPanic covers the init_dam[] read. WeaponFlame
|
||||
// is the first kind with no table row, so initWeapon must leave the
|
||||
// object alone rather than index past the end.
|
||||
func TestMalformedWeaponDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
weap := newObject()
|
||||
|
||||
mustNotPanic(t, "initWeapon with the flame pseudo-weapon", func() {
|
||||
g.initWeapon(weap, WeaponFlame)
|
||||
})
|
||||
|
||||
mustNotPanic(t, "initWeapon with a negative weapon kind", func() {
|
||||
g.initWeapon(weap, WeaponKind(-1))
|
||||
})
|
||||
|
||||
if weap.Kind != KindNone {
|
||||
t.Errorf("weapon was initialized from a missing table row: kind %v",
|
||||
weap.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixStickMalformedWhichDoesNotPanic covers the ws_type[] read that
|
||||
// the reported reproducer actually crashed on.
|
||||
func TestFixStickMalformedWhichDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGameInput(t)
|
||||
wand := malformed(KindWand)
|
||||
|
||||
mustNotPanic(t, "fixStick on a malformed wand", func() {
|
||||
g.fixStick(wand)
|
||||
})
|
||||
|
||||
if wand.Damage.String() != "1x1" {
|
||||
t.Errorf("damage = %q, want the wand damage %q",
|
||||
wand.Damage.String(), "1x1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreRejectsOutOfRangeWhich is the save-file half of the fix: a
|
||||
// malformed object must not be able to sneak past the keyboard guard by
|
||||
// arriving in a snapshot.
|
||||
//
|
||||
// A decoded save is also the only place a *negative* Which can come
|
||||
// from. On the keyboard path createObj's ch-'a' is byte arithmetic and
|
||||
// wraps, so 'A' and '!' land at 234 and 202; Which is a plain int in the
|
||||
// gob stream, so a tampered file can carry any value at all. Both shapes
|
||||
// are covered here, and the negative case is what exercises the
|
||||
// Which >= 0 arm of hasValidWhich.
|
||||
func TestRestoreRejectsOutOfRangeWhich(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
which int
|
||||
}{
|
||||
{"one past the wand table", int(NumWandTypes)},
|
||||
{"the value 'A' wraps to on the keyboard path", 234},
|
||||
{"the value '!' wraps to on the keyboard path", 202},
|
||||
{"negative, reachable only from a tampered file", -1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 11)
|
||||
st := g.snapshot()
|
||||
|
||||
if len(st.Player.Body.Pack) == 0 {
|
||||
t.Fatal("starting pack is empty; nothing to corrupt")
|
||||
}
|
||||
|
||||
st.Player.Body.Pack[0].Kind = KindWand
|
||||
st.Player.Body.Pack[0].Which = tc.which
|
||||
|
||||
path := filepath.Join(t.TempDir(), "rogue.save")
|
||||
writeSnapshot(t, path, st)
|
||||
|
||||
_, restoreErr := Restore(path, Params{Term: &testTerm{}})
|
||||
if !errors.Is(restoreErr, ErrSaveCorrupt) {
|
||||
t.Errorf("Restore error = %v, want ErrSaveCorrupt", restoreErr)
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
t.Error("a rejected save file was deleted; it should be left alone")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeSnapshot gob-encodes a snapshot to path the way saveFile does.
|
||||
func writeSnapshot(t *testing.T, path string, st *SaveState) {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Create(path) //nolint:gosec // G304: test temp path
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encErr := gob.NewEncoder(f).Encode(st)
|
||||
if encErr != nil {
|
||||
t.Fatal(encErr)
|
||||
}
|
||||
|
||||
closeErr := f.Close()
|
||||
if closeErr != nil {
|
||||
t.Fatal(closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhichLimitCoversEveryIndexedTable pins the bounds table itself
|
||||
// against the per-kind arrays it has to agree with.
|
||||
func TestWhichLimitCoversEveryIndexedTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// len() of an array field is a compile-time constant, so the zero
|
||||
// value is enough to read the table sizes off.
|
||||
var it ItemLore
|
||||
|
||||
cases := []struct {
|
||||
kind ObjectKind
|
||||
size int
|
||||
}{
|
||||
{KindPotion, len(it.Potions)},
|
||||
{KindScroll, len(it.Scrolls)},
|
||||
{KindRing, len(it.Rings)},
|
||||
{KindWand, len(it.Sticks)},
|
||||
{KindArmor, len(it.Armors)},
|
||||
{KindWeapon, len(it.Weapons)},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := whichLimit(tc.kind); got != tc.size {
|
||||
t.Errorf("whichLimit(%v) = %d, want the table size %d",
|
||||
tc.kind, got, tc.size)
|
||||
}
|
||||
}
|
||||
|
||||
// Kinds whose Which is not a table index accept anything, as in C.
|
||||
for _, kind := range []ObjectKind{KindFood, KindAmulet, KindGold, KindNone} {
|
||||
obj := &Object{Kind: kind, Which: 99}
|
||||
if !obj.hasValidWhich() {
|
||||
t.Errorf("%v should not be bounds-checked on Which", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWizardToggleOffRehidesSensedMonsters drives '+' through command
|
||||
// dispatch in wizard mode (command.c 317-338). Clearing the flag is the
|
||||
// cheap half; the substantive half is turn_see(TRUE) — leaving wizard
|
||||
// mode has to put the screen back, or there is no way out of wizard
|
||||
// sight once it is on.
|
||||
func TestWizardToggleOffRehidesSensedMonsters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 11)
|
||||
g.Wizard = true
|
||||
|
||||
// A phantom carries ISINVIS straight from the monster table, so
|
||||
// seeMonst is false for it and it is on screen only because wizard
|
||||
// sight put it there.
|
||||
tp := spawnAdjacent(g, 'P')
|
||||
if !tp.On(Invisible) {
|
||||
t.Fatal("phantom is not invisible; this test needs an unseeable monster")
|
||||
}
|
||||
|
||||
if g.seeMonst(tp) {
|
||||
t.Fatal("monster is ordinarily visible; wizard sight would reveal nothing")
|
||||
}
|
||||
|
||||
if tp.OldCh == tp.Type {
|
||||
t.Fatalf("map char under the monster is also %q; the redraw "+
|
||||
"assertion would prove nothing", tp.Type)
|
||||
}
|
||||
|
||||
g.turnSee(false)
|
||||
|
||||
if !g.Player.On(SenseMonsters) {
|
||||
t.Fatal("turnSee(false) did not set SenseMonsters")
|
||||
}
|
||||
|
||||
if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.Type {
|
||||
t.Fatalf("wizard sight did not draw the monster: cell is %q, want %q",
|
||||
ch, tp.Type)
|
||||
}
|
||||
|
||||
if !g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout {
|
||||
t.Fatal("wizard-sighted monster was not drawn in standout")
|
||||
}
|
||||
|
||||
g.Msgs.Mpos = 0
|
||||
g.After = true
|
||||
|
||||
g.dispatch('+')
|
||||
|
||||
if g.Wizard {
|
||||
t.Error("'+' did not clear the wizard flag")
|
||||
}
|
||||
|
||||
if g.Player.On(SenseMonsters) {
|
||||
t.Error("'+' left SenseMonsters set: turn_see(TRUE) was not performed")
|
||||
}
|
||||
|
||||
if ch := g.mvinch(tp.Pos.Y, tp.Pos.X); ch != tp.OldCh {
|
||||
t.Errorf("monster still on screen after leaving wizard mode: cell is "+
|
||||
"%q, want the map char under it, %q", ch, tp.OldCh)
|
||||
}
|
||||
|
||||
if g.scr.Std.at(tp.Pos.Y, tp.Pos.X).standout {
|
||||
t.Error("cell left in standout after leaving wizard mode")
|
||||
}
|
||||
|
||||
if g.Msgs.Huh != "not wizard any more" {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, "not wizard any more")
|
||||
}
|
||||
|
||||
if g.After {
|
||||
t.Error("'+' consumed a turn; C sets after = FALSE")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWizardToggleWithoutWizardSaysSorry pins the other arm. C ran
|
||||
// wizard = passwd() and said "sorry" when the answer was wrong; the
|
||||
// password machinery is dropped, so that is the only outcome left. What
|
||||
// it must not be any more is "illegal command '+'".
|
||||
func TestWizardToggleWithoutWizardSaysSorry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := mkGame(t, 12)
|
||||
g.Wizard = false
|
||||
g.Msgs.Mpos = 0
|
||||
g.After = true
|
||||
|
||||
g.dispatch('+')
|
||||
|
||||
if g.Wizard {
|
||||
t.Error("'+' entered wizard mode with no password check to pass")
|
||||
}
|
||||
|
||||
if g.Player.On(SenseMonsters) {
|
||||
t.Error("'+' turned on monster sense outside wizard mode")
|
||||
}
|
||||
|
||||
if g.Msgs.Huh != "sorry" {
|
||||
t.Errorf("message = %q, want %q", g.Msgs.Huh, "sorry")
|
||||
}
|
||||
|
||||
if g.After {
|
||||
t.Error("'+' consumed a turn; C sets after = FALSE")
|
||||
}
|
||||
}
|
||||
196
term/tcell.go
196
term/tcell.go
@@ -5,6 +5,8 @@
|
||||
package term
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -21,6 +23,9 @@ type Tcell struct {
|
||||
last *game.Window // last rendered window, for resize redraws
|
||||
}
|
||||
|
||||
// ErrScreenTooSmall reports a terminal below the required 80x24.
|
||||
var ErrScreenTooSmall = errors.New("screen too small")
|
||||
|
||||
// New initializes the terminal. The screen must be at least 80x24, as the
|
||||
// C game required.
|
||||
func New() (*Tcell, error) {
|
||||
@@ -28,16 +33,22 @@ func New() (*Tcell, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Init(); err != nil {
|
||||
return nil, err
|
||||
|
||||
initErr := s.Init()
|
||||
if initErr != nil {
|
||||
return nil, initErr
|
||||
}
|
||||
|
||||
w, h := s.Size()
|
||||
if h < game.NumLines || w < game.NumCols {
|
||||
s.Fini()
|
||||
return nil, fmt.Errorf("sorry, the screen must be at least %dx%d",
|
||||
game.NumLines, game.NumCols)
|
||||
|
||||
return nil, fmt.Errorf("sorry, %w: %dx%d required",
|
||||
ErrScreenTooSmall, game.NumCols, game.NumLines)
|
||||
}
|
||||
|
||||
s.HideCursor()
|
||||
|
||||
return &Tcell{screen: s}, nil
|
||||
}
|
||||
|
||||
@@ -49,23 +60,38 @@ func (t *Tcell) Fini() {
|
||||
// Render blits a game window to the terminal (curses refresh).
|
||||
func (t *Tcell) Render(w *game.Window) {
|
||||
t.last = w
|
||||
|
||||
rows, cols := w.Size()
|
||||
for y := 0; y < rows; y++ {
|
||||
for x := 0; x < cols; x++ {
|
||||
for y := range rows {
|
||||
for x := range cols {
|
||||
ch, standout := w.CellAt(y, x)
|
||||
|
||||
style := tcell.StyleDefault
|
||||
if standout {
|
||||
style = style.Reverse(true)
|
||||
}
|
||||
|
||||
t.screen.SetContent(x, y, rune(ch), nil, style)
|
||||
}
|
||||
}
|
||||
|
||||
t.screen.Show()
|
||||
}
|
||||
|
||||
// Repaint redraws the whole physical screen from tcell's content buffer
|
||||
// — which holds what Render last blitted, so this is C's clearok(curscr,
|
||||
// TRUE) + wrefresh(curscr) (command.c, the CTRL('R') arm) rather than a
|
||||
// fresh draw of stdscr. Sync throws away tcell's record of what the
|
||||
// terminal is showing, so unlike Show it repaints cells it believes are
|
||||
// already correct, which is what makes it fix a corrupted screen.
|
||||
func (t *Tcell) Repaint() {
|
||||
t.screen.Sync()
|
||||
}
|
||||
|
||||
// ReadChar blocks for the next key, translated to the byte codes the C
|
||||
// game reads: arrows become hjkl, control keys their C0 codes.
|
||||
func (t *Tcell) ReadChar() byte {
|
||||
// game reads: arrows become hjkl, control keys their C0 codes. ok is
|
||||
// false when Interrupt woke the read instead of a key arriving.
|
||||
func (t *Tcell) ReadChar() (byte, bool) {
|
||||
for {
|
||||
ev := t.screen.PollEvent()
|
||||
switch ev := ev.(type) {
|
||||
@@ -73,63 +99,135 @@ func (t *Tcell) ReadChar() byte {
|
||||
if t.last != nil {
|
||||
t.Render(t.last)
|
||||
}
|
||||
case *tcell.EventInterrupt:
|
||||
// Interrupt posted this from the signal goroutine: hand
|
||||
// control back so the game goroutine can service a pending
|
||||
// autosave, then it reads again.
|
||||
return 0, false
|
||||
case *tcell.EventKey:
|
||||
switch ev.Key() {
|
||||
case tcell.KeyUp:
|
||||
return 'k'
|
||||
case tcell.KeyDown:
|
||||
return 'j'
|
||||
case tcell.KeyLeft:
|
||||
return 'h'
|
||||
case tcell.KeyRight:
|
||||
return 'l'
|
||||
case tcell.KeyHome:
|
||||
return 'y'
|
||||
case tcell.KeyPgUp:
|
||||
return 'u'
|
||||
case tcell.KeyEnd:
|
||||
return 'b'
|
||||
case tcell.KeyPgDn:
|
||||
return 'n'
|
||||
case tcell.KeyEnter:
|
||||
return '\n'
|
||||
case tcell.KeyEscape:
|
||||
return game.Escape
|
||||
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
||||
return 8
|
||||
case tcell.KeyDelete:
|
||||
return 0x7f
|
||||
case tcell.KeyTab:
|
||||
return '\t'
|
||||
case tcell.KeyCtrlC:
|
||||
return 3
|
||||
default:
|
||||
if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ {
|
||||
return byte(ev.Key())
|
||||
}
|
||||
if r := ev.Rune(); r > 0 && r < 0x80 {
|
||||
return byte(r)
|
||||
}
|
||||
if b, ok := translateKey(ev); ok {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interrupt wakes a ReadChar parked in PollEvent by posting an interrupt
|
||||
// event onto tcell's own event queue — the mechanism tcell provides for
|
||||
// exactly this, and the only Tcell method called from another goroutine
|
||||
// (Screen.PostEvent is a channel send, safe to call concurrently).
|
||||
//
|
||||
// Best effort by design: PostEvent fails only when the event queue is
|
||||
// full, which means the game goroutine is not parked waiting for a key,
|
||||
// and a game goroutine that is running turns reaches the between-turns
|
||||
// check on its own.
|
||||
func (t *Tcell) Interrupt() {
|
||||
_ = t.screen.PostEvent(tcell.NewEventInterrupt(nil))
|
||||
}
|
||||
|
||||
// translateKey converts a key event to a game input byte; ok is false
|
||||
// for keys the C game does not understand.
|
||||
func translateKey(ev *tcell.EventKey) (byte, bool) {
|
||||
if b, ok := namedKey(ev.Key()); ok {
|
||||
return b, true
|
||||
}
|
||||
|
||||
if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ {
|
||||
return byte(ev.Key()), true //nolint:gosec // G115: 1..26 fits
|
||||
}
|
||||
|
||||
if r := ev.Rune(); r > 0 && r < 0x80 {
|
||||
return byte(r), true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// namedKey translates tcell's navigation and editing keys to the single
|
||||
// bytes the C game reads (arrows become hjkl, etc.); ok is false for
|
||||
// keys handled elsewhere.
|
||||
func namedKey(k tcell.Key) (byte, bool) {
|
||||
if b, ok := motionKey(k); ok {
|
||||
return b, true
|
||||
}
|
||||
|
||||
return editingKey(k)
|
||||
}
|
||||
|
||||
// motionKey translates the arrow and paging keys to Rogue's movement
|
||||
// letters (tcell.go ReadChar).
|
||||
func motionKey(k tcell.Key) (byte, bool) {
|
||||
//nolint:exhaustive // translation table: all other keys fall through
|
||||
switch k {
|
||||
case tcell.KeyUp:
|
||||
return 'k', true
|
||||
case tcell.KeyDown:
|
||||
return 'j', true
|
||||
case tcell.KeyLeft:
|
||||
return 'h', true
|
||||
case tcell.KeyRight:
|
||||
return 'l', true
|
||||
case tcell.KeyHome:
|
||||
return 'y', true
|
||||
case tcell.KeyPgUp:
|
||||
return 'u', true
|
||||
case tcell.KeyEnd:
|
||||
return 'b', true
|
||||
case tcell.KeyPgDn:
|
||||
return 'n', true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// editingKey translates the editing and control keys to their C0 codes
|
||||
// (tcell.go ReadChar).
|
||||
func editingKey(k tcell.Key) (byte, bool) {
|
||||
//nolint:exhaustive // translation table: all other keys fall through
|
||||
switch k {
|
||||
case tcell.KeyEnter:
|
||||
return '\n', true
|
||||
case tcell.KeyEscape:
|
||||
return game.Escape, true
|
||||
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
||||
return '\b', true
|
||||
case tcell.KeyDelete:
|
||||
return '\x7f', true
|
||||
case tcell.KeyTab:
|
||||
return '\t', true
|
||||
case tcell.KeyCtrlC:
|
||||
return '\x03', true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ShellEscape suspends the screen and runs the user's shell (main.c
|
||||
// shell + md_shellescape).
|
||||
func (t *Tcell) ShellEscape() {
|
||||
if err := t.screen.Suspend(); err != nil {
|
||||
err := t.screen.Suspend()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
fmt.Println("[Entering shell; exit to return to the game]")
|
||||
cmd := exec.Command(shell)
|
||||
|
||||
_, _ = fmt.Fprintln(os.Stdout,
|
||||
"[Entering shell; exit to return to the game]")
|
||||
|
||||
// The shell session has no deadline by design; Background context.
|
||||
//nolint:gosec // G204: the user's own $SHELL
|
||||
cmd := exec.CommandContext(context.Background(), shell)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
t.screen.Resume()
|
||||
_ = cmd.Run() // best effort: the shell is the user's business
|
||||
|
||||
resumeErr := t.screen.Resume()
|
||||
if resumeErr != nil {
|
||||
panic(resumeErr) // terminal resume failure is unrecoverable
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user