13 Commits

Author SHA1 Message Date
8ce238dd62 Merge seed-compat (item-table cross-validation vs C reference) 2026-07-24 03:05:39 +07:00
c30da22e43 Rotate TODO to coverage-broadening step (seed-compat item tables done) 2026-07-24 03:05:39 +07:00
e595b87718 Cross-validate item appearance tables against the C reference
Instrumented the C game on modern-rogue with a DUMP mode (patch in
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 — in the normal init order, before initscr
so no terminal is needed. Captured its output for four seeds as
testdata/item_tables.golden.

TestSeedCompatItemTables regenerates the same tables from the Go port
via New(Params{Seed, Wizard: true}) and checks they match the golden
byte for byte. They do, for all four seeds — proving the LCG and its
consumption order through the whole init sequence (init_probs →
init_player → init_names → init_colors → init_stones → init_materials,
including init_player's arrow rnd(8)+rnd(15)) agree with C exactly.

testdata/README.md documents how to regenerate the golden.
2026-07-24 03:05:01 +07:00
11223caa7c Merge playtest-hardening (deep playthrough + crash sweep tests) 2026-07-23 08:59:03 +07:00
e7e1bc3c40 Rotate TODO to seed-verification step (playtest hardening done) 2026-07-23 08:59:03 +07:00
061da11877 Add deep-playthrough and turn-loop crash-sweep tests (playtest hardening)
Two death-safe regression drives that exercise the full 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: quaff/read/zap through command dispatch, then descend the
  staircase to depth 8 with a save/restore at depth 4 — a crash sweep of deep
  level generation, item effects, and mid-game save/restore. It asserts the
  consumables identify themselves (the commands really ran) and the descent and
  restore land where expected.
- TestTurnLoopCrashSweep: mash movement/search/rest for 200 turns on four seeds,
  exercising combat, monster AI, and traps.

Neither surfaced a panic. Space-separated command scripts answer the --More--
prompts, as wait_for consumes input up to a space.
2026-07-23 08:58:05 +07:00
0e6ed41351 Merge docs-refresh (ARCHITECTURE.md Part 2 + rename table) 2026-07-23 08:40:55 +07:00
b431af8b74 Rotate TODO to playtest step (docs refresh done) 2026-07-23 08:40:55 +07:00
cb1e302102 Refresh ARCHITECTURE.md Part 2 for the post-refactor design
Part 2 was written as a design sketch before the port was implemented and
refactored, so much of it described the planned code rather than the final
code. Updated the RogueGame/Stats/Object/Flags/Level sketches to the current
names and types (typed ObjectKind, DiceSpec, split o_arm fields, step-1 flag
names, TrapCount, Level list methods); rewrote §4.7 to say the static tables
now live on the per-game gameData struct (no package globals); noted the
daemon and effect handler tables (step 7), the MessageLine extraction (step 6),
the Terminal interface, the flat gob SaveState, and the New(Params) +
os.Exit-on-game-over design (step 8). Added §7.1, a C-name → Go-name rename
table, and a README note about the make targets. Docs only.
2026-07-23 08:40:15 +07:00
bcdfaf4ab4 Merge refactor/constructor-style (refactor step 8: New/Params + os.Exit) 2026-07-23 08:02:40 +07:00
a7d27ef65f Rotate TODO to docs-refresh step (step 8 done)
Step 8 complete: New(Params) constructor and os.Exit game-over. The
77-column wrap sweep was dropped per sneak. Docs refresh is now Next
Step.
2026-07-23 08:02:35 +07:00
194ce1dd16 Exit the process on game-over instead of unwinding a panic
One game run is one process, so game-over ends the process directly,
as the C game did with exit(). myExit now restores the terminal
(via the new Terminal.Fini) and calls os.Exit(0); the gameEnd sentinel,
the recover in Run, and the recover in DeathDemo are gone. Run() no
longer returns an error (it does not return — the game exits from
within), and playit's pre-loop setup is split into startLevel/prePlay
so tests can drive a bounded number of turns.

Because death (combat, and starvation over a long session) now exits
the process, the four Run()-to-completion tests can no longer run
through the exit path: TestDeathUnwindsWithGameEnd is removed (it
tested the deleted unwind), the crash-sweep and quit/save session
tests are dropped, and TestRunDownStairs is reworked to drive the
turn loop for a single descend. Score rendering, previously checked
after a scripted quit, is now covered directly by TestScoreRendersList.
Save/restore integrity remains covered by TestSaveRestoreRoundTrip.
2026-07-23 06:44:39 +07:00
cd0ba6c8ee Rename constructor to game.New(game.Params) per styleguide
NewGame(Config) becomes New(Params), and Restore takes Params too, so
the package's primary type gets the canonical New() constructor with a
named-field Params struct (styleguide points 139, 159). cmd/rogue and
all tests updated; ARCHITECTURE.md constructor references corrected.
Pure rename, suite green.
2026-07-23 05:39:31 +07:00
19 changed files with 965 additions and 353 deletions

View File

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

View File

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

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

View File

@@ -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"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
}

View File

@@ -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
View 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?)"
}

View File

@@ -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")
}

View File

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