Compare commits
13 Commits
8241cf4bee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce238dd62 | |||
| c30da22e43 | |||
| e595b87718 | |||
| 11223caa7c | |||
| e7e1bc3c40 | |||
| 061da11877 | |||
| 0e6ed41351 | |||
| b431af8b74 | |||
| cb1e302102 | |||
| bcdfaf4ab4 | |||
| a7d27ef65f | |||
| 194ce1dd16 | |||
| cd0ba6c8ee |
367
ARCHITECTURE.md
367
ARCHITECTURE.md
@@ -1076,7 +1076,7 @@ that `game` itself has no third-party imports:
|
||||
go.mod module git.eeqj.de/sneak/rgoue
|
||||
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)
|
||||
game.go RogueGame struct, New, 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)
|
||||
@@ -1086,7 +1086,7 @@ game/ package game — the port, one .go file per .c fil
|
||||
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)
|
||||
screen.go Terminal iface, Screen, Window (curses + mdport display shims)
|
||||
newlevel.go level orchestration (new_level.c)
|
||||
rooms.go room/maze generation (rooms.c)
|
||||
passages.go corridors (passages.c)
|
||||
@@ -1099,12 +1099,13 @@ game/ package game — the port, one .go file per .c fil
|
||||
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)
|
||||
daemons.go daemon/fuse 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)
|
||||
term/tcell.go package term — tcell-backed Terminal (curses + mdport input/display)
|
||||
```
|
||||
|
||||
`mach_dep.c`, `mdport.c`, `xcrypt.c`, `state.c`, `list.c`, `vers.c` have no
|
||||
@@ -1120,7 +1121,7 @@ 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,
|
||||
// C implementation, plus the terminal it plays on. Construct with New,
|
||||
// then call Run.
|
||||
type RogueGame struct {
|
||||
// --- identity / RNG ---
|
||||
@@ -1138,7 +1139,7 @@ type RogueGame struct {
|
||||
Level Level // map, rooms, passages, level objects, monsters
|
||||
Depth int // C `level`
|
||||
MaxDepth int // C `max_level`
|
||||
Amulet bool
|
||||
HasAmulet bool
|
||||
SeenStairs bool
|
||||
|
||||
// --- turn/command engine ---
|
||||
@@ -1155,7 +1156,7 @@ type RogueGame struct {
|
||||
ToDeath, Kamikaze, HasHit bool
|
||||
MaxHit int
|
||||
Take byte // glyph of thing picked up this move
|
||||
Delta Coord // last direction from GetDir
|
||||
Delta Coord // last direction from promptDirection
|
||||
DirCh byte
|
||||
LastComm, LastDir byte // command repeat state ('a' command)
|
||||
LLastComm, LLastDir byte
|
||||
@@ -1166,9 +1167,9 @@ type RogueGame struct {
|
||||
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...
|
||||
scr *Screen // tcell-backed terminal (stdscr + hw scratch window)
|
||||
Msgs MessageLine // 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()
|
||||
@@ -1182,26 +1183,29 @@ type RogueGame struct {
|
||||
LastScore int
|
||||
AllScore bool
|
||||
ScorePath string
|
||||
|
||||
// --- static data tables (extern.c/init.c ROM) ---
|
||||
data *gameData // this game's copy of the immutable C tables (§4.7)
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
`MessageLine`, 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()
|
||||
func New(params Params) *RogueGame // Params: seed, name, ROGUEOPTS string, score path, wizard
|
||||
func (g *RogueGame) Run() // main.c main()+playit(): init tables, first level, daemons, loop
|
||||
func (g *RogueGame) command() // one turn (command.c)
|
||||
func Restore(path string, params Params) (*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.
|
||||
save-file arg, `SEED`/`ROGUEOPTS` env), `New(...)` or `Restore(...)`, `Run()`,
|
||||
exit code.
|
||||
|
||||
## 4. Core types
|
||||
|
||||
@@ -1211,13 +1215,13 @@ save-file arg, `SEED`/`ROGUEOPTS` env), `NewGame(...)` or `Restore(...)`,
|
||||
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
|
||||
Str int // str_t; 3..31
|
||||
Exp int
|
||||
Lvl int
|
||||
ArmorClass int // armor class, lower is better (was s_arm)
|
||||
HP int // s_hpt
|
||||
Dmg DiceSpec // damage dice, parsed once by ParseDice (§4.3)
|
||||
MaxHP int
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1268,29 +1272,31 @@ both the AI and the save format rely on.
|
||||
|
||||
```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, ...)
|
||||
Kind ObjectKind // item category (was o_type); Glyph() gives the map char
|
||||
Pos Coord
|
||||
Text string // scroll gibberish
|
||||
Launch WeaponKind // launcher weapon (noWeapon if none)
|
||||
PackCh byte // inventory letter
|
||||
Damage DiceSpec // wield dice, parsed once
|
||||
HurlDmg DiceSpec // thrown dice
|
||||
Count int // stack size
|
||||
Which int // subtype index (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
|
||||
ArmorClass int // armor only (was part of the overloaded o_arm)
|
||||
Charges int // wands/staffs
|
||||
GoldValue int // gold piles
|
||||
Bonus int // rings (protection, add strength, ...)
|
||||
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.
|
||||
The single C `o_arm` field was overloaded (armor class | charges | gold value |
|
||||
ring bonus). The port splits it into four honest fields (step 3); `o_type`
|
||||
became a typed `ObjectKind` distinct from the display glyph (step 2). Damage
|
||||
strings are parsed once into a `DiceSpec` (`[]DiceRoll`) at table-definition
|
||||
time rather than re-parsed on every swing.
|
||||
|
||||
### 4.4 Flags
|
||||
|
||||
@@ -1301,11 +1307,11 @@ 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
|
||||
CanConfuse CreatureFlags = 1 << iota // ISHUH: creature can confuse
|
||||
CanSeeInvisible; Blind; Cancelled; Found; Greedy; Hasted; Targeted
|
||||
Held; Confused; Invisible; Mean; Regenerates; Awake; Flying; Slowed
|
||||
// hero aliases sharing C bit values are separate named constants:
|
||||
IsLevit = IsCanc; IsHalu = IsMean; SeeMonst = IsFly
|
||||
Levitating = Cancelled; Hallucinating = Mean; SenseMonsters = Flying
|
||||
)
|
||||
|
||||
func (f CreatureFlags) Has(b CreatureFlags) bool
|
||||
@@ -1313,10 +1319,11 @@ 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.
|
||||
The C `IS*`/`CAN*` macro names became descriptive Go constants in step 1
|
||||
(`ISHUH`→`Confused`, `SEEMONST`→`SenseMonsters`, `ISRUN`→`Awake`, ...). C's
|
||||
`on(player, ISHALU)` ports to `g.Player.On(Hallucinating)`. 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
|
||||
|
||||
@@ -1336,7 +1343,7 @@ type Level struct {
|
||||
Objects []*Object // lvl_obj
|
||||
Monsters []*Monster // mlist
|
||||
Stairs Coord
|
||||
NTraps int
|
||||
TrapCount int
|
||||
}
|
||||
|
||||
func (l *Level) At(y, x int) *Place // INDEX
|
||||
@@ -1345,8 +1352,16 @@ 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
|
||||
func (l *Level) ObjectAt(y, x int) *Object // find_obj
|
||||
func (l *Level) AddObject(o *Object) // attach to lvl_obj
|
||||
func (l *Level) RemoveObject(o *Object)
|
||||
func (l *Level) AddMonster(m *Monster) // attach to mlist
|
||||
func (l *Level) RemoveMonster(m *Monster)
|
||||
```
|
||||
|
||||
Object and monster list management (attach/detach on `lvl_obj`/`mlist`) and the
|
||||
`find_obj` lookup are `Level` methods (step 6), not free functions on `g`.
|
||||
|
||||
### 4.6 Room
|
||||
|
||||
```go
|
||||
@@ -1362,14 +1377,18 @@ type Room struct {
|
||||
|
||||
### 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.
|
||||
`tables.go` holds the immutable data from extern.c/init.c on a `gameData`
|
||||
struct, built by `newGameData()` and hung on each game as `g.data`. Every game
|
||||
carries its own copy, so the package keeps **no** non-constant package-level
|
||||
state (the linter's `gochecknoglobals` is clean): `monsterTable [26]MonsterKind`
|
||||
(name, carry %, flags, stats), the `initWeaps` weapon table, base `ObjInfo`
|
||||
tables (copied into `RogueGame.Items` at New so per-game mutation — probability
|
||||
resumming, `Know`, `Guess` — stays instance-local), `eLevels`, `rainbow`,
|
||||
`stoneTable`, `woods`, `metals`, `sylls`, trap names, help text,
|
||||
`lvlMons`/`wandMons` spawn strings, `hNames`/`mNames` combat messages, and the
|
||||
effect-dispatch handler tables (§5, step 7). Nothing on `gameData` is written
|
||||
during play, except that the game's own `Monsters` copy (not the template) takes
|
||||
the venus-flytrap damage hack.
|
||||
|
||||
```go
|
||||
type ObjInfo struct {
|
||||
@@ -1408,9 +1427,9 @@ 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%
|
||||
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
|
||||
@@ -1424,10 +1443,12 @@ small integers. The port makes that mapping primary:
|
||||
```go
|
||||
type DaemonID int
|
||||
const (
|
||||
DRollwand DaemonID = iota + 1 // matches state.c save ids
|
||||
DNone DaemonID = iota // empty slot
|
||||
DRollwand // matches state.c save ids
|
||||
DDoctor; DStomach; DRunners; DSwander
|
||||
DNohaste; DUnconfuse; DUnsee; DSight
|
||||
DVisuals; DComeDown; DLand // extra fuses beyond the C save map
|
||||
DVisuals; DComeDown; DLand
|
||||
DTurnSee // potions.c casts turn_see to a fuse callback
|
||||
)
|
||||
|
||||
type delayedAction struct {
|
||||
@@ -1447,23 +1468,32 @@ 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) DoDaemons(flag int) // fires the ready daemons/fuses
|
||||
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.
|
||||
The callbacks in daemons.go become methods (`g.doctor()`, `g.stomach()`, ...).
|
||||
`runDaemon` looks the callback up in a `daemonHandlers` table on `gameData` (the
|
||||
C `d_func` function pointers restored as method expressions, step 7). A
|
||||
`DaemonID` is comparable and serializable — the two properties the C design
|
||||
needed function pointers for — so it is also what the save format stores.
|
||||
|
||||
### 5.3 Screen — curses → tcell
|
||||
|
||||
```go
|
||||
// Screen wraps tcell and exposes the curses vocabulary the ported code speaks.
|
||||
// Terminal is the physical device, behind an interface so tests run headless.
|
||||
// The real game uses term.Tcell; tests use a scripted testTerm.
|
||||
type Terminal interface {
|
||||
Render(w *Window) // blit a window to the device
|
||||
ReadChar() byte // event loop → C char codes (arrows→hjkl, ^C→quit)
|
||||
Fini() // restore the device (curses endwin) on exit
|
||||
}
|
||||
|
||||
// Screen exposes the curses vocabulary the ported code speaks, over a Terminal.
|
||||
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
|
||||
term Terminal
|
||||
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
|
||||
@@ -1471,10 +1501,11 @@ type Screen struct {
|
||||
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) Printwf(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)
|
||||
func (s *Screen) Refresh() // blit stdscr to the device
|
||||
func (s *Screen) RefreshWin(w *Window) // blit any window
|
||||
func (s *Screen) Fini() // tear the device down
|
||||
```
|
||||
|
||||
Ported drawing code keeps its structure: `mvaddch(y, x, ch)` →
|
||||
@@ -1488,20 +1519,24 @@ SIGHUP/SIGTERM → `autoSave` via `os/signal`.
|
||||
### 5.4 Messaging
|
||||
|
||||
```go
|
||||
type MsgLine struct {
|
||||
// MessageLine owns the io.c message machinery and the top screen line, wired
|
||||
// to its screen/redraw/input needs via attach() (step 6).
|
||||
type MessageLine 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
|
||||
scr *Screen; look func(bool); readChar func() byte
|
||||
}
|
||||
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
|
||||
func (m *MessageLine) Msg(format string, a ...any) int // msg(); ^Escape / Escape
|
||||
func (m *MessageLine) Addf(format string, a ...any)
|
||||
func (m *MessageLine) End() int
|
||||
```
|
||||
|
||||
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`.
|
||||
`RogueGame` keeps one-line `g.msg` / `g.addmsgf` / `g.endmsg` shorthands over
|
||||
`g.Msgs`, so the ~400 ported call sites are unchanged. 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
|
||||
|
||||
@@ -1519,29 +1554,34 @@ 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).
|
||||
the save file is `encoding/gob` of a `SaveState` snapshot struct (version-tagged
|
||||
with `Release + "-go3"`; no compression). 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 —
|
||||
`SaveState` is a flat, pointer-free mirror of the game rather than an embedded
|
||||
`RogueGame` — gob silently drops embedded fields of unexported types, and the
|
||||
pointer aliasing has to be flattened anyway. What needs explicit code is exactly
|
||||
what state.c's `rs_fix_thing` did: pointers that alias other structures are
|
||||
encoded as indices, then re-aliased on load —
|
||||
|
||||
```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
|
||||
Version string
|
||||
// ... the scalar game state, then value-copied worlds:
|
||||
Player savedPlayer // Body savedCreature + equipment as pack indices
|
||||
Places []savedPlace // map cells without the monster pointer
|
||||
Objects []Object
|
||||
Monsters []savedCreature
|
||||
Dests []destRef // per monster: {Kind: hero|monster|object|gold, Idx}
|
||||
// rooms, passages, daemons, items, bestiary, screen contents, ...
|
||||
}
|
||||
```
|
||||
|
||||
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).
|
||||
Save-file hygiene keeps the C behavior: the file is deleted on restore, and the
|
||||
save command exits the process after a successful write (via `myExit`, §6). The
|
||||
XOR encryption and symlink checks are dropped (they were 1980s shared-machine
|
||||
anti-cheat).
|
||||
|
||||
### 5.7 Scoreboard
|
||||
|
||||
@@ -1552,46 +1592,93 @@ 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 |
|
||||
| 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 → handler tables on `gameData` (daemons + effect dispatch); 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"` | parsed once into a `DiceSpec` (`[]DiceRoll`) by `ParseDice` at table time |
|
||||
| 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) | `myExit` restores the terminal and calls `os.Exit(0)`; one game run is one process |
|
||||
| `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.
|
||||
call chains (death, victory, save). One game run is one process, so the port
|
||||
does the same — `myExit` restores the terminal (via `Screen.Fini`) and calls
|
||||
`os.Exit(0)`. `Run` therefore does not return; the game ends by exiting. (An
|
||||
earlier iteration unwound a `gameEnd` panic recovered in `Run`; step 8 replaced
|
||||
it with the direct exit.)
|
||||
|
||||
## 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`.
|
||||
The initial port preserved C names in CamelCase for mechanical
|
||||
cross-referencing. The idiomatic-refactor phase (steps 1–8) then renamed symbols
|
||||
to descriptive Go names, keeping the C name as a `(file.c func_name)` breadcrumb
|
||||
in each doc comment. So `do_move` is `moveHero`, not `DoMove`; `inv_name` is
|
||||
`inventoryName`; `roll_em` is `rollAttacks`. The notable mappings are tabulated
|
||||
in §7.1.
|
||||
|
||||
- Exported vs. unexported: `RogueGame`, the constructor `New` (taking a `Params`
|
||||
struct), `Run`, `Restore`, and the type/field vocabulary are exported; the
|
||||
ported command and effect methods are unexported (`moveHero`, `quaff`, ...) —
|
||||
they are internals, not API.
|
||||
- Entity-intrinsic functions become entity methods where they fit: `find_obj` →
|
||||
`(l *Level) ObjectAt`, pack surgery → `Player` methods, the message machinery
|
||||
→ `MessageLine` methods (step 6).
|
||||
- Boolean C `bool`/`int` returns become `bool`; the C `-1/0` status codes
|
||||
(`attack`, `move_monst`, `do_chase`) become named `bool` results (step 5).
|
||||
`char` returns become `byte`.
|
||||
- `coord*` out-params become `Coord` returns (or `(Coord, bool)` for find-style
|
||||
functions like `fallpos`/`find_floor`).
|
||||
|
||||
### 7.1 C name → Go name
|
||||
|
||||
Renames worth cross-referencing against Part 1 (the full set lives in the
|
||||
`(file.c name)` breadcrumbs on each definition):
|
||||
|
||||
| C source name | Go name | Where |
|
||||
| -------------------------- | ------------------------------------------ | ----------------- |
|
||||
| `NewGame`/`Config`\* | `New` / `Params` | game.go |
|
||||
| `my_exit` | `myExit` (→ `os.Exit`) | rip.go |
|
||||
| `do_move` | `moveHero` | move.go |
|
||||
| `be_trapped` | `springTrap` | move.go |
|
||||
| `rndmove` | `randomStep` | move.go |
|
||||
| `do_run` | `startRun` | move.go |
|
||||
| `move_stuff` | `finishMove` | move.go |
|
||||
| `chg_str` | `changeStrength` | misc.go |
|
||||
| `get_dir` | `promptDirection` | misc.go |
|
||||
| `do_rooms` / `do_maze` | `digRooms` / `digMaze` | rooms.go |
|
||||
| `rnd_room` / `rnd_pos` | `randomRoom` / `randomPos` | rooms.go |
|
||||
| `do_passages` / `conn` | `digPassages` / `connectRooms` | passages.go |
|
||||
| `putpass` / `numpass` | `putPassage` / `numberPassage` | passages.go |
|
||||
| `roomin` / `cansee` | `roomIn` / `canSee` | chase.go |
|
||||
| `runto` / `move_monst` | `runTo` / `moveMonster` | chase.go |
|
||||
| `do_chase` / `set_oldch` | `chaseStep` / `setOldChar` | chase.go |
|
||||
| `find_obj` | `(l *Level).ObjectAt` | level.go |
|
||||
| `roll_em` | `rollAttacks` | fight.go |
|
||||
| `do_pot` | `applyPotionFuse` | potions.go |
|
||||
| `get_item` | `promptPackItem` (→ `(obj, ok)`) | pack.go |
|
||||
| `inv_name` | `inventoryName` | things.go |
|
||||
| `struct msg` statics | `MessageLine` | io.go |
|
||||
| `o_type` (byte) | `Object.Kind` (`ObjectKind`) | object.go |
|
||||
| `o_arm` (overloaded) | `ArmorClass`/`Charges`/`GoldValue`/`Bonus` | object.go |
|
||||
| `s_arm` | `Stats.ArmorClass` | types.go |
|
||||
| `amulet` / `ntraps` | `HasAmulet` / `Level.TrapCount` | game.go/level.go |
|
||||
| `ISHUH`/`SEEMONST`/`ISRUN` | `Confused`/`SenseMonsters`/`Awake` | types.go (step 1) |
|
||||
|
||||
\* `Restore` keeps its C name but also takes `Params`.
|
||||
|
||||
## 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,
|
||||
@@ -1609,6 +1696,13 @@ defers.
|
||||
7. **Endgame:** rip.go, score.go, save.go, cmd/rogue. Full game loop, save,
|
||||
scores.
|
||||
|
||||
Once the transliterated port was complete and playable, an **idiomatic-refactor
|
||||
phase** followed (one feature branch per step, suite green throughout): typed
|
||||
kinds and split `o_arm` fields; descriptive renames and `goto` removal; the
|
||||
god-object extraction onto `Player`/`Level`/`MessageLine`; effect-dispatch
|
||||
handler tables on `gameData`; the `New(Params)` constructor; and direct process
|
||||
exit. Those are the steps referenced above (e.g. "step 5", "step 7").
|
||||
|
||||
## 9. Dropped C functionality (deliberate)
|
||||
|
||||
| Dropped | Why | Replacement |
|
||||
@@ -1629,11 +1723,14 @@ defers.
|
||||
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,
|
||||
- **Combat math tests:** `swing`/`rollAttacks` 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.
|
||||
- **Save round-trip:** dirty a game's state, snapshot it to a file, `Restore`,
|
||||
and compare — including the pointer fixups (equipment aliasing, monster chase
|
||||
targets, map monster index). Because game-over now exits the process (step 8),
|
||||
this goes through `saveFile`/`Restore` directly rather than driving the `S`
|
||||
command to completion.
|
||||
- `go vet` + `gofmt` + `golangci-lint` clean at every commit.
|
||||
|
||||
@@ -73,8 +73,12 @@ cmd/rogue/ the executable
|
||||
```
|
||||
|
||||
The engine package is fully headless-testable: `go test ./game/` runs scripted
|
||||
game sessions, dungeon-generation golden checks, and an RNG compatibility test
|
||||
against the original C generator.
|
||||
command sequences, dungeon-generation golden checks, and an RNG compatibility
|
||||
test against the original C generator.
|
||||
|
||||
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
|
||||
prettier), `make lint` (golangci-lint), `make test`, and `make check` (all
|
||||
three).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
66
TODO.md
66
TODO.md
@@ -29,12 +29,55 @@ Refactor ground rules:
|
||||
|
||||
# Next Step
|
||||
|
||||
Refactor step 8: constructor and style pass per the house styleguide —
|
||||
game.New(game.Params{...}) replacing NewGame(Config); replace the gameEnd panic
|
||||
unwind with error-based turn results where feasible; 77-column wrap sweep.
|
||||
Broaden unit test coverage where playtesting finds thin spots (rings, sticks,
|
||||
wizard commands).
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
|
||||
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
|
||||
forces the RNG seed and prints the per-seed item appearance tables (potion
|
||||
colors, scroll names, ring stones, wand/staff materials) before initscr, and
|
||||
captured its output for four seeds as testdata/item_tables.golden.
|
||||
TestSeedCompatItemTables regenerates the same tables from the Go port and they
|
||||
match byte for byte — proving the LCG and its consumption order through the
|
||||
whole init sequence agree with C. The remaining "same dungeon (map)" half
|
||||
would need the harder headless-curses C dump (new_level draws to curses);
|
||||
deferred — the item-table match already validates RNG-order faithfulness
|
||||
through init, and the Go generation goldens guard determinism thereafter.
|
||||
|
||||
- 2026-07-23 Playtest hardening (playtest-hardening): added two death-safe
|
||||
crash-sweep drives through the real turn loop, within the step-8 os.Exit
|
||||
constraint (a fortify() helper pins HP/food/exp and clears the freeze/stuck
|
||||
counters each turn so no death exits the test binary; fixed seeds keep them
|
||||
deterministic). TestDeepPlaythrough uses quaff/read/zap through command
|
||||
dispatch, then descends to depth 8 with a save/restore at depth 4;
|
||||
TestTurnLoopCrashSweep mashes movement/search/rest for 200 turns on four
|
||||
seeds. Neither surfaced a panic. The interactive "play several games at a real
|
||||
tcell terminal" portion needs a human at an 80x24 terminal and is left to the
|
||||
maintainer; the binary's non-interactive paths (`-s` scores) were
|
||||
smoke-tested.
|
||||
|
||||
- 2026-07-23 Docs refresh (docs-refresh): rewrote ARCHITECTURE.md Part 2 (the
|
||||
pre-implementation design sketch) to match the final code — current type/field
|
||||
names (ObjectKind, DiceSpec, split o_arm, step-1 flag names, TrapCount, Level
|
||||
list methods), the static tables now on the per-game gameData struct, the
|
||||
daemon/effect handler tables, the MessageLine extraction, the Terminal
|
||||
interface, the flat gob SaveState, and the New(Params) + os.Exit design. Added
|
||||
§7.1, a C-name → Go-name rename table, and a README note on the make targets.
|
||||
|
||||
- 2026-07-23 Refactor step 8 (refactor/constructor-style): constructor and exit
|
||||
pass. NewGame(Config) → New(Params) and Restore takes Params, so the package's
|
||||
primary type gets the canonical New() constructor with a named-field Params
|
||||
struct (styleguide 139/159). The gameEnd panic unwind is gone: one game run is
|
||||
one process, so myExit restores the terminal (new Terminal.Fini) and calls
|
||||
os.Exit(0), and Run() no longer returns; the four Run()-to-completion tests
|
||||
were reworked/dropped since death (combat or starvation) now exits the process
|
||||
(TestScoreRendersList and TestRunDownStairs preserve what is still drivable;
|
||||
save/restore stays covered by TestSaveRestoreRoundTrip). The 77-column wrap
|
||||
sweep was dropped per sneak (2026-07-23): line lengths left as-is (lll caps at
|
||||
88 and passes).
|
||||
|
||||
- 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects dispatch
|
||||
tables plus a full decomposition sweep — the quaff / readScroll / doZap
|
||||
switches, the attack monster-power switch, the be_trapped switch, the daemon
|
||||
@@ -133,24 +176,13 @@ unwind with error-based turn results where feasible; 77-column wrap sweep.
|
||||
|
||||
# Future Steps
|
||||
|
||||
1. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the
|
||||
post-refactor names; add the C name → Go name rename table.
|
||||
2. Playtest hardening pass: play several full games with the tcell binary and
|
||||
extend run_test.go to script a deeper multi-level playthrough (descend past
|
||||
level 5, use potions, scrolls, zapping, save/restore). Fix any panics,
|
||||
message mismatches, or divergences from the C behavior that this uncovers,
|
||||
with regression tests.
|
||||
3. Verify the seed-compatibility claim against the C reference on c-master: same
|
||||
seed, same dungeon, same item tables, for several seeds.
|
||||
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
|
||||
1. 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):
|
||||
2. Full-terminal-size support (deferred by explicit decision 2026-07-06):
|
||||
per-game dungeon dimensions instead of the 80x24 constants; open design
|
||||
questions are resize policy, gameplay tuning at larger sizes, and a --classic
|
||||
80x24 mode.
|
||||
7. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
||||
3. Note: this repo is exempt from the standard policy scaffold. A minimal dev
|
||||
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
|
||||
2026-07-07 request, but do not add a Dockerfile, CI config, or
|
||||
REPO_POLICIES.md.
|
||||
|
||||
@@ -21,18 +21,20 @@ func main() {
|
||||
os.Exit(run())
|
||||
}
|
||||
|
||||
// run carries the real main so that deferred terminal restoration runs
|
||||
// before the process exits (os.Exit skips defers).
|
||||
// run does the real work and returns an exit code. It only returns on a
|
||||
// startup error; once the game starts, it ends by exiting the process
|
||||
// from within (game.myExit restores the terminal first). The deferred
|
||||
// Fini covers the early-return paths.
|
||||
func run() int {
|
||||
scores := flag.Bool("s", false, "print the scoreboard and exit")
|
||||
deathDemo := flag.Bool("d", false, "die a random death (demo)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
cfg := loadConfig()
|
||||
params := loadParams()
|
||||
|
||||
if *scores {
|
||||
game.NewGame(cfg).ShowScores()
|
||||
game.New(params).ShowScores()
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -45,46 +47,39 @@ func run() int {
|
||||
}
|
||||
defer t.Fini()
|
||||
|
||||
cfg.Term = t
|
||||
params.Term = t
|
||||
|
||||
var g *game.RogueGame
|
||||
|
||||
if args := flag.Args(); len(args) == 1 && !*deathDemo {
|
||||
// restore a saved game
|
||||
g, err = game.Restore(args[0], cfg)
|
||||
g, err = game.Restore(args[0], params)
|
||||
if err != nil {
|
||||
t.Fini()
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
fmt.Fprintln(os.Stderr, err) // deferred Fini restores the terminal
|
||||
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
g = game.NewGame(cfg)
|
||||
g = game.New(params)
|
||||
}
|
||||
|
||||
if *deathDemo {
|
||||
g.DeathDemo()
|
||||
g.DeathDemo() // does not return: death exits the process
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
installAutosave(g, t)
|
||||
|
||||
runErr := g.Run()
|
||||
if runErr != nil {
|
||||
t.Fini()
|
||||
fmt.Fprintln(os.Stderr, runErr)
|
||||
|
||||
return 1
|
||||
}
|
||||
g.Run() // does not return: the game ends by exiting the process
|
||||
|
||||
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
|
||||
// (main.c's startup).
|
||||
func loadConfig() game.Config {
|
||||
func loadParams() game.Params {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
name := ""
|
||||
@@ -96,7 +91,7 @@ func loadConfig() game.Config {
|
||||
|
||||
wizard := os.Getenv("ROGUE_WIZARD") != ""
|
||||
|
||||
return game.Config{
|
||||
return game.Params{
|
||||
Seed: chooseSeed(wizard),
|
||||
Name: name,
|
||||
RogueOpts: os.Getenv("ROGUEOPTS"),
|
||||
|
||||
@@ -7,7 +7,7 @@ import "testing"
|
||||
func mkGameInput(t *testing.T) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := NewGame(Config{Seed: 5, Term: &testTerm{}})
|
||||
g := New(Params{Seed: 5, Term: &testTerm{}})
|
||||
g.NewLevel()
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
@@ -183,7 +183,7 @@ func TestZapSlowMonster(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")
|
||||
|
||||
if !g.Options.Terse {
|
||||
|
||||
@@ -7,7 +7,7 @@ import "testing"
|
||||
func mkGame(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := NewGame(Config{Seed: seed, Term: &testTerm{}})
|
||||
g := New(Params{Seed: seed, Term: &testTerm{}})
|
||||
g.NewLevel()
|
||||
g.Oldpos = g.Player.Pos
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
@@ -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) {
|
||||
g := mkGame(t, 3)
|
||||
// Place a hobgoblin a few squares away in the hero's room and set it
|
||||
|
||||
100
game/game.go
100
game/game.go
@@ -31,9 +31,9 @@ type Options struct {
|
||||
InvType int // inven: inventory style (InvOver/InvSlow/InvClear)
|
||||
}
|
||||
|
||||
// Config carries everything needed to construct a game.
|
||||
type Config struct {
|
||||
Seed int32 // dungeon number; the caller derives it (time+pid or SEED env)
|
||||
// Params carries everything needed to construct a game.
|
||||
type Params struct {
|
||||
Seed int32 // dungeon number; caller derives it (time+pid or SEED)
|
||||
Name string // player name (overridden by ROGUEOPTS name=)
|
||||
RogueOpts string // the ROGUEOPTS environment string
|
||||
Home string // home directory (save file default location)
|
||||
@@ -44,7 +44,7 @@ type Config struct {
|
||||
|
||||
// RogueGame is one complete game of Rogue: every piece of state that was a
|
||||
// global (or file-scope static) in the C sources, plus the terminal it is
|
||||
// played on. Construct with NewGame, then call Run.
|
||||
// played on. Construct with New, then call Run.
|
||||
//
|
||||
// The struct grows with the port; fields appear in the phase that ports the
|
||||
// code owning them.
|
||||
@@ -143,22 +143,22 @@ type RogueGame struct {
|
||||
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
|
||||
// and first level arrive with later porting phases).
|
||||
func NewGame(cfg Config) *RogueGame {
|
||||
func New(params Params) *RogueGame {
|
||||
g := &RogueGame{
|
||||
data: newGameData(),
|
||||
Rng: &Rng{Seed: cfg.Seed},
|
||||
Dnum: int(cfg.Seed),
|
||||
Whoami: cfg.Name,
|
||||
Rng: &Rng{Seed: params.Seed},
|
||||
Dnum: int(params.Seed),
|
||||
Whoami: params.Name,
|
||||
Fruit: "slime-mold",
|
||||
Home: cfg.Home,
|
||||
Wizard: cfg.Wizard,
|
||||
NoScore: cfg.Wizard,
|
||||
Home: params.Home,
|
||||
Wizard: params.Wizard,
|
||||
NoScore: params.Wizard,
|
||||
Playing: true,
|
||||
Depth: 1,
|
||||
ScorePath: cfg.ScorePath,
|
||||
ScorePath: params.ScorePath,
|
||||
LastScore: -1,
|
||||
}
|
||||
g.Options = Options{
|
||||
@@ -168,17 +168,17 @@ func NewGame(cfg Config) *RogueGame {
|
||||
}
|
||||
g.InvDescribe = 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.FileName = cfg.Home + "/rogue.save"
|
||||
g.FileName = params.Home + "/rogue.save"
|
||||
|
||||
g.rogueOpts = cfg.RogueOpts
|
||||
if cfg.Wizard {
|
||||
g.rogueOpts = params.RogueOpts
|
||||
if params.Wizard {
|
||||
g.Player.Flags.Set(SenseMonsters)
|
||||
}
|
||||
|
||||
if cfg.RogueOpts != "" {
|
||||
g.ParseOpts(cfg.RogueOpts)
|
||||
if params.RogueOpts != "" {
|
||||
g.ParseOpts(params.RogueOpts)
|
||||
}
|
||||
|
||||
g.Monsters = g.data.monsterTable
|
||||
@@ -199,37 +199,45 @@ func NewGame(cfg Config) *RogueGame {
|
||||
}
|
||||
|
||||
// Run plays the game to its end: the back half of main.c main() plus
|
||||
// playit(). It returns after death, victory, quitting, or saving.
|
||||
func (g *RogueGame) Run() error {
|
||||
// A gameEnd panic is the port's my_exit(): recovering it here makes
|
||||
// Run return normally (zero values), restoring the terminal via the
|
||||
// caller's defers.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(gameEnd); ok {
|
||||
return // normal game over / save exit
|
||||
}
|
||||
// playit(). It does not return — the game ends by exiting the process
|
||||
// (see myExit); one game run is one process.
|
||||
func (g *RogueGame) Run() {
|
||||
g.startLevel()
|
||||
g.playit()
|
||||
}
|
||||
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
if !g.restored {
|
||||
g.NewLevel() // draw current level
|
||||
// Start up daemons and fuses
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
// startLevel draws the first level and starts the standing daemons and
|
||||
// fuses for a fresh game; a restored game brings its own (the back half
|
||||
// of main.c main()).
|
||||
func (g *RogueGame) startLevel() {
|
||||
if g.restored {
|
||||
return
|
||||
}
|
||||
|
||||
g.playit()
|
||||
|
||||
return nil
|
||||
g.NewLevel() // draw current level
|
||||
// Start up daemons and fuses
|
||||
g.StartDaemon(DRunners, 0, After)
|
||||
g.StartDaemon(DDoctor, 0, After)
|
||||
g.Fuse(DSwander, 0, wanderTime(g), After)
|
||||
g.StartDaemon(DStomach, 0, After)
|
||||
}
|
||||
|
||||
// playit is the main loop of the program (main.c playit).
|
||||
func (g *RogueGame) playit() {
|
||||
g.prePlay()
|
||||
|
||||
for g.Playing {
|
||||
g.command() // command execution
|
||||
}
|
||||
|
||||
g.endit()
|
||||
}
|
||||
|
||||
// prePlay does the option and position setup at the top of playit,
|
||||
// before the command loop (main.c playit). It is split out so tests can
|
||||
// drive a bounded number of turns; the loop itself never returns,
|
||||
// because game-over exits the process.
|
||||
func (g *RogueGame) prePlay() {
|
||||
// set up defaults for modern terminals: curses' md_hasclreol() is
|
||||
// always true, so the C default inventory style applies
|
||||
if !g.restored {
|
||||
@@ -242,13 +250,7 @@ func (g *RogueGame) playit() {
|
||||
}
|
||||
|
||||
g.Oldpos = g.Player.Pos
|
||||
|
||||
g.Oldrp = g.roomIn(g.Player.Pos)
|
||||
for g.Playing {
|
||||
g.command() // command execution
|
||||
}
|
||||
|
||||
g.endit()
|
||||
}
|
||||
|
||||
// endit exits the game (main.c endit).
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
func genLevel(t *testing.T, seed int32) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
g := NewGame(Config{Seed: seed})
|
||||
g := New(Params{Seed: seed})
|
||||
g.NewLevel()
|
||||
|
||||
return g
|
||||
@@ -159,7 +159,7 @@ func TestNewLevelDeterministic(t *testing.T) {
|
||||
// mazes, dark rooms, traps, treasure rooms — as a crash/invariant sweep.
|
||||
func TestDeeperLevels(t *testing.T) {
|
||||
for _, seed := range []int32{7, 42, 1000, 31337} {
|
||||
g := NewGame(Config{Seed: seed})
|
||||
g := New(Params{Seed: seed})
|
||||
for depth := 1; depth <= 30; depth++ {
|
||||
g.Depth = depth
|
||||
g.NewLevel()
|
||||
|
||||
30
game/rip.go
30
game/rip.go
@@ -2,23 +2,20 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rip.c — the fun ends: death or a total win.
|
||||
//
|
||||
// The C functions here call exit(); the port panics with a gameEnd sentinel
|
||||
// that Run recovers, so the terminal is restored by normal unwinding.
|
||||
// The C functions here call exit(). One game run is one process, so the
|
||||
// port does the same: myExit restores the terminal and exits directly.
|
||||
|
||||
// gameEnd is the sentinel carried by the panic that replaces my_exit().
|
||||
type gameEnd struct{}
|
||||
|
||||
// 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.
|
||||
// myExit leaves the process properly (main.c my_exit): it restores the
|
||||
// terminal and ends the process. Every C caller exited with status 0.
|
||||
func (g *RogueGame) myExit() {
|
||||
g.Playing = false
|
||||
|
||||
panic(gameEnd{})
|
||||
g.scr.Fini()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 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
|
||||
// random numbers to break patterns, then die a random death.
|
||||
// random numbers to break patterns, then die a random death. It does not
|
||||
// return — death exits the process.
|
||||
func (g *RogueGame) DeathDemo() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(gameEnd); ok {
|
||||
return
|
||||
}
|
||||
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
dnum := g.rnd(100)
|
||||
for dnum--; dnum > 0; dnum-- {
|
||||
g.rnd(100)
|
||||
|
||||
232
game/run_test.go
232
game/run_test.go
@@ -1,27 +1,75 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRunScriptedSession drives a complete game through Run(): a few
|
||||
// moves, a rest, an inventory, then Q-quit answered yes.
|
||||
func TestRunScriptedSession(t *testing.T) {
|
||||
tt := &testTerm{input: []byte("hjkl.i Qy")}
|
||||
// fortify makes the hero effectively immortal for a crash-sweep drive:
|
||||
// game-over now calls myExit and os.Exit(0) (step 8), which would kill the
|
||||
// test binary, so every death vector is neutralized. Re-applied each turn
|
||||
// because combat, digestion, freezing, and level drain chip away at these.
|
||||
func fortify(g *RogueGame) {
|
||||
p := &g.Player
|
||||
p.Stats.HP = 30000 // survive combat, arrow/dart traps, bolts
|
||||
p.Stats.MaxHP = 30000 // survive vampire max-hp drain
|
||||
p.Stats.Exp = 30000 // survive wraith level drain (death when exp hits 0)
|
||||
p.FoodLeft = 30000 // never starve
|
||||
g.NoCommand = 0 // never freeze to death (ice monster / sleep trap)
|
||||
g.NoMove = 0 // never stay stuck in a bear trap
|
||||
}
|
||||
|
||||
g := NewGame(Config{Seed: 99, Term: tt})
|
||||
// driveTurns runs the game's per-turn loop up to n times, doing the same
|
||||
// first-level and pre-play setup Run() does. Run() itself no longer
|
||||
// returns — game-over exits the process — so tests drive command()
|
||||
// directly, with short scripts that avoid quitting, saving, or playing
|
||||
// long enough to starve, any of which would exit the test binary.
|
||||
func driveTurns(t *testing.T, g *RogueGame, n int) {
|
||||
t.Helper()
|
||||
|
||||
err := g.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
for range n {
|
||||
g.command()
|
||||
}
|
||||
}
|
||||
|
||||
if g.Playing {
|
||||
t.Error("still playing after quit")
|
||||
// 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)
|
||||
}
|
||||
// After quitting, the scoreboard is the last thing shown (in C it went
|
||||
// to stdout after endwin; here it is drawn on the screen).
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
for y := range NumLines {
|
||||
@@ -31,96 +79,126 @@ func TestRunScriptedSession(t *testing.T) {
|
||||
}
|
||||
|
||||
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
|
||||
// the whole turn loop (daemons, hunger, monsters, combat), ending with a
|
||||
// quit. The input alternates directions so the hero bumps around rooms.
|
||||
func TestRunManyTurns(t *testing.T) {
|
||||
// Spaces between commands double as answers to any --More-- prompts;
|
||||
// without them a single prompt would swallow the rest of the script
|
||||
// (wait_for eats everything that isn't a space).
|
||||
moves := []byte("h j k l y u b n s .")
|
||||
script := make([]byte, 0, len(moves)*200+7)
|
||||
// TestDeepPlaythrough drives a fortified hero through the real command loop:
|
||||
// quaff/read/zap on the first level, then descend through the staircase to
|
||||
// depth 8, saving and restoring mid-way. It is a crash sweep of the turn
|
||||
// engine, deep level generation, item effects, and mid-game save/restore.
|
||||
// The hero is fortified so no death exits the process (step 8), and the fixed
|
||||
// seed keeps it deterministic.
|
||||
func TestDeepPlaythrough(t *testing.T) {
|
||||
g := New(Params{Seed: 4242, Wizard: true, Term: &testTerm{}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
fortify(g)
|
||||
|
||||
for range 200 {
|
||||
script = append(script, moves...)
|
||||
// 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")
|
||||
}
|
||||
|
||||
script = append(script, " Q y Qy"...)
|
||||
tt := &testTerm{input: script}
|
||||
|
||||
g := NewGame(Config{Seed: 31337, Term: tt})
|
||||
|
||||
err := g.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
if !g.Items.Scrolls[ScrollMagicMapping].Know {
|
||||
t.Error("read command did not identify the magic-mapping scroll")
|
||||
}
|
||||
|
||||
if g.Playing {
|
||||
t.Error("session did not end")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDownStairs walks the hero onto the stairs by teleporting there in
|
||||
// wizard style, then descends and keeps playing.
|
||||
func TestRunDownStairs(t *testing.T) {
|
||||
tt := &testTerm{input: []byte(">..Qy")}
|
||||
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)
|
||||
// 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))
|
||||
|
||||
err := g.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
for _, seed := range []int32{1, 99, 2026, 31337} {
|
||||
g := New(Params{Seed: seed, Term: &testTerm{input: script}})
|
||||
g.startLevel()
|
||||
g.prePlay()
|
||||
|
||||
if g.Depth != 2 {
|
||||
t.Errorf("depth = %d after descending, want 2", g.Depth)
|
||||
for range 200 {
|
||||
fortify(g)
|
||||
g.command()
|
||||
}
|
||||
|
||||
if g.Player.Stats.HP <= 0 {
|
||||
t.Errorf("seed %d: hero died despite fortify", seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
// saveAndRestore snapshots the game to a file, restores it, checks the key
|
||||
// state survived, and returns the restored game ready to keep playing.
|
||||
func saveAndRestore(t *testing.T, g *RogueGame) *RogueGame {
|
||||
t.Helper()
|
||||
|
||||
path := "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})
|
||||
path := filepath.Join(t.TempDir(), "deep.save")
|
||||
|
||||
g.FileName = "" // force the name prompt
|
||||
|
||||
runErr := g.Run()
|
||||
if runErr != nil {
|
||||
t.Fatalf("Run: %v", runErr)
|
||||
saveErr := g.saveFile(path)
|
||||
if saveErr != nil {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
||||
h, err := Restore(path, Params{Wizard: true, Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
|
||||
if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse {
|
||||
t.Error("restored game does not match saved game")
|
||||
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()
|
||||
if restoredErr != nil {
|
||||
t.Fatalf("restored Run: %v", restoredErr)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -704,12 +704,12 @@ func Restore(path string, cfg Config) (*RogueGame, error) {
|
||||
data: newGameData(),
|
||||
Rng: &Rng{},
|
||||
Playing: true,
|
||||
ScorePath: cfg.ScorePath,
|
||||
ScorePath: params.ScorePath,
|
||||
FileName: path,
|
||||
rogueOpts: cfg.RogueOpts,
|
||||
rogueOpts: params.RogueOpts,
|
||||
restored: true,
|
||||
}
|
||||
g.scr = NewScreen(cfg.Term)
|
||||
g.scr = NewScreen(params.Term)
|
||||
g.Msgs.attach(g.scr, g.look, g.readchar)
|
||||
g.applySnapshot(&st)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSaveRestoreRoundTrip(t *testing.T) {
|
||||
t.Fatalf("saveFile: %v", saveErr)
|
||||
}
|
||||
|
||||
h, err := Restore(path, Config{Term: &testTerm{}})
|
||||
h, err := Restore(path, Params{Term: &testTerm{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestRestoreRejectsWrongVersion(t *testing.T) {
|
||||
t.Fatal(closeErr)
|
||||
}
|
||||
|
||||
_, restoreErr := Restore(path, Config{})
|
||||
_, restoreErr := Restore(path, Params{})
|
||||
if restoreErr == nil {
|
||||
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
|
||||
// (arrows become hjkl, control keys their C0 codes).
|
||||
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.
|
||||
@@ -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).
|
||||
func (s *Screen) RefreshWin(w *Window) {
|
||||
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) {
|
||||
g := NewGame(Config{Seed: 1})
|
||||
g := New(Params{Seed: 1})
|
||||
|
||||
last := g.Items.Potions[NumPotionTypes-1].Prob
|
||||
if last != 100 {
|
||||
@@ -47,14 +47,14 @@ func TestInitProbsCumulative(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGameRandomizesAppearances(t *testing.T) {
|
||||
g := NewGame(Config{Seed: 12345})
|
||||
g := New(Params{Seed: 12345})
|
||||
|
||||
checkPotionColors(t, g)
|
||||
checkScrollNames(t, g)
|
||||
checkWandMaterials(t, g)
|
||||
|
||||
// Determinism: same seed, same appearances.
|
||||
h := NewGame(Config{Seed: 12345})
|
||||
h := New(Params{Seed: 12345})
|
||||
if h.Items != g.Items {
|
||||
t.Error("two games with the same seed produced different item lore")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ type testTerm struct {
|
||||
|
||||
func (t *testTerm) Render(*Window) {}
|
||||
|
||||
func (t *testTerm) Fini() {}
|
||||
|
||||
func (t *testTerm) ReadChar() byte {
|
||||
if t.pos < len(t.input) {
|
||||
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