Compare commits
15 Commits
88f18fc635
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce238dd62 | |||
| c30da22e43 | |||
| e595b87718 | |||
| 11223caa7c | |||
| e7e1bc3c40 | |||
| 061da11877 | |||
| 0e6ed41351 | |||
| b431af8b74 | |||
| cb1e302102 | |||
| bcdfaf4ab4 | |||
| a7d27ef65f | |||
| 194ce1dd16 | |||
| cd0ba6c8ee | |||
| 8241cf4bee | |||
| 35b538e888 |
772
ARCHITECTURE.md
772
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
57
MEMORY.md
57
MEMORY.md
@@ -1,44 +1,41 @@
|
|||||||
# Project Memory
|
# Project Memory
|
||||||
|
|
||||||
Working notes for agents on this repo. Read this alongside TODO.md
|
Working notes for agents on this repo. Read this alongside TODO.md (which holds
|
||||||
(which holds the step queue and workflow) before starting work.
|
the step queue and workflow) before starting work.
|
||||||
|
|
||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
Panicking on bad/unexpected errors is allowed and preferred over
|
Panicking on bad/unexpected errors is allowed and preferred over threading
|
||||||
threading unlikely error returns through game code — e.g. write-side
|
unlikely error returns through game code — e.g. write-side Close/encode failures
|
||||||
Close/encode failures where continuing would mean corrupt state. The
|
where continuing would mean corrupt state. The game already unwinds C's exit()
|
||||||
game already unwinds C's exit() calls via a gameEnd panic recovered in
|
calls via a gameEnd panic recovered in Run. Return errors where a caller
|
||||||
Run. Return errors where a caller genuinely handles them (save-file
|
genuinely handles them (save-file prompts, restore validation). Reserve
|
||||||
prompts, restore validation). Reserve deliberate `_ =` discards for
|
deliberate `_ =` discards for true best-effort paths (scorefile writes,
|
||||||
true best-effort paths (scorefile writes, signal-time autosave), always
|
signal-time autosave), always with a comment saying why.
|
||||||
with a comment saying why.
|
|
||||||
|
|
||||||
## Linting
|
## Linting
|
||||||
|
|
||||||
The .golangci.yml is the house standard and may only be modified with
|
The .golangci.yml is the house standard and may only be modified with sneak's
|
||||||
sneak's explicit permission. To disable a linter, ask, explaining what
|
explicit permission. To disable a linter, ask, explaining what the linter does;
|
||||||
the linter does; he approves specific exceptions, which are recorded
|
he approves specific exceptions, which are recorded in the config's
|
||||||
in the config's "Repo-specific exceptions" block with the approval
|
"Repo-specific exceptions" block with the approval date. Approved so far:
|
||||||
date. Approved so far: paralleltest (2026-07-06); testpackage,
|
paralleltest (2026-07-06); testpackage, exhaustive, and mnd (2026-07-07). The
|
||||||
exhaustive, and mnd (2026-07-07). The complexity linters (cyclop,
|
complexity linters (cyclop, gocognit, nestif) are enabled and clean as of
|
||||||
gocognit, nestif) are enabled and clean as of refactor step 7
|
refactor step 7 (2026-07-07): the whole golangci-lint run is 0 issues, so keep
|
||||||
(2026-07-07): the whole golangci-lint run is 0 issues, so keep it that
|
it that way — decompose new hot spots rather than reaching for a nolint.
|
||||||
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
|
||||||
Line-level //nolint with a reason is used sparingly for C-faithfulness
|
authentic "missle" message spellings) and provably-safe gosec conversions; each
|
||||||
(e.g. the authentic "missle" message spellings) and provably-safe gosec
|
needs a justifying comment.
|
||||||
conversions; each needs a justifying comment.
|
|
||||||
|
|
||||||
## Faithfulness
|
## Faithfulness
|
||||||
|
|
||||||
Behavior must not change during the idiomatic-Go refactor unless a
|
Behavior must not change during the idiomatic-Go refactor unless a TODO step
|
||||||
TODO step says so. The 80x24 seed-compatible gameplay, message text
|
says so. The 80x24 seed-compatible gameplay, message text (including original
|
||||||
(including original typos), RNG call order, and C quirks (documented
|
typos), RNG call order, and C quirks (documented in tests like
|
||||||
in tests like TestHoldScrollGreedyMonsterQuirk) are contract. Doc
|
TestHoldScrollGreedyMonsterQuirk) are contract. Doc comments keep their "(file.c
|
||||||
comments keep their "(file.c func_name)" breadcrumbs.
|
func_name)" breadcrumbs.
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
Write real, committed test files with t.Logf output and run plain
|
Write real, committed test files with t.Logf output and run plain `go test -v`;
|
||||||
`go test -v`; no throwaway scratch scripts. Successful debug probes
|
no throwaway scratch scripts. Successful debug probes become regression tests.
|
||||||
become regression tests.
|
|
||||||
|
|||||||
35
Makefile
Normal file
35
Makefile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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.
|
||||||
|
test:
|
||||||
|
go test $(GO_PKGS)
|
||||||
45
README.md
45
README.md
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
[](LICENSE.TXT)
|
[](LICENSE.TXT)
|
||||||
|
|
||||||
**Rogue** is the original dungeon-crawling adventure game that spawned an
|
**Rogue** is the original dungeon-crawling adventure game that spawned an entire
|
||||||
entire genre. This branch is a faithful Go port of Rogue 5.4.4: explore
|
genre. This branch is a faithful Go port of Rogue 5.4.4: explore procedurally
|
||||||
procedurally generated dungeons, fight monsters, collect treasure, and
|
generated dungeons, fight monsters, collect treasure, and attempt to retrieve
|
||||||
attempt to retrieve the Amulet of Yendor.
|
the Amulet of Yendor.
|
||||||
|
|
||||||
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman
|
**Original authors:** Michael Toy, Ken Arnold, and Glenn Wichman (1980–1983,
|
||||||
(1980–1983, 1985, 1999).
|
1985, 1999).
|
||||||
|
|
||||||
The port is function-by-function faithful to the classic C sources — same
|
The port is function-by-function faithful to the classic C sources — same
|
||||||
dungeon generation (seed-compatible RNG), same combat math, same item
|
dungeon generation (seed-compatible RNG), same combat math, same item tables,
|
||||||
tables, same messages. The C reference implementation lives on the
|
same messages. The C reference implementation lives on the `master` and
|
||||||
`master` and `modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md)
|
`modern-rogue` branches; [ARCHITECTURE.md](ARCHITECTURE.md) documents both the
|
||||||
documents both the original program structure and the design of this port.
|
original program structure and the design of this port.
|
||||||
|
|
||||||
## Building and running
|
## Building and running
|
||||||
|
|
||||||
@@ -40,13 +40,12 @@ go build ./cmd/rogue
|
|||||||
|
|
||||||
Press `?` in game for the full list.
|
Press `?` in game for the full list.
|
||||||
|
|
||||||
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run
|
- **arrows** or **h/j/k/l/y/u/b/n** — move (shift to run, ctrl to run until
|
||||||
until adjacent)
|
adjacent)
|
||||||
- **`.`** rest, **`s`** search for hidden doors and traps
|
- **`.`** rest, **`s`** search for hidden doors and traps
|
||||||
- **`i`** inventory, **`,`** pick up, **`d`** drop
|
- **`i`** inventory, **`,`** pick up, **`d`** drop
|
||||||
- **`q`** quaff potion, **`r`** read scroll, **`e`** eat food
|
- **`q`** quaff potion, **`r`** read scroll, **`e`** eat food
|
||||||
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on /
|
- **`w`** wield weapon, **`W`** wear armor, **`P`**/**`R`** put on / remove ring
|
||||||
remove ring
|
|
||||||
- **`t`** throw, **`z`** zap a wand, **`f`**/**`F`** fight
|
- **`t`** throw, **`z`** zap a wand, **`f`**/**`F`** fight
|
||||||
- **`>`**/**`<`** take the stairs
|
- **`>`**/**`<`** take the stairs
|
||||||
- **`S`** save, **`Q`** quit
|
- **`S`** save, **`Q`** quit
|
||||||
@@ -61,8 +60,8 @@ export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
|
|||||||
ROGUE_WIZARD=1 SEED=12345 ./rogue
|
ROGUE_WIZARD=1 SEED=12345 ./rogue
|
||||||
```
|
```
|
||||||
|
|
||||||
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob
|
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
|
||||||
snapshots and, as in the original, are deleted when restored.
|
and, as in the original, are deleted when restored.
|
||||||
|
|
||||||
## Code layout
|
## Code layout
|
||||||
|
|
||||||
@@ -73,13 +72,17 @@ term/ tcell-backed terminal, replacing curses
|
|||||||
cmd/rogue/ the executable
|
cmd/rogue/ the executable
|
||||||
```
|
```
|
||||||
|
|
||||||
The engine package is fully headless-testable: `go test ./game/` runs
|
The engine package is fully headless-testable: `go test ./game/` runs scripted
|
||||||
scripted game sessions, dungeon-generation golden checks, and an RNG
|
command sequences, dungeon-generation golden checks, and an RNG compatibility
|
||||||
compatibility test against the original C generator.
|
test against the original C generator.
|
||||||
|
|
||||||
|
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
||||||
|
prettier), `make lint` (golangci-lint), `make test`, and `make check` (all
|
||||||
|
three).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
BSD-style; see [LICENSE.TXT](LICENSE.TXT).
|
BSD-style; see [LICENSE.TXT](LICENSE.TXT).
|
||||||
|
|
||||||
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn
|
Copyright (C) 1980-1983, 1985, 1999 Michael Toy, Ken Arnold and Glenn Wichman.
|
||||||
Wichman. All rights reserved.
|
All rights reserved.
|
||||||
|
|||||||
304
TODO.md
304
TODO.md
@@ -1,164 +1,188 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
* branch (from `main`)
|
- branch (from `main`)
|
||||||
* do the work in Next Step
|
- do the work in Next Step
|
||||||
* move Next Step to the top of Completed Steps
|
- move Next Step to the top of Completed Steps
|
||||||
* move the top item of Future Steps into Next Step
|
- move the top item of Future Steps into Next Step
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
* push
|
- push
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0
|
pre-1.0
|
||||||
|
|
||||||
The port on main is complete and faithful (function-by-function from
|
The port on main is complete and faithful (function-by-function from Rogue 5.4.4
|
||||||
Rogue 5.4.4 C; reference sources on c-master/modern-rogue). Current
|
C; reference sources on c-master/modern-rogue). Current phase: refactor from a
|
||||||
phase: refactor from a transliterated port into idiomatic Go — one
|
transliterated port into idiomatic Go — one feature branch per step below,
|
||||||
feature branch per step below, descriptive naming, real types, house
|
descriptive naming, real types, house style per
|
||||||
style per ~/dev/prompts/prompts/CODE_STYLEGUIDE_GO.md.
|
~/dev/prompts/prompts/CODE_STYLEGUIDE_GO.md.
|
||||||
|
|
||||||
Refactor ground rules:
|
Refactor ground rules:
|
||||||
|
|
||||||
- Behavior must not change unless a step says so. The full test suite
|
- Behavior must not change unless a step says so. The full test suite (scripted
|
||||||
(scripted sessions, generation invariants, C-compatible RNG goldens)
|
sessions, generation invariants, C-compatible RNG goldens) gates every step;
|
||||||
gates every step; 80x24 seed-compatible gameplay stays intact.
|
80x24 seed-compatible gameplay stays intact.
|
||||||
- Renames keep the C lineage greppable: doc comments retain their
|
- Renames keep the C lineage greppable: doc comments retain their "(file.c
|
||||||
"(file.c func_name)" breadcrumbs, and the docs refresh step adds a
|
func_name)" breadcrumbs, and the docs refresh step adds a C-name → Go-name
|
||||||
C-name → Go-name table to ARCHITECTURE.md.
|
table to ARCHITECTURE.md.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Refactor step 8: constructor and style pass per the house styleguide —
|
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
|
||||||
game.New(game.Params{...}) replacing NewGame(Config); replace the
|
wizard commands).
|
||||||
gameEnd panic unwind with error-based turn results where feasible;
|
|
||||||
77-column wrap sweep.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects
|
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
|
||||||
dispatch tables plus a full decomposition sweep — the quaff /
|
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
|
||||||
readScroll / doZap switches, the attack monster-power switch, the
|
forces the RNG seed and prints the per-seed item appearance tables (potion
|
||||||
be_trapped switch, the daemon d_func switch, and the command-key
|
colors, scroll names, ring stones, wand/staff materials) before initscr, and
|
||||||
switch all became handler tables on gameData (quaffHandlers,
|
captured its output for four seeds as testdata/item_tables.golden.
|
||||||
readHandlers, zapHandlers, hitHandlers, trapHandlers, daemonHandlers,
|
TestSeedCompatItemTables regenerates the same tables from the Go port and they
|
||||||
commandHandlers), one small named method per case. Every remaining
|
match byte for byte — proving the LCG and its consumption order through the
|
||||||
cyclop/gocognit/nestif hot spot was split into named helpers across
|
whole init sequence agree with C. The remaining "same dungeon (map)" half
|
||||||
fight, misc (look), command, chase, move, passages, options, pack,
|
would need the harder headless-curses C dump (new_level draws to curses);
|
||||||
things, save, daemons, rooms, score, monsters, rings, rip, io,
|
deferred — the item-table match already validates RNG-order faithfulness
|
||||||
object, weapons, wizard, and term/tcell, plus three test functions.
|
through init, and the Go generation goldens guard determinism thereafter.
|
||||||
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):
|
- 2026-07-23 Playtest hardening (playtest-hardening): added two death-safe
|
||||||
MessageLine (was MsgLine) owns the msg/addmsg/endmsg machinery,
|
crash-sweep drives through the real turn loop, within the step-8 os.Exit
|
||||||
wired to its screen/look/input needs via attach(); RogueGame keeps
|
constraint (a fortify() helper pins HP/food/exp and clears the freeze/stuck
|
||||||
one-line msg/addmsgf/endmsg shorthands so call sites are unchanged.
|
counters each turn so no death exits the test binary; fixed seeds keep them
|
||||||
Player owns pack bookkeeping (nextPackChar, removeFromPack — the
|
deterministic). TestDeepPlaythrough uses quaff/read/zap through command
|
||||||
state half of leave_pack; leavePack keeps only LastPick tracking).
|
dispatch, then descends to depth 8 with a save/restore at depth 4;
|
||||||
Level owns object/monster list management and lookup (ObjectAt
|
TestTurnLoopCrashSweep mashes movement/search/rest for 200 turns on four
|
||||||
replaces findObj; AddObject/RemoveObject/AddMonster/RemoveMonster
|
seeds. Neither surfaced a panic. The interactive "play several games at a real
|
||||||
replace direct attachObj/detachObj/attachMon/detachMon on level
|
tcell terminal" portion needs a human at an 80x24 terminal and is left to the
|
||||||
lists). Inventory/pickup UI flows stay on RogueGame deliberately:
|
maintainer; the binary's non-interactive paths (`-s` scores) were
|
||||||
they are display and turn orchestration, not state surgery.
|
smoke-tested.
|
||||||
|
|
||||||
- 2026-07-07 Refactor step 5 (refactor/item-combat-ui-renames, three
|
- 2026-07-23 Docs refresh (docs-refresh): rewrote ARCHITECTURE.md Part 2 (the
|
||||||
commits, one subsystem each): items — getItem→promptPackItem now
|
pre-implementation design sketch) to match the final code — current type/field
|
||||||
returning (obj, ok), invName→inventoryName, doPot→applyPotionFuse;
|
names (ObjectKind, DiceSpec, split o_arm, step-1 flag names, TrapCount, Level
|
||||||
combat — rollEm→rollAttacks, attack/moveMonster/chaseStep return
|
list methods), the static tables now on the per-game gameData struct, the
|
||||||
(removed bool) instead of C -1/0 int codes; UI —
|
daemon/effect handler tables, the MessageLine extraction, the Terminal
|
||||||
getDir→promptDirection. C breadcrumbs kept; suite green.
|
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-07 Refactor step 4 (refactor/movement-renames): movement/world
|
- 2026-07-23 Refactor step 8 (refactor/constructor-style): constructor and exit
|
||||||
renames (doMove→moveHero, beTrapped→springTrap, rndmove→randomStep,
|
pass. NewGame(Config) → New(Params) and Restore takes Params, so the package's
|
||||||
doRooms/doPassages/doMaze→digRooms/digPassages/digMaze,
|
primary type gets the canonical New() constructor with a named-field Params
|
||||||
chgStr→changeStrength, doRun→startRun, moveStuff→finishMove,
|
struct (styleguide 139/159). The gameEnd panic unwind is gone: one game run is
|
||||||
turnref→turnRefresh, moveMonst→moveMonster, doChase→chaseStep,
|
one process, so myExit restores the terminal (new Terminal.Fini) and calls
|
||||||
setOldch→setOldChar, cansee→canSee, roomin→roomIn, runto→runTo,
|
os.Exit(0), and Run() no longer returns; the four Run()-to-completion tests
|
||||||
conn→connectRooms, putpass→putPassage, passnum→numberPassages,
|
were reworked/dropped since death (combat or starvation) now exits the process
|
||||||
numpass→numberPassage, rndPos→randomPos, rndRoom→randomRoom,
|
(TestScoreRendersList and TestRunDownStairs preserve what is still drivable;
|
||||||
treasRoom→treasureRoom, accntMaze→accountMaze); all goto/label flows
|
save/restore stays covered by TestSaveRestoreRoundTrip). The 77-column wrap
|
||||||
replaced with loops (moveHero retry loop + extracted passageTurn,
|
sweep was dropped per sneak (2026-07-23): line lengths left as-is (lll caps at
|
||||||
dispatch re-dispatch loop, chaseStep passage loop, saveGame labeled
|
88 and passes).
|
||||||
prompt loop); C breadcrumbs kept in doc comments.
|
|
||||||
- 2026-07-07 Lint adoption finished (refactor/no-package-globals): all
|
- 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects dispatch
|
||||||
37 package-level vars moved into `gameData` (built by `newGameData`,
|
tables plus a full decomposition sweep — the quaff / readScroll / doZap
|
||||||
hung on RogueGame as `g.data`, set in NewGame and Restore); ObjectKind
|
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
|
Glyph()/objectKindForGlyph became switches; the table-reading subtype
|
||||||
Stringers were removed; isMagic became a RogueGame method; goconst
|
Stringers were removed; isMagic became a RogueGame method; goconst fixed with
|
||||||
fixed with named word constants (potionName, goldName, staffName,
|
named word constants (potionName, goldName, staffName, ripWall, ...);
|
||||||
ripWall, ...); testpackage and exhaustive disabled in .golangci.yml
|
testpackage and exhaustive disabled in .golangci.yml with sneak's approval
|
||||||
with sneak's approval (2026-07-07); misspell's corruption of the
|
(2026-07-07); misspell's corruption of the "ther" scroll syllable reverted.
|
||||||
"ther" scroll syllable reverted. mnd disabled with sneak's approval
|
mnd disabled with sneak's approval (2026-07-07, follow-up commit). Remaining
|
||||||
(2026-07-07, follow-up commit). Remaining red: cyclop (36), nestif
|
red: cyclop (36), nestif (30), gocognit (23) stay until step 7 fixes them per
|
||||||
(30), gocognit (23) stay until step 7 fixes them per sneak's ruling.
|
sneak's ruling.
|
||||||
- 2026-07-06 Lint adoption bulk (refactor/lint-adoption, 5ba9fe8):
|
- 2026-07-06 Lint adoption bulk (refactor/lint-adoption, 5ba9fe8): .golangci.yml
|
||||||
.golangci.yml copied verbatim from the prompts repo (plus the
|
copied verbatim from the prompts repo (plus the sneak-approved paralleltest
|
||||||
sneak-approved paralleltest exception, 2026-07-06); ~1,500 findings
|
exception, 2026-07-06); ~1,500 findings fixed (autofix formatting sweep,
|
||||||
fixed (autofix formatting sweep, errcheck/err113/noinlineerr error
|
errcheck/err113/noinlineerr error handling, forbidigo, funcorder, recvcheck
|
||||||
handling, forbidigo, funcorder, recvcheck pointer receivers,
|
pointer receivers, goprintffuncname renames msg helpers to *f, revive doc
|
||||||
goprintffuncname renames msg helpers to *f, revive doc comments,
|
comments, gocritic switch rewrites, gosec real fixes plus justified nolints,
|
||||||
gocritic switch rewrites, gosec real fixes plus justified nolints,
|
unparam signature tightening, C-faithful "missle" spellings restored after
|
||||||
unparam signature tightening, C-faithful "missle" spellings restored
|
misspell autofix changed game text).
|
||||||
after misspell autofix changed game text).
|
- 2026-07-06 Module base path updated to git.eeqj.de/sneak/rgoue (go.mod, term/
|
||||||
- 2026-07-06 Module base path updated to git.eeqj.de/sneak/rgoue
|
and cmd/ imports, ARCHITECTURE.md, version string).
|
||||||
(go.mod, term/ and cmd/ imports, ARCHITECTURE.md, version string).
|
- 2026-07-06 Refactor step 3 (refactor/object-fields): Object.Arm split into
|
||||||
- 2026-07-06 Refactor step 3 (refactor/object-fields): Object.Arm split
|
ArmorClass/Charges/GoldValue/Bonus (rings); Stats.Arm → ArmorClass; damage
|
||||||
into ArmorClass/Charges/GoldValue/Bonus (rings); Stats.Arm →
|
strings parsed once into DiceSpec at table definition (ParseDice keeps C
|
||||||
ArmorClass; damage strings parsed once into DiceSpec at table
|
roll_em parse semantics, incl. "%%%x0" and "000x0" edge cases,
|
||||||
definition (ParseDice keeps C roll_em parse semantics, incl. "%%%x0"
|
regression-tested); save format 5.4.4-go3.
|
||||||
and "000x0" edge cases, regression-tested); save format 5.4.4-go3.
|
- 2026-07-06 Refactor step 2 (refactor/typed-kinds, b940cfc): ObjectKind
|
||||||
- 2026-07-06 Refactor step 2 (refactor/typed-kinds, b940cfc):
|
separates item category from map glyph (Object.Type byte → Kind ObjectKind
|
||||||
ObjectKind separates item category from map glyph (Object.Type byte
|
with Glyph()); PotionKind/ScrollKind/RingKind/
|
||||||
→ Kind ObjectKind with Glyph()); PotionKind/ScrollKind/RingKind/
|
WandKind/WeaponKind/ArmorKind/TrapKind typed iota enums with Stringer; typed
|
||||||
WandKind/WeaponKind/ArmorKind/TrapKind typed iota enums with
|
accessors on Object; getItem/inventory/whatis filters take ObjectKind
|
||||||
Stringer; typed accessors on Object; getItem/inventory/whatis
|
(KindCallable/KindRingOrStick replace CALLABLE/R_OR_S); save format bumped to
|
||||||
filters take ObjectKind (KindCallable/KindRingOrStick replace
|
5.4.4-go2. Suite green.
|
||||||
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
|
||||||
- 2026-07-06 Refactor step 1 (refactor/descriptive-constants): renamed
|
bits, trap types, item subtype constants, and Max* counts to descriptive names
|
||||||
all flag bits, trap types, item subtype constants, and Max* counts to
|
(IsHuh→Confused, SeeMonst→SenseMonsters, WsHasteM→WandHasteMonster,
|
||||||
descriptive names (IsHuh→Confused, SeeMonst→SenseMonsters,
|
MaxSticks→NumWandTypes, ...); Level.NTraps→TrapCount; C names kept as comment
|
||||||
WsHasteM→WandHasteMonster, MaxSticks→NumWandTypes, ...);
|
breadcrumbs. Pure rename, suite green.
|
||||||
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
|
- 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),
|
autoconf/VS build system (they remain on master and modern-rogue), ported the
|
||||||
ported the last wizard command (item-probability listing), rewrote
|
last wizard command (item-probability listing), rewrote README.md for the Go
|
||||||
README.md for the Go port (c0b533e)
|
port (c0b533e)
|
||||||
- 2026-07-06 Ported the command loop, save/restore, the tcell terminal
|
- 2026-07-06 Ported the command loop, save/restore, the tcell terminal layer,
|
||||||
layer, and the playable binary at cmd/rogue (41fc104)
|
and the playable binary at cmd/rogue (41fc104)
|
||||||
- 2026-07-06 Ported item effects: potions, scrolls, options, call_it
|
- 2026-07-06 Ported item effects: potions, scrolls, options, call_it (cdf9bf7)
|
||||||
(cdf9bf7)
|
- 2026-07-06 Ported combat, the chase driver, traps, zapping, death and scores
|
||||||
- 2026-07-06 Ported combat, the chase driver, traps, zapping, death and
|
(3c5add8)
|
||||||
scores (3c5add8)
|
- 2026-07-06 Ported dungeon generation, base items, the pack, and monster
|
||||||
- 2026-07-06 Ported dungeon generation, base items, the pack, and
|
creation (a69ef7d)
|
||||||
monster creation (a69ef7d)
|
- 2026-07-06 Ported the foundation: types, seed-compatible RNG, item tables,
|
||||||
- 2026-07-06 Ported the foundation: types, seed-compatible RNG, item
|
daemon scheduler (7fa2048)
|
||||||
tables, daemon scheduler (7fa2048)
|
- 2026-07-06 Wrote ARCHITECTURE.md Parts 1 and 2: complete map of the C program
|
||||||
- 2026-07-06 Wrote ARCHITECTURE.md Parts 1 and 2: complete map of the C
|
and the Go port design (91eeee0, 45dba95)
|
||||||
program and the Go port design (91eeee0, 45dba95)
|
- Fork base: Davidslv/rogue C 5.4.4 with modernization fixes (C23 prototypes,
|
||||||
- Fork base: Davidslv/rogue C 5.4.4 with modernization fixes (C23
|
ncurses compat), preserved on master/modern-rogue
|
||||||
prototypes, ncurses compat), preserved on master/modern-rogue
|
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
1. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the
|
1. Tag a release once a full game (Amulet retrieval and score entry) completes
|
||||||
post-refactor names; add the C name → Go name rename table.
|
without defects.
|
||||||
2. Playtest hardening pass: play several full games with the tcell
|
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
||||||
binary and extend run_test.go to script a deeper multi-level
|
per-game dungeon dimensions instead of the 80x24 constants; open design
|
||||||
playthrough (descend past level 5, use potions, scrolls, zapping,
|
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
||||||
save/restore). Fix any panics, message mismatches, or divergences
|
80x24 mode.
|
||||||
from the C behavior that this uncovers, with regression tests.
|
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
||||||
3. Verify the seed-compatibility claim against the C reference on
|
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
||||||
c-master: same seed, same dungeon, same item tables, for several
|
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
||||||
seeds.
|
REPO_POLICIES.md.
|
||||||
4. Broaden unit test coverage where playtesting finds thin spots
|
|
||||||
(rings, sticks, wizard commands).
|
|
||||||
5. Tag a release once a full game (Amulet retrieval and score entry)
|
|
||||||
completes without defects.
|
|
||||||
6. 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.
|
|
||||||
7. Note: this repo is exempt from the standard policy scaffold. Do not
|
|
||||||
add Makefile, Dockerfile, or REPO_POLICIES.md.
|
|
||||||
|
|||||||
@@ -21,18 +21,20 @@ func main() {
|
|||||||
os.Exit(run())
|
os.Exit(run())
|
||||||
}
|
}
|
||||||
|
|
||||||
// run carries the real main so that deferred terminal restoration runs
|
// run does the real work and returns an exit code. It only returns on a
|
||||||
// before the process exits (os.Exit skips defers).
|
// 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 {
|
func run() int {
|
||||||
scores := flag.Bool("s", false, "print the scoreboard and exit")
|
scores := flag.Bool("s", false, "print the scoreboard and exit")
|
||||||
deathDemo := flag.Bool("d", false, "die a random death (demo)")
|
deathDemo := flag.Bool("d", false, "die a random death (demo)")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
cfg := loadConfig()
|
params := loadParams()
|
||||||
|
|
||||||
if *scores {
|
if *scores {
|
||||||
game.NewGame(cfg).ShowScores()
|
game.New(params).ShowScores()
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -45,46 +47,39 @@ func run() int {
|
|||||||
}
|
}
|
||||||
defer t.Fini()
|
defer t.Fini()
|
||||||
|
|
||||||
cfg.Term = t
|
params.Term = t
|
||||||
|
|
||||||
var g *game.RogueGame
|
var g *game.RogueGame
|
||||||
|
|
||||||
if args := flag.Args(); len(args) == 1 && !*deathDemo {
|
if args := flag.Args(); len(args) == 1 && !*deathDemo {
|
||||||
// restore a saved game
|
// restore a saved game
|
||||||
g, err = game.Restore(args[0], cfg)
|
g, err = game.Restore(args[0], params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fini()
|
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
g = game.NewGame(cfg)
|
g = game.New(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *deathDemo {
|
if *deathDemo {
|
||||||
g.DeathDemo()
|
g.DeathDemo() // does not return: death exits the process
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
installAutosave(g, t)
|
installAutosave(g, t)
|
||||||
|
|
||||||
runErr := g.Run()
|
g.Run() // does not return: the game ends by exiting the process
|
||||||
if runErr != nil {
|
|
||||||
t.Fini()
|
|
||||||
fmt.Fprintln(os.Stderr, runErr)
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig gathers the game configuration from the environment: home
|
// loadParams gathers the game parameters from the environment: home
|
||||||
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
|
// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed
|
||||||
// (main.c's startup).
|
// (main.c's startup).
|
||||||
func loadConfig() game.Config {
|
func loadParams() game.Params {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
||||||
name := ""
|
name := ""
|
||||||
@@ -96,7 +91,7 @@ func loadConfig() game.Config {
|
|||||||
|
|
||||||
wizard := os.Getenv("ROGUE_WIZARD") != ""
|
wizard := os.Getenv("ROGUE_WIZARD") != ""
|
||||||
|
|
||||||
return game.Config{
|
return game.Params{
|
||||||
Seed: chooseSeed(wizard),
|
Seed: chooseSeed(wizard),
|
||||||
Name: name,
|
Name: name,
|
||||||
RogueOpts: os.Getenv("ROGUEOPTS"),
|
RogueOpts: os.Getenv("ROGUEOPTS"),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import "testing"
|
|||||||
func mkGameInput(t *testing.T) *RogueGame {
|
func mkGameInput(t *testing.T) *RogueGame {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
g := NewGame(Config{Seed: 5, Term: &testTerm{}})
|
g := New(Params{Seed: 5, Term: &testTerm{}})
|
||||||
g.NewLevel()
|
g.NewLevel()
|
||||||
g.Oldpos = g.Player.Pos
|
g.Oldpos = g.Player.Pos
|
||||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||||
@@ -183,7 +183,7 @@ func TestZapSlowMonster(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseOpts(t *testing.T) {
|
func TestParseOpts(t *testing.T) {
|
||||||
g := NewGame(Config{Seed: 1})
|
g := New(Params{Seed: 1})
|
||||||
g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow")
|
g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow")
|
||||||
|
|
||||||
if !g.Options.Terse {
|
if !g.Options.Terse {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import "testing"
|
|||||||
func mkGame(t *testing.T, seed int32) *RogueGame {
|
func mkGame(t *testing.T, seed int32) *RogueGame {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
g := NewGame(Config{Seed: seed, Term: &testTerm{}})
|
g := New(Params{Seed: seed, Term: &testTerm{}})
|
||||||
g.NewLevel()
|
g.NewLevel()
|
||||||
g.Oldpos = g.Player.Pos
|
g.Oldpos = g.Player.Pos
|
||||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||||
@@ -79,24 +79,6 @@ func TestAttackHurtsPlayer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeathUnwindsWithGameEnd(t *testing.T) {
|
|
||||||
g := mkGame(t, 11)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
r := recover()
|
|
||||||
if _, ok := r.(gameEnd); !ok {
|
|
||||||
t.Fatalf("death did not unwind with gameEnd, got %v", r)
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.Playing {
|
|
||||||
t.Error("still playing after death")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
g.Options.Tombstone = false
|
|
||||||
g.death('K')
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunnersChaseHero(t *testing.T) {
|
func TestRunnersChaseHero(t *testing.T) {
|
||||||
g := mkGame(t, 3)
|
g := mkGame(t, 3)
|
||||||
// Place a hobgoblin a few squares away in the hero's room and set it
|
// Place a hobgoblin a few squares away in the hero's room and set it
|
||||||
|
|||||||
86
game/game.go
86
game/game.go
@@ -31,9 +31,9 @@ type Options struct {
|
|||||||
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
|
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config carries everything needed to construct a game.
|
// Params carries everything needed to construct a game.
|
||||||
type Config struct {
|
type Params struct {
|
||||||
Seed int32 // dungeon number; the caller derives it (time+pid or SEED env)
|
Seed int32 // dungeon number; caller derives it (time+pid or SEED)
|
||||||
Name string // player name (overridden by ROGUEOPTS name=)
|
Name string // player name (overridden by ROGUEOPTS name=)
|
||||||
RogueOpts string // the ROGUEOPTS environment string
|
RogueOpts string // the ROGUEOPTS environment string
|
||||||
Home string // home directory (save file default location)
|
Home string // home directory (save file default location)
|
||||||
@@ -44,7 +44,7 @@ type Config struct {
|
|||||||
|
|
||||||
// RogueGame is one complete game of Rogue: every piece of state that was a
|
// 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
|
// 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
|
// The struct grows with the port; fields appear in the phase that ports the
|
||||||
// code owning them.
|
// code owning them.
|
||||||
@@ -143,22 +143,22 @@ type RogueGame struct {
|
|||||||
data *gameData
|
data *gameData
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGame builds a game from cfg, seeds the RNG, and randomizes the item
|
// 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
|
// appearance tables (the front half of main.c main(); the player roll-up
|
||||||
// and first level arrive with later porting phases).
|
// and first level arrive with later porting phases).
|
||||||
func NewGame(cfg Config) *RogueGame {
|
func New(params Params) *RogueGame {
|
||||||
g := &RogueGame{
|
g := &RogueGame{
|
||||||
data: newGameData(),
|
data: newGameData(),
|
||||||
Rng: &Rng{Seed: cfg.Seed},
|
Rng: &Rng{Seed: params.Seed},
|
||||||
Dnum: int(cfg.Seed),
|
Dnum: int(params.Seed),
|
||||||
Whoami: cfg.Name,
|
Whoami: params.Name,
|
||||||
Fruit: "slime-mold",
|
Fruit: "slime-mold",
|
||||||
Home: cfg.Home,
|
Home: params.Home,
|
||||||
Wizard: cfg.Wizard,
|
Wizard: params.Wizard,
|
||||||
NoScore: cfg.Wizard,
|
NoScore: params.Wizard,
|
||||||
Playing: true,
|
Playing: true,
|
||||||
Depth: 1,
|
Depth: 1,
|
||||||
ScorePath: cfg.ScorePath,
|
ScorePath: params.ScorePath,
|
||||||
LastScore: -1,
|
LastScore: -1,
|
||||||
}
|
}
|
||||||
g.Options = Options{
|
g.Options = Options{
|
||||||
@@ -168,17 +168,17 @@ func NewGame(cfg Config) *RogueGame {
|
|||||||
}
|
}
|
||||||
g.InvDescribe = true
|
g.InvDescribe = true
|
||||||
g.Msgs.SaveMsg = true
|
g.Msgs.SaveMsg = true
|
||||||
g.scr = NewScreen(cfg.Term)
|
g.scr = NewScreen(params.Term)
|
||||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||||
g.FileName = cfg.Home + "/rogue.save"
|
g.FileName = params.Home + "/rogue.save"
|
||||||
|
|
||||||
g.rogueOpts = cfg.RogueOpts
|
g.rogueOpts = params.RogueOpts
|
||||||
if cfg.Wizard {
|
if params.Wizard {
|
||||||
g.Player.Flags.Set(SenseMonsters)
|
g.Player.Flags.Set(SenseMonsters)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.RogueOpts != "" {
|
if params.RogueOpts != "" {
|
||||||
g.ParseOpts(cfg.RogueOpts)
|
g.ParseOpts(params.RogueOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
g.Monsters = g.data.monsterTable
|
g.Monsters = g.data.monsterTable
|
||||||
@@ -199,22 +199,21 @@ func NewGame(cfg Config) *RogueGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run plays the game to its end: the back half of main.c main() plus
|
// Run plays the game to its end: the back half of main.c main() plus
|
||||||
// playit(). It returns after death, victory, quitting, or saving.
|
// playit(). It does not return — the game ends by exiting the process
|
||||||
func (g *RogueGame) Run() error {
|
// (see myExit); one game run is one process.
|
||||||
// A gameEnd panic is the port's my_exit(): recovering it here makes
|
func (g *RogueGame) Run() {
|
||||||
// Run return normally (zero values), restoring the terminal via the
|
g.startLevel()
|
||||||
// caller's defers.
|
g.playit()
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
if _, ok := r.(gameEnd); ok {
|
|
||||||
return // normal game over / save exit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
panic(r)
|
// 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
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
if !g.restored {
|
|
||||||
g.NewLevel() // draw current level
|
g.NewLevel() // draw current level
|
||||||
// Start up daemons and fuses
|
// Start up daemons and fuses
|
||||||
g.StartDaemon(DRunners, 0, After)
|
g.StartDaemon(DRunners, 0, After)
|
||||||
@@ -223,13 +222,22 @@ func (g *RogueGame) Run() error {
|
|||||||
g.StartDaemon(DStomach, 0, After)
|
g.StartDaemon(DStomach, 0, After)
|
||||||
}
|
}
|
||||||
|
|
||||||
g.playit()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// playit is the main loop of the program (main.c playit).
|
// playit is the main loop of the program (main.c playit).
|
||||||
func (g *RogueGame) 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
|
// set up defaults for modern terminals: curses' md_hasclreol() is
|
||||||
// always true, so the C default inventory style applies
|
// always true, so the C default inventory style applies
|
||||||
if !g.restored {
|
if !g.restored {
|
||||||
@@ -242,13 +250,7 @@ func (g *RogueGame) playit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g.Oldpos = g.Player.Pos
|
g.Oldpos = g.Player.Pos
|
||||||
|
|
||||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||||
for g.Playing {
|
|
||||||
g.command() // command execution
|
|
||||||
}
|
|
||||||
|
|
||||||
g.endit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// endit exits the game (main.c endit).
|
// endit exits the game (main.c endit).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
func genLevel(t *testing.T, seed int32) *RogueGame {
|
func genLevel(t *testing.T, seed int32) *RogueGame {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
g := NewGame(Config{Seed: seed})
|
g := New(Params{Seed: seed})
|
||||||
g.NewLevel()
|
g.NewLevel()
|
||||||
|
|
||||||
return g
|
return g
|
||||||
@@ -159,7 +159,7 @@ func TestNewLevelDeterministic(t *testing.T) {
|
|||||||
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
|
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
|
||||||
func TestDeeperLevels(t *testing.T) {
|
func TestDeeperLevels(t *testing.T) {
|
||||||
for _, seed := range []int32{7, 42, 1000, 31337} {
|
for _, seed := range []int32{7, 42, 1000, 31337} {
|
||||||
g := NewGame(Config{Seed: seed})
|
g := New(Params{Seed: seed})
|
||||||
for depth := 1; depth <= 30; depth++ {
|
for depth := 1; depth <= 30; depth++ {
|
||||||
g.Depth = depth
|
g.Depth = depth
|
||||||
g.NewLevel()
|
g.NewLevel()
|
||||||
|
|||||||
30
game/rip.go
30
game/rip.go
@@ -2,23 +2,20 @@ package game
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rip.c — the fun ends: death or a total win.
|
// rip.c — the fun ends: death or a total win.
|
||||||
//
|
//
|
||||||
// The C functions here call exit(); the port panics with a gameEnd sentinel
|
// The C functions here call exit(). One game run is one process, so the
|
||||||
// that Run recovers, so the terminal is restored by normal unwinding.
|
// port does the same: myExit restores the terminal and exits directly.
|
||||||
|
|
||||||
// gameEnd is the sentinel carried by the panic that replaces my_exit().
|
// myExit leaves the process properly (main.c my_exit): it restores the
|
||||||
type gameEnd struct{}
|
// terminal and ends the process. Every C caller exited with status 0.
|
||||||
|
|
||||||
// myExit leaves the process properly (main.c my_exit): it unwinds to Run.
|
|
||||||
// Every C caller exited with status 0; abnormal exits panic for real.
|
|
||||||
func (g *RogueGame) myExit() {
|
func (g *RogueGame) myExit() {
|
||||||
g.Playing = false
|
g.scr.Fini()
|
||||||
|
os.Exit(0)
|
||||||
panic(gameEnd{})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// death does something really fun when he dies (rip.c death).
|
// death does something really fun when he dies (rip.c death).
|
||||||
@@ -255,18 +252,9 @@ func (g *RogueGame) killname(monst byte, doart bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeathDemo implements the -d command line option (main.c): burn some
|
// 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() {
|
func (g *RogueGame) DeathDemo() {
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
if _, ok := r.(gameEnd); ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
panic(r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
dnum := g.rnd(100)
|
dnum := g.rnd(100)
|
||||||
for dnum--; dnum > 0; dnum-- {
|
for dnum--; dnum > 0; dnum-- {
|
||||||
g.rnd(100)
|
g.rnd(100)
|
||||||
|
|||||||
248
game/run_test.go
248
game/run_test.go
@@ -1,27 +1,75 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRunScriptedSession drives a complete game through Run(): a few
|
// fortify makes the hero effectively immortal for a crash-sweep drive:
|
||||||
// moves, a rest, an inventory, then Q-quit answered yes.
|
// game-over now calls myExit and os.Exit(0) (step 8), which would kill the
|
||||||
func TestRunScriptedSession(t *testing.T) {
|
// test binary, so every death vector is neutralized. Re-applied each turn
|
||||||
tt := &testTerm{input: []byte("hjkl.i Qy")}
|
// because combat, digestion, freezing, and level drain chip away at these.
|
||||||
|
func fortify(g *RogueGame) {
|
||||||
g := NewGame(Config{Seed: 99, Term: tt})
|
p := &g.Player
|
||||||
|
p.Stats.HP = 30000 // survive combat, arrow/dart traps, bolts
|
||||||
err := g.Run()
|
p.Stats.MaxHP = 30000 // survive vampire max-hp drain
|
||||||
if err != nil {
|
p.Stats.Exp = 30000 // survive wraith level drain (death when exp hits 0)
|
||||||
t.Fatalf("Run: %v", err)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.Playing {
|
// driveTurns runs the game's per-turn loop up to n times, doing the same
|
||||||
t.Error("still playing after quit")
|
// 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()
|
||||||
}
|
}
|
||||||
// After quitting, the scoreboard is the last thing shown (in C it went
|
}
|
||||||
// to stdout after endwin; here it is drawn on the screen).
|
|
||||||
|
// 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) {
|
||||||
|
// '>' is a free action (After=false), so it is followed by a paying
|
||||||
|
// rest ('.') to end the command() call; without a paying action the
|
||||||
|
// turn loop would spin forever on the auto-fed prompt input.
|
||||||
|
tt := &testTerm{input: []byte(">.")}
|
||||||
|
g := New(Params{Seed: 7, Term: tt})
|
||||||
|
g.NewLevel()
|
||||||
|
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
||||||
|
g.restored = true // keep startLevel from regenerating
|
||||||
|
g.Daemons = DaemonList{} // and give it a fresh daemon table
|
||||||
|
g.StartDaemon(DRunners, 0, After)
|
||||||
|
g.StartDaemon(DDoctor, 0, After)
|
||||||
|
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||||
|
g.StartDaemon(DStomach, 0, After)
|
||||||
|
|
||||||
|
driveTurns(t, g, 1)
|
||||||
|
|
||||||
|
if g.Depth != 2 {
|
||||||
|
t.Errorf("depth = %d after descending, want 2", g.Depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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
|
found := false
|
||||||
|
|
||||||
for y := range NumLines {
|
for y := range NumLines {
|
||||||
@@ -31,96 +79,126 @@ func TestRunScriptedSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("score list not on screen after quit")
|
t.Error("score list not on screen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunManyTurns mashes movement keys for a while as a crash sweep of
|
// TestDeepPlaythrough drives a fortified hero through the real command loop:
|
||||||
// the whole turn loop (daemons, hunger, monsters, combat), ending with a
|
// quaff/read/zap on the first level, then descend through the staircase to
|
||||||
// quit. The input alternates directions so the hero bumps around rooms.
|
// depth 8, saving and restoring mid-way. It is a crash sweep of the turn
|
||||||
func TestRunManyTurns(t *testing.T) {
|
// engine, deep level generation, item effects, and mid-game save/restore.
|
||||||
// Spaces between commands double as answers to any --More-- prompts;
|
// The hero is fortified so no death exits the process (step 8), and the fixed
|
||||||
// without them a single prompt would swallow the rest of the script
|
// seed keeps it deterministic.
|
||||||
// (wait_for eats everything that isn't a space).
|
func TestDeepPlaythrough(t *testing.T) {
|
||||||
moves := []byte("h j k l y u b n s .")
|
g := New(Params{Seed: 4242, Wizard: true, Term: &testTerm{}})
|
||||||
script := make([]byte, 0, len(moves)*200+7)
|
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) {
|
||||||
|
// 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 {
|
for range 200 {
|
||||||
script = append(script, moves...)
|
fortify(g)
|
||||||
|
g.command()
|
||||||
}
|
}
|
||||||
|
|
||||||
script = append(script, " Q y Qy"...)
|
if g.Player.Stats.HP <= 0 {
|
||||||
tt := &testTerm{input: script}
|
t.Errorf("seed %d: hero died despite fortify", seed)
|
||||||
|
|
||||||
g := NewGame(Config{Seed: 31337, Term: tt})
|
|
||||||
|
|
||||||
err := g.Run()
|
|
||||||
if 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
|
// saveAndRestore snapshots the game to a file, restores it, checks the key
|
||||||
// wizard style, then descends and keeps playing.
|
// state survived, and returns the restored game ready to keep playing.
|
||||||
func TestRunDownStairs(t *testing.T) {
|
func saveAndRestore(t *testing.T, g *RogueGame) *RogueGame {
|
||||||
tt := &testTerm{input: []byte(">..Qy")}
|
t.Helper()
|
||||||
g := NewGame(Config{Seed: 7, Term: tt})
|
|
||||||
g.NewLevel()
|
|
||||||
g.Player.Pos = g.Level.Stairs // stand on the stairs
|
|
||||||
g.restored = true // keep Run from regenerating the level
|
|
||||||
g.Daemons = DaemonList{} // and give it a fresh daemon table
|
|
||||||
g.StartDaemon(DRunners, 0, After)
|
|
||||||
g.StartDaemon(DDoctor, 0, After)
|
|
||||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
|
||||||
g.StartDaemon(DStomach, 0, After)
|
|
||||||
|
|
||||||
err := g.Run()
|
path := filepath.Join(t.TempDir(), "deep.save")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run: %v", err)
|
saveErr := g.saveFile(path)
|
||||||
|
if saveErr != nil {
|
||||||
|
t.Fatalf("saveFile: %v", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.Depth != 2 {
|
h, err := Restore(path, Params{Wizard: true, Term: &testTerm{}})
|
||||||
t.Errorf("depth = %d after descending, want 2", g.Depth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSaveCommandRoundTrip saves via the 'S' command (as a player would)
|
|
||||||
// and restores the game.
|
|
||||||
func TestSaveCommandRoundTrip(t *testing.T) {
|
|
||||||
// The C get_str caps input at MAXINP=50 characters, so the save path
|
|
||||||
// must be short: work from the temp directory.
|
|
||||||
t.Chdir(t.TempDir())
|
|
||||||
|
|
||||||
path := "cmd.save"
|
|
||||||
// 'S' with no default file name goes straight to the name prompt.
|
|
||||||
script := "S" + path + "\n"
|
|
||||||
tt := &testTerm{input: []byte(script)}
|
|
||||||
g := NewGame(Config{Seed: 55, Term: tt})
|
|
||||||
|
|
||||||
g.FileName = "" // force the name prompt
|
|
||||||
|
|
||||||
runErr := g.Run()
|
|
||||||
if runErr != nil {
|
|
||||||
t.Fatalf("Run: %v", runErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Restore: %v", err)
|
t.Fatalf("Restore: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
|
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
|
||||||
t.Error("restored game does not match saved game")
|
t.Errorf("restored game diverged: depth %d/%d purse %d/%d",
|
||||||
|
h.Depth, g.Depth, h.Player.Purse, g.Player.Purse)
|
||||||
}
|
}
|
||||||
// The restored game must be playable.
|
|
||||||
setInput(t, h, []byte("..Qy")...)
|
|
||||||
|
|
||||||
restoredErr := h.Run()
|
return h
|
||||||
if restoredErr != nil {
|
|
||||||
t.Fatalf("restored Run: %v", restoredErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -681,7 +681,7 @@ var ErrSaveOutOfDate = errors.New("sorry, saved game is out of date")
|
|||||||
|
|
||||||
// Restore restores a saved game from a file (save.c restore). The file is
|
// 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.
|
// deleted, as in C, to defeat restarting from the same save.
|
||||||
func Restore(path string, cfg Config) (*RogueGame, error) {
|
func Restore(path string, params Params) (*RogueGame, error) {
|
||||||
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
|
f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -704,12 +704,12 @@ func Restore(path string, cfg Config) (*RogueGame, error) {
|
|||||||
data: newGameData(),
|
data: newGameData(),
|
||||||
Rng: &Rng{},
|
Rng: &Rng{},
|
||||||
Playing: true,
|
Playing: true,
|
||||||
ScorePath: cfg.ScorePath,
|
ScorePath: params.ScorePath,
|
||||||
FileName: path,
|
FileName: path,
|
||||||
rogueOpts: cfg.RogueOpts,
|
rogueOpts: params.RogueOpts,
|
||||||
restored: true,
|
restored: true,
|
||||||
}
|
}
|
||||||
g.scr = NewScreen(cfg.Term)
|
g.scr = NewScreen(params.Term)
|
||||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||||
g.applySnapshot(&st)
|
g.applySnapshot(&st)
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("saveFile: %v", saveErr)
|
t.Fatalf("saveFile: %v", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
h, err := Restore(path, Params{Term: &testTerm{}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Restore: %v", err)
|
t.Fatalf("Restore: %v", err)
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ func TestRestoreRejectsWrongVersion(t *testing.T) {
|
|||||||
t.Fatal(closeErr)
|
t.Fatal(closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, restoreErr := Restore(path, Config{})
|
_, restoreErr := Restore(path, Params{})
|
||||||
if restoreErr == nil {
|
if restoreErr == nil {
|
||||||
t.Error("restore accepted an out-of-date save")
|
t.Error("restore accepted an out-of-date save")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ type Terminal interface {
|
|||||||
// ReadChar blocks for the next key, translated to Rogue's input bytes
|
// ReadChar blocks for the next key, translated to Rogue's input bytes
|
||||||
// (arrows become hjkl, control keys their C0 codes).
|
// (arrows become hjkl, control keys their C0 codes).
|
||||||
ReadChar() byte
|
ReadChar() byte
|
||||||
|
// 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.
|
// cell is one screen position.
|
||||||
@@ -213,6 +216,13 @@ func (s *Screen) Refresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fini restores the terminal device, if there is one (curses endwin).
|
||||||
|
func (s *Screen) Fini() {
|
||||||
|
if s.term != nil {
|
||||||
|
s.term.Fini()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
|
// RefreshWin pushes an arbitrary window to the device (curses wrefresh).
|
||||||
func (s *Screen) RefreshWin(w *Window) {
|
func (s *Screen) RefreshWin(w *Window) {
|
||||||
if s.term != nil {
|
if s.term != nil {
|
||||||
|
|||||||
98
game/seedcompat_test.go
Normal file
98
game/seedcompat_test.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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) {
|
||||||
|
golden, err := os.ReadFile("testdata/item_tables.golden")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read golden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// These must match the seeds the golden was generated from
|
||||||
|
// (testdata/README.md).
|
||||||
|
seeds := []int32{1, 42, 12345, 99999}
|
||||||
|
|
||||||
|
var got strings.Builder
|
||||||
|
|
||||||
|
for _, seed := range seeds {
|
||||||
|
g := New(Params{Seed: seed, Wizard: true})
|
||||||
|
got.WriteString(dumpItemTables(seed, g))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.String() != string(golden) {
|
||||||
|
t.Errorf("Go item tables diverge from the C reference at %s",
|
||||||
|
firstDiff(string(golden), got.String()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstDiff returns a description of the first line where want and got
|
||||||
|
// differ, for a readable failure.
|
||||||
|
func firstDiff(want, got string) string {
|
||||||
|
wl := strings.Split(want, "\n")
|
||||||
|
gl := strings.Split(got, "\n")
|
||||||
|
|
||||||
|
for i := 0; i < len(wl) || i < len(gl); i++ {
|
||||||
|
w, g := "", ""
|
||||||
|
if i < len(wl) {
|
||||||
|
w = wl[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < len(gl) {
|
||||||
|
g = gl[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if w != g {
|
||||||
|
return fmt.Sprintf("line %d: C=%q Go=%q", i+1, w, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "no line difference (trailing content?)"
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ func TestProbabilitiesSumTo100(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInitProbsCumulative(t *testing.T) {
|
func TestInitProbsCumulative(t *testing.T) {
|
||||||
g := NewGame(Config{Seed: 1})
|
g := New(Params{Seed: 1})
|
||||||
|
|
||||||
last := g.Items.Potions[NumPotionTypes-1].Prob
|
last := g.Items.Potions[NumPotionTypes-1].Prob
|
||||||
if last != 100 {
|
if last != 100 {
|
||||||
@@ -47,14 +47,14 @@ func TestInitProbsCumulative(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewGameRandomizesAppearances(t *testing.T) {
|
func TestNewGameRandomizesAppearances(t *testing.T) {
|
||||||
g := NewGame(Config{Seed: 12345})
|
g := New(Params{Seed: 12345})
|
||||||
|
|
||||||
checkPotionColors(t, g)
|
checkPotionColors(t, g)
|
||||||
checkScrollNames(t, g)
|
checkScrollNames(t, g)
|
||||||
checkWandMaterials(t, g)
|
checkWandMaterials(t, g)
|
||||||
|
|
||||||
// Determinism: same seed, same appearances.
|
// Determinism: same seed, same appearances.
|
||||||
h := NewGame(Config{Seed: 12345})
|
h := New(Params{Seed: 12345})
|
||||||
if h.Items != g.Items {
|
if h.Items != g.Items {
|
||||||
t.Error("two games with the same seed produced different item lore")
|
t.Error("two games with the same seed produced different item lore")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type testTerm struct {
|
|||||||
|
|
||||||
func (t *testTerm) Render(*Window) {}
|
func (t *testTerm) Render(*Window) {}
|
||||||
|
|
||||||
|
func (t *testTerm) Fini() {}
|
||||||
|
|
||||||
func (t *testTerm) ReadChar() byte {
|
func (t *testTerm) ReadChar() byte {
|
||||||
if t.pos < len(t.input) {
|
if t.pos < len(t.input) {
|
||||||
c := t.input[t.pos]
|
c := t.input[t.pos]
|
||||||
|
|||||||
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
|
||||||
Reference in New Issue
Block a user