Add ARCHITECTURE.md Part 2: Go OOP port design

Design for the Go port (module sneak.berlin/go/rogue):
- RogueGame instance type owns all former globals; no package globals
- THING union split into Creature (Player/Monster) and Object
- Slices replace THING linked lists; list.c is not ported
- Daemon function pointers become serializable DaemonID enum + dispatch
- tcell/v2 (sole third-party dep) replaces curses; Window cell buffers
  preserve the screen-as-data-structure idiom
- encoding/gob snapshot replaces the 2,134-line state.c serializer
- Exact-LCG RNG for seed-compatible dungeon generation, with golden tests
- Per-file porting map, naming conventions, porting order, drop list
This commit is contained in:
2026-07-06 18:17:48 +02:00
parent 91eeee09c5
commit 45dba95678

View File

@@ -882,3 +882,615 @@ typedef struct sc_ent SCORE;
``` ```
Declares `rd_score(SCORE*)` / `wr_score(SCORE*)` (implemented in save.c). Declares `rd_score(SCORE*)` / `wr_score(SCORE*)` (implemented in save.c).
---
# Part 2 — The Go Port Design
Module: **`sneak.berlin/go/rogue`**
## 1. Goals and principles
1. **Faithful gameplay.** The port reproduces Rogue 5.4.4 behavior exactly:
same dungeon generation for a given seed, same combat math, same item
tables, same messages. The unit of porting is the C function; every C
function becomes a Go method (or function) with a recognizable name, ported
line by line unless a rule below says otherwise.
2. **No globals.** All formerly-global state lives in a single instance type,
**`RogueGame`**, which owns per-game state and exposes the game's main
entrypoint (`Run`). Two `RogueGame` values are two independent games.
File-scope C statics (pagination counters, shadow copies, scratch buffers)
become fields too — either on `RogueGame` or on the subsystem struct that
replaces their file.
3. **Modern data structures.** Basic containers are NOT ported. The THING
doubly-linked lists become slices (`[]*Object`, `[]*Monster`). `list.c`
disappears; where the C code's shape depends on list semantics
(attach-to-front, detach), small compat helpers keep the ported call sites
readable. calloc/free vanish under GC.
4. **OOP where the domain has objects, not everywhere.** The THING union
splits into `Object` and `Creature` (with `Player` and `Monster` variants).
Behavior that is intrinsically about one entity (naming an object, testing
a flag) is a method on that entity. Everything that touches wider game
state (most of the game) is a method on `*RogueGame`, mirroring the C
call graph one-to-one.
5. **Standard library first.** Exactly one third-party dependency:
`github.com/gdamore/tcell/v2` — the de facto standard Go terminal cell
library — replacing curses. Everything else (saves, scores, RNG, signals,
options) is stdlib.
6. **Dead weight is dropped, not ported.** `mdport.c`'s 900-line keyboard
decoder (tcell events replace it), `xcrypt.c`'s DES (wizard mode gates on
an env var instead), load-average checks, tty dsusp-char juggling, and the
XOR save encryption all disappear. Each drop is documented in §9.
## 2. Repository and package layout
The C sources remain in the repository root as the reference implementation.
Go code lives in subdirectories (Go will not build a package directory that
contains foreign `.c` files):
```
go.mod module sneak.berlin/go/rogue
cmd/rogue/main.go package main — flag parsing, constructs RogueGame, calls Run
game/ package game — the port, one .go file per .c file
game.go RogueGame struct, NewGame, Run (main.c)
rng.go seeded LCG (main.c: rnd/roll + RN macro)
types.go Coord, Stats, flags, constants (rogue.h)
object.go Object type (rogue.h THING _o arm)
creature.go Creature/Monster/Player (rogue.h THING _t arm)
level.go Place, Level, map access (rogue.h places[] + macros)
tables.go monsters[26], obj_info tables, e_levels, rainbow/stones/wood/metal (extern.c)
init.go per-game randomization (init.c)
command.go command dispatch (command.c)
io.go message/status line (io.c)
screen.go Screen: tcell wrapper + Window (curses + mdport display shims)
newlevel.go level orchestration (new_level.c)
rooms.go room/maze generation (rooms.c)
passages.go corridors (passages.c)
move.go player movement, traps (move.c)
chase.go monster AI (chase.c)
monsters.go monster creation (monsters.c)
fight.go combat (fight.c)
things.go item naming/creation (things.c)
pack.go inventory (pack.c)
potions.go, scrolls.go, rings.go, sticks.go, weapons.go, armor.go
misc.go look(), eat, direction input (misc.c)
daemon.go scheduler (daemon.c)
daemons.go the 11 callbacks (daemons.c)
options.go options screen + ROGUEOPTS (options.c)
wizard.go wizard commands, identify (wizard.c)
rip.go tombstone, victory, scores (rip.c)
save.go save/restore via gob (save.c + state.c)
score.go scoreboard (score.h + rip.c/save.c score I/O)
```
`mach_dep.c`, `mdport.c`, `xcrypt.c`, `state.c`, `list.c`, `vers.c` have no
counterpart files: their few live responsibilities are absorbed (signals →
`game.go`; score file locking → `score.go`; version string → `game.go`;
serialization → `save.go`).
## 3. The `RogueGame` type
`RogueGame` is deliberately a god object: it is the C global namespace given
a receiver. Fields are grouped into embedded sub-structs where a subsystem is
coherent, but everything is reachable from `g`.
```go
// RogueGame is one complete game of Rogue: all state that was global in the
// C implementation, plus the terminal it plays on. Construct with NewGame,
// then call Run.
type RogueGame struct {
// --- identity / RNG ---
Rng *Rng // the LCG; seed == C `seed`
Dnum int // dungeon number (the seed as shown to the user)
Whoami string // player name
Fruit string // favorite fruit
Home string // home directory (save default)
FileName string // save file path
Wizard bool // debug mode
NoScore bool // wizard was used / scoring disabled
// --- the world ---
Player Player
Level Level // map, rooms, passages, level objects, monsters
Depth int // C `level`
MaxDepth int // C `max_level`
Amulet bool
SeenStairs bool
// --- turn/command engine ---
Playing bool
After bool // this action consumes a turn
Again bool // repeating last command
Count int // command repeat count
NoCommand int // turns asleep
NoMove int // turns held
Quiet int // consecutive restful turns (heal rate)
Running bool
RunCh byte
DoorStop, Firstmove, MoveOn bool
ToDeath, Kamikaze, HasHit bool
MaxHit int
Take byte // glyph of thing picked up this move
Delta Coord // last direction from GetDir
DirCh byte
LastComm, LastDir byte // command repeat state ('a' command)
LLastComm, LLastDir byte
LastPick, LLastPick *Object
countCh, direction, newCount byte // command.c statics (as bool for newCount)
// --- daemons/fuses ---
Daemons DaemonList // d_list[20] + `between` counter
// --- messages / UI ---
scr *Screen // tcell-backed terminal (stdscr + hw scratch window)
Msgs MsgLine // msgbuf/newpos/mpos/huh/save state
Options Options // terse, jump, seefloor, passgo, flush, tombstone, inv_type...
StatMsg bool
InvDescribe bool
Oldpos Coord // hero position before last look()
Oldrp *Room
// --- item identity (per-game randomized appearance + discovery) ---
Items ItemLore // p_colors, s_names, r_stones, ws_made, ws_type,
// the 7 ObjInfo tables, `group` counter
// --- scores ---
LastScore int
AllScore bool
ScorePath string
}
```
Player-centric C globals move onto `Player`, world-centric onto `Level`
(see §4/§5); the remainder sit directly on `RogueGame` with the original
semantics. The command.c/io.c/things.c file-statics become lowercase fields
of `RogueGame`, `MsgLine`, and the inventory pagination helper respectively.
### Entrypoints
```go
func NewGame(opts Config) *RogueGame // Config: seed, name, ROGUEOPTS string, score path, wizard
func (g *RogueGame) Run() error // main.c main()+playit(): init tables, first level, daemons, loop
func (g *RogueGame) command() // one turn (command.c)
func Restore(path string, opts Config) (*RogueGame, error) // save.c restore()
```
`cmd/rogue/main.go` is a thin shell: flags (`-s` scores, `-d` death demo,
save-file arg, `SEED`/`ROGUEOPTS` env), `NewGame(...)` or `Restore(...)`,
`Run()`, exit code.
## 4. Core types
### 4.1 Geometry and stats
```go
type Coord struct{ X, Y int } // value type; C `ce(a,b)` == `a == b`
type Stats struct {
Str int // str_t; 3..31
Exp int
Lvl int
Arm int // armor class, lower is better
HP int // s_hpt
Dmg string // damage dice "1x4/1x2" — kept as string, parsed by rollEm
MaxHP int
}
```
### 4.2 The THING union → two types
```go
// Creature is the _t arm of the C THING union: the player or a monster.
type Creature struct {
Pos Coord
Turn bool // slowed: is it our turn
Type byte // 'A'..'Z' monster letter; '@' for the player
Disguise byte // what a xeroc shows as
OldCh byte // map char underneath
Dest *Coord // chase target — aliases hero pos, another monster's pos, or room gold
Flags CreatureFlags
Stats Stats
Room *Room
Pack []*Object
}
type Monster struct{ Creature }
// Player embeds Creature and owns the player-only state that was global in C.
type Player struct {
Creature
CurArmor *Object // worn armor
CurWeapon *Object // wielded weapon
CurRing [2]*Object // LEFT, RIGHT
PackUsed [26]bool // inventory letters in use
Inpack int // pack item count
Purse int // gold
FoodLeft int
HungryState int // 0..3
NoFood int // levels since food seen
MaxStats Stats // potential (restore-strength target)
VfHit int // venus flytrap grip counter
}
```
The `hero`/`pstats`/`pack`/`proom`/`max_hp` macros become
`g.Player.Pos` / `g.Player.Stats` / `g.Player.Pack` / `g.Player.Room` /
`g.Player.Stats.MaxHP`.
`Dest *Coord` keeps the C aliasing on purpose: monster targeting works by
pointer identity (`m.Dest == &g.Player.Pos` means "hunting the hero"), which
both the AI and the save format rely on.
### 4.3 Object
```go
type Object struct {
Type byte // POTION '!', SCROLL '?', WEAPON ')', ...
Pos Coord
Text string // scroll gibberish
Launch int // launcher weapon index, -1 none
PackCh byte // inventory letter
Damage string // wield dice
HurlDmg string // thrown dice
Count int // stack size
Which int // subtype (P_HEALING, MACE, R_PROTECT, ...)
HPlus, DPlus int
Arm int // OVERLOADED, as in C: armor class | charges | gold value
Flags ObjFlags
Group int // stack group id
Label string // player-applied name
}
func (o *Object) Charges() int { return o.Arm } // sticks
func (o *Object) SetCharges(n int) { o.Arm = n }
func (o *Object) GoldVal() int { return o.Arm } // gold piles
```
The overload stays (one field, named accessors) so ported arithmetic like
"rust: `o.Arm++`" keeps its original shape.
### 4.4 Flags
```go
type CreatureFlags uint32
type ObjFlags uint32
type RoomFlags uint16
type PlaceFlags uint8
const (
CanHuh CreatureFlags = 1 << iota // creature can confuse
CanSee; IsBlind; IsCanc; IsFound; IsGreed; IsHaste; IsTarget
IsHeld; IsHuh; IsInvis; IsMean; IsRegen; IsRun; IsFly; IsSlow
// hero aliases sharing C bit values are separate named constants:
IsLevit = IsCanc; IsHalu = IsMean; SeeMonst = IsFly
)
func (f CreatureFlags) Has(b CreatureFlags) bool
func (f *CreatureFlags) Set(b CreatureFlags)
func (f *CreatureFlags) Clear(b CreatureFlags)
```
C's `on(player, ISHALU)` ports to `g.Player.Flags.Has(IsHalu)`. The C bit
collisions (ISCANC/ISLEVIT, ISMEAN/ISHALU, ISFLY/SEEMONST) are preserved as
equal values — they are disjoint by owner (monster vs hero), and the save
format keeps working.
### 4.5 Level and Place
```go
type Place struct {
Ch byte // base map character
Flags PlaceFlags // FPass|FSeen|FDropped|FReal|FPnum|FTmask
Monst *Monster
}
// Level is the current dungeon level. It is reset in place by NewLevel,
// matching the C reuse of the global arrays.
type Level struct {
Places [MaxLines * MaxCols]Place // same column-major INDEX(y,x) layout
Rooms [MaxRooms]Room
Passages [MaxPass]Room
Objects []*Object // lvl_obj
Monsters []*Monster // mlist
Stairs Coord
NTraps int
}
func (l *Level) At(y, x int) *Place // INDEX
func (l *Level) Char(y, x int) byte // chat
func (l *Level) SetChar(y, x int, ch byte)
func (l *Level) FlagsAt(y, x int) *PlaceFlags // flat
func (l *Level) MonsterAt(y, x int) *Monster // moat
func (l *Level) VisibleChar(y, x int) byte // winat: disguise if monster, else Ch
```
### 4.6 Room
```go
type Room struct {
Pos Coord // upper-left
Max Coord // size
Gold Coord
GoldVal int
Flags RoomFlags // IsDark | IsGone | IsMaze
Exits []Coord // r_exit[12] + r_nexits → slice
}
```
### 4.7 Static tables
`tables.go` holds the immutable data as package-level **constants and
`var` tables that are never written after init** (the only permitted
package-level data — they are ROM, not state): `monsterTable [26]MonsterKind`
(name, carry %, flags, stats), `initDam` weapon table, base `objInfo` tables
(copied into `RogueGame.Items` at NewGame so per-game mutation — probability
resumming, `oi_know`, `oi_guess` — stays instance-local), `eLevels`,
`rainbow`, `stones`, `woods`, `metals`, `sylls`, trap names, help text,
`lvlMons`/`wandMons` spawn strings, `hNames`/`mNames` combat messages.
```go
type ObjInfo struct {
Name string
Prob int // cumulative after initProbs
Worth int
Guess string // player's "call it" label
Know bool
}
type ItemLore struct {
PotColors [MaxPotions]string // p_colors
ScrNames [MaxScrolls]string // s_names
RingStones [MaxRings]string // r_stones
WandMade [MaxSticks]string // ws_made
WandType [MaxSticks]string // ws_type ("wand"/"staff")
Things [NumThings]ObjInfo
Potions [MaxPotions]ObjInfo
Scrolls [MaxScrolls]ObjInfo
Rings [MaxRings]ObjInfo
Sticks [MaxSticks]ObjInfo
Weapons [MaxWeapons + 1]ObjInfo
Armors [MaxArmors]ObjInfo
Group int // stack group counter
}
```
## 5. Subsystems
### 5.1 RNG — exact LCG compatibility
```go
type Rng struct{ Seed int32 }
func (r *Rng) next() int { // the RN macro
r.Seed = r.Seed*11109 + 13849 // 32-bit wraparound, as C int
return int(r.Seed>>16) & 0xffff
}
func (g *RogueGame) Rnd(rng int) int // rnd(): 0 if range==0 else abs(RN)%range
func (g *RogueGame) Roll(n, sides int) int
func (g *RogueGame) Spread(n int) int // misc.c: n ± 10%
```
Same seed → same dungeon, byte for byte. This is the port's master
correctness oracle (§10).
### 5.2 Daemons and fuses — function pointers → identifiers
C identifies daemons by function pointer; the save format already maps them
to small integers. The port makes that mapping primary:
```go
type DaemonID int
const (
DRollwand DaemonID = iota + 1 // matches state.c save ids
DDoctor; DStomach; DRunners; DSwander
DNohaste; DUnconfuse; DUnsee; DSight
DVisuals; DComeDown; DLand // extra fuses beyond the C save map
)
type delayedAction struct {
Type int // EMPTY / BEFORE / AFTER
Func DaemonID
Arg int
Time int // DAEMON sentinel or fuse countdown
}
type DaemonList struct {
List [MaxDaemons]delayedAction
Between int // daemons.c rollwand static
}
func (g *RogueGame) StartDaemon(f DaemonID, arg, typ int)
func (g *RogueGame) Fuse(f DaemonID, arg, time, typ int)
func (g *RogueGame) Lengthen(f DaemonID, xtime int)
func (g *RogueGame) Extinguish(f DaemonID)
func (g *RogueGame) KillDaemon(f DaemonID)
func (g *RogueGame) DoDaemons(flag int) // dispatches via one switch on DaemonID
func (g *RogueGame) DoFuses(flag int)
```
The 11 callbacks in daemons.go become methods (`g.doctor()`, `g.stomach()`,
...) invoked from the dispatch switch. This is more robust than function
values because it is comparable and serializable — the two properties the C
design needed pointers for.
### 5.3 Screen — curses → tcell
```go
// Screen wraps tcell and exposes the curses vocabulary the ported code speaks.
type Screen struct {
T tcell.Screen
Std *Window // stdscr equivalent (the game map is drawn here)
Hw *Window // the scratch window for inventory/help overlays
}
// Window is an in-memory cell buffer with a cursor, standout attribute, and
// the handful of curses ops the game uses.
type Window struct{ ... }
func (w *Window) Move(y, x int); func (w *Window) AddCh(ch byte)
func (w *Window) MvAddCh(y, x int, ch byte); func (w *Window) MvAddStr(y, x int, s string)
func (w *Window) Printw(format string, a ...any); func (w *Window) Inch(y, x int) byte
func (w *Window) Clear(); func (w *Window) Clrtoeol(); func (w *Window) Standout(on bool)
func (s *Screen) Refresh(w *Window) // blit buffer to tcell
func (s *Screen) ReadChar() byte // event loop → C char codes (arrows→hjkl, ^C→quit)
```
Ported drawing code keeps its structure: `mvaddch(y, x, ch)`
`g.scr.Std.MvAddCh(y, x, ch)`. Curses `mvinch` reads come from the Window
buffer, preserving the "screen is a data structure" idiom without touching
the real terminal. `md_readchar`'s escape decoding is deleted; tcell's
`EventKey` provides decoded keys and we translate to the byte codes
`command()` already handles (KeyUp → 'k', etc.). SIGTSTP/resume and resize
are handled by tcell; SIGHUP/SIGTERM → `autoSave` via `os/signal`.
### 5.4 Messaging
```go
type MsgLine struct {
buf strings.Builder // msgbuf/newpos
Mpos int // cursor col on the message line
Huh string // last message (Ctrl-R repeat)
SaveMsg bool; LowerMsg bool; MsgEsc bool
}
func (g *RogueGame) Msg(format string, a ...any) bool // msg(); returns ESC-pressed
func (g *RogueGame) AddMsg(format string, a ...any)
func (g *RogueGame) EndMsg() bool
```
C's `%s`+`vowelstr` message assembly ports directly on `fmt`. The io.c
status-line shadow statics become fields of an unexported `statusCache`
struct on `RogueGame`.
### 5.5 Options
```go
type Options struct {
Terse, FightFlush, Jump, SeeFloor, PassGo, Tombstone bool
InvType int // INV_OVER / INV_SLOW / INV_CLEAR
}
```
`ROGUEOPTS` parsing (`parse_opts`) and the interactive option screen port
as-is; the C per-option get/put function pointers become a slice of option
descriptors holding closures.
### 5.6 Save/restore — gob replaces state.c
The 2,134-line hand-rolled serializer exists because C cannot reflect. Go
can: the save file is `encoding/gob` of a `SaveState` snapshot struct
(version-tagged, gzip-compressed). C-format saves are **not** readable — the
formats were never cross-version compatible anyway (the file embeds the
exact version string).
What still needs explicit code is exactly what state.c's `rs_fix_thing` did:
pointers that alias other structures are encoded as indices —
```go
type SaveState struct {
Version string
Game RogueGame // gob-friendly: exported fields
// pointer fixups:
CurArmorIdx, CurWeaponIdx int // index into Player.Pack, -1 = nil
CurRingIdx [2]int
MonstDest []DestRef // per monster: {Kind: hero|monster|object|gold, Index int}
PlaceMonst []int // per map cell: index into Level.Monsters
}
```
Save-file hygiene keeps the C behavior: the file is deleted on restore, and
`Run` exits after a successful save. The XOR encryption and symlink checks
are dropped (they were 1980s shared-machine anti-cheat).
### 5.7 Scoreboard
`SCORE``ScoreEnt` struct; the score file is a gob (or JSON) list at
`~/.rogue_scores` (overridable). Locking via `os.OpenFile(O_CREATE|O_EXCL)`
lock file with stale-age takeover, porting `lock_sc`/`unlock_sc` semantics.
Tombstone/victory screens port verbatim from rip.c.
## 6. C construct → Go construct map
| C construct | Go translation |
|---|---|
| global variable | `RogueGame` field (or Player/Level/subsystem field) |
| file-scope static | unexported field on `RogueGame` or subsystem struct |
| `THING` union | `Creature` / `Object` structs |
| linked lists (`l_next/l_prev`, attach/detach) | slices + `attach(&s, x)`-style helpers (prepend, remove by identity) |
| `coord*` aliasing (t_dest) | `*Coord` pointers into live structs (kept) |
| function pointers (daemons, options, nameit) | enum IDs + dispatch switch (daemons, save-relevant); closures (options, nameit) |
| `when`/`otherwise`/`until` macros | `case`/`default`/`for !cond` |
| `goto` (retry loops in pack.c, move.c, passages.c) | labeled loops / restructured `for` |
| char-indexed tables (`monsters[type-'A']`) | same, `monsterTable[t.Type-'A']` |
| damage strings `"3x4/1x2"` | kept as strings; parsed by `rollEm` (`strings`/`strconv`) |
| curses stdscr/hw windows | `Window` cell buffers over tcell |
| `mvinch` screen reads | `Window.Inch` from the buffer |
| signal handlers (SIGHUP autosave, SIGTSTP, SIGINT) | `os/signal` goroutine → channel checked in ReadChar; tcell handles TSTP/resize |
| `setjmp`-free exits (`my_exit`, `exit()` everywhere) | error returns from `Run` + a small `gameOver` sentinel; no `os.Exit` inside game logic |
| `vsprintf` message building | `fmt.Sprintf` |
| XOR-encrypted binary saves (state.c) | gob snapshot (§5.6) |
| DES wizard password | `ROGUE_WIZARD=1` env check |
| `md_*` platform shims | stdlib (`os`, `os/user`, `time`) or deleted |
**Exit discipline** deserves a note: the C code calls `exit()` from deep
inside call chains (death, victory, save). The port threads a
`g.gameOver(reason)` that sets `Playing=false` and unwinds via normal
returns to `Run` — the one structural change made everywhere, since
`os.Exit` would skip tcell teardown and defers.
## 7. Naming conventions
- C function `do_move` → method `(g *RogueGame) DoMove(dy, dx int)`; the C
name is preserved in CamelCase so cross-referencing Part 1 is mechanical.
Statics/internal helpers are lowerCamel (`g.doadd``g.addMsgV`).
- Entity-intrinsic functions become entity methods where they read no game
state: `inv_name(obj, drop)` needs ItemLore → `(g *RogueGame) InvName(o
*Object, drop bool) string`; but `is_magic(obj)``(o *Object) IsMagic()
bool`.
- Boolean C `bool`/`int` returns stay `bool`; `char` returns become `byte`.
- `coord*` out-params become `Coord` returns (or `(Coord, bool)` for
find-style functions like `fallpos`/`find_floor`).
## 8. Porting order (each step compiles, is committed, and is testable)
1. **Foundation:** `go.mod`, types.go, object.go, creature.go, level.go,
rng.go, tables.go, game.go skeleton. Unit tests: RNG sequence vs C
formula, table sanity (probabilities sum to 100).
2. **Dungeon generation:** rooms.go, passages.go, newlevel.go + enough of
misc.go/monsters.go/things.go to place things. Test: fixed-seed level
render to text, eyeballed against C build.
3. **Items:** things.go, pack.go, init.go + ItemLore.
4. **Creatures & combat:** monsters.go, chase.go, fight.go, daemon(s).go.
5. **UI:** screen.go, io.go, options.go, misc.go look(), command.go, move.go.
First playable build.
6. **Effects:** potions.go, scrolls.go, rings.go, sticks.go, weapons.go,
armor.go, wizard.go.
7. **Endgame:** rip.go, score.go, save.go, cmd/rogue. Full game loop, save,
scores.
## 9. Dropped C functionality (deliberate)
| Dropped | Why | Replacement |
|---|---|---|
| `md_readchar` escape decoding | tcell decodes keys | key-event translation table |
| XOR save/score encryption | obscurity, not security | plain gob (file perms 0600) |
| save-file symlink/hardlink checks | single-user era anti-cheat | none |
| DES crypt wizard password | ditto | `ROGUE_WIZARD` env var |
| load-average / user-count gating (`too_much`, `ucount`, CHECKTIME) | 1980s timesharing courtesy | none |
| tty dsusp/ltc character juggling | tcell owns the tty | none |
| shell escape (`!`) setuid dance | no privileges to drop | plain `os/exec` shell |
| curses window save in save file | screen is derivable | redraw on restore |
## 10. Testing strategy
- **RNG oracle:** golden test locking the LCG sequence for known seeds.
- **Generation goldens:** for a set of seeds, render generated levels
(map chars + room/passage metadata) to text fixtures; any diff = a porting
bug in rooms/passages/newlevel. Fixtures generated once from instrumented
C, or accepted from the first correct Go run and guarded thereafter.
- **Combat math tests:** `swing`/`rollEm` against hand-computed cases,
str_plus/add_dam table spot checks.
- **Determinism test:** scripted input sequence against a fixed seed must
produce identical final state across runs (guards hidden nondeterminism —
map iteration, time dependence).
- **Save round-trip:** NewGame → play N scripted turns → save → restore →
compare full state.
- `go vet` + `gofmt` clean at every commit.