From 41fc1042fc980fb95c9446856a4d635b07531946 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 6 Jul 2026 20:15:47 +0200 Subject: [PATCH] go: port command loop, save/restore, tcell terminal, and the binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - command.c in full: the command dispatcher with repeat counts, run prefixes, ctrl-run door-stop mode, fight-to-death targeting, and all wizard debug commands; search, help, identify, d_level/u_level, call, current - main.c completed: Run()/playit() with the C double ROGUEOPTS parse; exit()-anywhere becomes a gameEnd panic recovered in Run - save.c + state.c: gob SaveState snapshot with explicit pointer-to- index fixups for equipment, monster rooms, and chase targets (the rs_fix_thing role); save file deleted on restore as in C; SIGHUP/ SIGTERM autosave hook - wizard.c completed (create_obj, show_map), passages.c add_pass - term/: tcell/v2 Terminal — the one third-party dependency — replacing curses and mdport's 900-line key decoder; shell escape via suspend - cmd/rogue: flags -s/-d, SEED (wizard), ROGUEOPTS, ROGUE_WIZARD Tests: full scripted sessions through Run (quit, descend stairs, 3200-move crash sweep, save-command round trip). Notable fixes found by tests: gob silently drops embedded fields of unexported types, and --More-- prompts swallow space-free input scripts. --- cmd/rogue/main.go | 101 ++++++ game/command.go | 769 ++++++++++++++++++++++++++++++++++++++++++++++ game/game.go | 68 +++- game/level.go | 17 - game/passages.go | 32 ++ game/rip.go | 20 ++ game/run_test.go | 104 +++++++ game/save.go | 553 +++++++++++++++++++++++++++++++++ game/save_test.go | 112 +++++++ game/screen.go | 29 ++ game/wizard.go | 92 +++++- go.mod | 10 + go.sum | 45 +++ term/tcell.go | 135 ++++++++ 14 files changed, 2067 insertions(+), 20 deletions(-) create mode 100644 cmd/rogue/main.go create mode 100644 game/command.go create mode 100644 game/run_test.go create mode 100644 game/save.go create mode 100644 game/save_test.go create mode 100644 go.sum create mode 100644 term/tcell.go diff --git a/cmd/rogue/main.go b/cmd/rogue/main.go new file mode 100644 index 0000000..e6fe426 --- /dev/null +++ b/cmd/rogue/main.go @@ -0,0 +1,101 @@ +// Command rogue is the Go port of Rogue 5.4.4: Exploring the Dungeons of +// Doom. It is a faithful function-by-function port of the classic C game; +// see ARCHITECTURE.md at the repository root. +package main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "os/user" + "strconv" + "syscall" + "time" + + "sneak.berlin/go/rogue/game" + "sneak.berlin/go/rogue/term" +) + +func main() { + scores := flag.Bool("s", false, "print the scoreboard and exit") + deathDemo := flag.Bool("d", false, "die a random death (demo)") + flag.Parse() + + home, _ := os.UserHomeDir() + + // get options from environment (main.c) + rogueOpts := os.Getenv("ROGUEOPTS") + name := "" + if u, err := user.Current(); err == nil { + name = u.Username + } + + wizard := os.Getenv("ROGUE_WIZARD") != "" + // dungeon number: SEED for reproducible dungeons (wizard mode in C), + // else time+pid + var seed int32 + if env := os.Getenv("SEED"); env != "" && wizard { + n, _ := strconv.Atoi(env) + seed = int32(n) + } else { + seed = int32(time.Now().Unix()) + int32(os.Getpid()) + } + + cfg := game.Config{ + Seed: seed, + Name: name, + RogueOpts: rogueOpts, + Home: home, + ScorePath: home + "/.rogue.scores", + Wizard: wizard, + } + + if *scores { + g := game.NewGame(cfg) + g.ShowScores() + return + } + + t, err := term.New() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer t.Fini() + cfg.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) + if err != nil { + t.Fini() + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } else { + g = game.NewGame(cfg) + } + + if *deathDemo { + g.DeathDemo() + return + } + + // SIGHUP/SIGTERM autosave (save.c auto_save) + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGHUP, syscall.SIGTERM) + go func() { + <-sig + g.AutoSave() + t.Fini() + os.Exit(0) + }() + + if err := g.Run(); err != nil { + t.Fini() + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/game/command.go b/game/command.go new file mode 100644 index 0000000..4904947 --- /dev/null +++ b/game/command.go @@ -0,0 +1,769 @@ +package game + +// command.c — read and execute the user commands. + +// command processes one player command, with its BEFORE/AFTER daemon +// bracketing (command.c command). +func (g *RogueGame) command() { + p := &g.Player + ntimes := 1 // number of player moves + if p.On(IsHaste) { + ntimes++ + } + // Let the daemons start up + g.DoDaemons(Before) + g.DoFuses(Before) + for ; ntimes > 0; ntimes-- { + g.Again = false + if g.HasHit { + g.endmsg() + g.HasHit = false + } + // these are illegal things for the player to be, so if any are + // set, someone's been poking in memory + if p.On(IsSlow | IsGreed | IsInvis | IsRegen | IsTarget) { + panic("player flags corrupted") + } + + g.look(true) + if !g.Running { + g.DoorStop = false + } + g.status() + g.LastScore = p.Purse + g.move(p.Pos.Y, p.Pos.X) + if !((g.Running || g.Count != 0) && g.Options.Jump) { + g.refresh() // draw screen + } + g.Take = 0 + g.After = true + // Read command or continue run + if g.Wizard { + g.NoScore = true + } + var ch byte + if g.NoCommand == 0 { + if g.Running || g.ToDeath { + ch = g.RunCh + } else if g.Count != 0 { + ch = g.countCh + } else { + ch = g.readchar() + g.MoveOn = false + if g.Msgs.Mpos != 0 { // erase message if its there + g.msg("") + } + } + } else { + ch = '.' + } + if g.NoCommand != 0 { + if g.NoCommand--; g.NoCommand == 0 { + p.Flags.Set(IsRun) + g.msg("you can move again") + } + } else { + // check for prefixes + g.newCount = false + if isDigit(ch) { + g.Count = 0 + g.newCount = true + for isDigit(ch) { + g.Count = g.Count*10 + int(ch-'0') + if g.Count > 255 { + g.Count = 255 + } + ch = g.readchar() + } + g.countCh = ch + // turn off count for commands which don't make sense + // to repeat + switch ch { + case CTRL('B'), CTRL('H'), CTRL('J'), CTRL('K'), + CTRL('L'), CTRL('N'), CTRL('U'), CTRL('Y'), + '.', 'a', 'b', 'h', 'j', 'k', 'l', 'm', 'n', 'q', + 'r', 's', 't', 'u', 'y', 'z', 'B', 'C', 'H', 'I', + 'J', 'K', 'L', 'N', 'U', 'Y', + CTRL('D'), CTRL('A'): + default: + g.Count = 0 + } + } + // execute a command + if g.Count != 0 && !g.Running { + g.Count-- + } + if ch != 'a' && ch != Escape && + !(g.Running || g.Count != 0 || g.ToDeath) { + g.LLastComm = g.LastComm + g.LLastDir = g.LastDir + g.LLastPick = g.LastPick + g.LastComm = ch + g.LastDir = 0 + g.LastPick = nil + } + g.dispatch(ch) + // turn off flags if no longer needed + if !g.Running { + g.DoorStop = false + } + } + // If he ran into something to take, let him pick it up. + if g.Take != 0 { + g.pickUp(g.Take) + } + if !g.Running { + g.DoorStop = false + } + if !g.After { + ntimes++ + } + } + g.DoDaemons(After) + g.DoFuses(After) + if p.IsRing(Left, RSearch) { + g.search() + } else if p.IsRing(Left, RTeleport) && g.rnd(50) == 0 { + g.teleport() + } + if p.IsRing(Right, RSearch) { + g.search() + } else if p.IsRing(Right, RTeleport) && g.rnd(50) == 0 { + g.teleport() + } +} + +// dispatch runs one command character (the big switch in command.c, +// including its `goto over` re-dispatch). +func (g *RogueGame) dispatch(ch byte) { + p := &g.Player +over: + switch ch { + case ',': + var found *Object + for _, obj := range g.Level.Objects { + if obj.Pos.Y == p.Pos.Y && obj.Pos.X == p.Pos.X { + found = obj + break + } + } + if found != nil { + if !g.levitCheck() { + g.pickUp(found.Type) + } + } else { + if !g.Options.Terse { + g.addmsg("there is ") + } + g.addmsg("nothing here") + if !g.Options.Terse { + g.addmsg(" to pick up") + } + g.endmsg() + } + case '!': + g.shell() + case 'h': + g.doMove(0, -1) + case 'j': + g.doMove(1, 0) + case 'k': + g.doMove(-1, 0) + case 'l': + g.doMove(0, 1) + case 'y': + g.doMove(-1, -1) + case 'u': + g.doMove(-1, 1) + case 'b': + g.doMove(1, -1) + case 'n': + g.doMove(1, 1) + case 'H': + g.doRun('h') + case 'J': + g.doRun('j') + case 'K': + g.doRun('k') + case 'L': + g.doRun('l') + case 'Y': + g.doRun('y') + case 'U': + g.doRun('u') + case 'B': + g.doRun('b') + case 'N': + g.doRun('n') + case CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'), + CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'): + if !p.On(IsBlind) { + g.DoorStop = true + g.Firstmove = true + } + if g.Count != 0 && !g.newCount { + ch = g.direction + } else { + ch += 'A' - CTRL('A') + g.direction = ch + } + goto over + case 'F', 'f': + if ch == 'F' { + g.Kamikaze = true + } + if !g.getDir() { + g.After = false + break + } + g.Delta.Y += p.Pos.Y + g.Delta.X += p.Pos.X + mp := g.Level.MonsterAt(g.Delta.Y, g.Delta.X) + if mp == nil || (!g.seeMonst(mp) && !p.On(SeeMonst)) { + if !g.Options.Terse { + g.addmsg("I see ") + } + g.msg("no monster there") + g.After = false + } else if g.diagOk(p.Pos, g.Delta) { + g.ToDeath = true + g.MaxHit = 0 + mp.Flags.Set(IsTarget) + g.RunCh = g.DirCh + ch = g.DirCh + goto over + } + case 't': + if !g.getDir() { + g.After = false + } else { + g.missile(g.Delta.Y, g.Delta.X) + } + case 'a': + if g.LastComm == 0 { + g.msg("you haven't typed a command yet") + g.After = false + } else { + ch = g.LastComm + g.Again = true + goto over + } + case 'q': + g.quaff() + case 'Q': + g.After = false + g.QComm = true + g.quit(0) + g.QComm = false + case 'i': + g.After = false + g.inventory(p.Pack, 0) + case 'I': + g.After = false + g.pickyInven() + case 'd': + g.dropIt() + case 'r': + g.readScroll() + case 'e': + g.eat() + case 'w': + g.wield() + case 'W': + g.wear() + case 'T': + g.takeOff() + case 'P': + g.ringOn() + case 'R': + g.ringOff() + case 'o': + g.option() + g.After = false + case 'c': + g.call() + g.After = false + case '>': + g.After = false + g.dLevel() + case '<': + g.After = false + g.uLevel() + case '?': + g.After = false + g.help() + case '/': + g.After = false + g.identify() + case 's': + g.search() + case 'z': + if g.getDir() { + g.doZap() + } else { + g.After = false + } + case 'D': + g.After = false + g.discovered() + case CTRL('P'): + g.After = false + g.msg("%s", g.Msgs.Huh) + case CTRL('R'): + g.After = false + g.refresh() + case 'v': + g.After = false + g.msg("version %s. (mctesq was here)", Release) + case 'S': + g.After = false + g.saveGame() + case '.': + // rest command + case ' ': + g.After = false // "legal" illegal command + case '^': + g.After = false + if g.getDir() { + g.Delta.Y += p.Pos.Y + g.Delta.X += p.Pos.X + fp := g.Level.FlagsAt(g.Delta.Y, g.Delta.X) + if !g.Options.Terse { + g.addmsg("You have found ") + } + if g.Level.Char(g.Delta.Y, g.Delta.X) != Trap { + g.msg("no trap there") + } else if p.On(IsHalu) { + g.msg("%s", trName[g.rnd(NTraps)]) + } else { + g.msg("%s", trName[*fp&FTMask]) + fp.Set(FSeen) + } + } + case Escape: // escape + g.DoorStop = false + g.Count = 0 + g.After = false + g.Again = false + case 'm': + g.MoveOn = true + if !g.getDir() { + g.After = false + } else { + ch = g.DirCh + g.countCh = g.DirCh + goto over + } + case ')': + g.current(p.CurWeapon, "wielding", "") + case ']': + g.current(p.CurArmor, "wearing", "") + case '=': + g.current(p.CurRing[Left], "wearing", + g.chooseTerse("(L)", "on left hand")) + g.current(p.CurRing[Right], "wearing", + g.chooseTerse("(R)", "on right hand")) + case '@': + g.StatMsg = true + g.status() + g.StatMsg = false + g.After = false + default: + g.After = false + if g.Wizard { + g.wizardCommand(ch) + } else { + g.illcom(ch) + } + } +} + +// wizardCommand handles the MASTER debug commands (command.c). +func (g *RogueGame) wizardCommand(ch byte) { + p := &g.Player + switch ch { + case '|': + g.msg("@ %d,%d", p.Pos.Y, p.Pos.X) + case 'C': + g.createObj() + case '$': + g.msg("inpack = %d", p.Inpack) + case CTRL('G'): + g.inventory(g.Level.Objects, 0) + case CTRL('W'): + g.whatis(false, 0) + case CTRL('D'): + g.Depth++ + g.NewLevel() + case CTRL('A'): + g.Depth-- + g.NewLevel() + case CTRL('F'): + g.showMap() + case CTRL('T'): + g.teleport() + case CTRL('E'): + g.msg("food left: %d", p.FoodLeft) + case CTRL('C'): + g.addPass() + case CTRL('X'): + g.turnSee(p.On(SeeMonst)) + case CTRL('~'): + if item := g.getItem("charge", int(Stick)); item != nil { + item.SetCharges(10000) + } + case CTRL('I'): + for range 9 { + g.raiseLevel() + } + // Give him a sword (+1,+1) + obj := newObject() + g.initWeapon(obj, TwoSword) + obj.HPlus = 1 + obj.DPlus = 1 + g.addPack(obj, true) + p.CurWeapon = obj + // And his suit of armor + obj = newObject() + obj.Type = Armor + obj.Which = PlateMail + obj.Arm = -5 + obj.Flags.Set(IsKnow) + obj.Count = 1 + p.CurArmor = obj + g.addPack(obj, true) + default: + g.illcom(ch) + } +} + +// illcom reports an illegal command (command.c illcom). +func (g *RogueGame) illcom(ch byte) { + g.Msgs.SaveMsg = false + g.Count = 0 + g.msg("illegal command '%s'", unctrl(ch)) + g.Msgs.SaveMsg = true +} + +// search gropes about to find hidden things (command.c search). +func (g *RogueGame) search() { + p := &g.Player + ey := p.Pos.Y + 1 + ex := p.Pos.X + 1 + probinc := 0 + if p.On(IsHalu) { + probinc = 3 + } + if p.On(IsBlind) { + probinc += 2 + } + found := false + for y := p.Pos.Y - 1; y <= ey; y++ { + for x := p.Pos.X - 1; x <= ex; x++ { + if y == p.Pos.Y && x == p.Pos.X { + continue + } + fp := g.Level.FlagsAt(y, x) + if fp.Has(FReal) { + continue + } + foundone := false + switch g.Level.Char(y, x) { + case '|', '-': + if g.rnd(5+probinc) != 0 { + break + } + g.Level.SetChar(y, x, Door) + g.msg("a secret door") + foundone = true + case Floor: + if g.rnd(2+probinc) != 0 { + break + } + g.Level.SetChar(y, x, Trap) + if !g.Options.Terse { + g.addmsg("you found ") + } + if p.On(IsHalu) { + g.msg("%s", trName[g.rnd(NTraps)]) + } else { + g.msg("%s", trName[*fp&FTMask]) + fp.Set(FSeen) + } + foundone = true + case ' ': + if g.rnd(3+probinc) != 0 { + break + } + g.Level.SetChar(y, x, Passage) + foundone = true + } + if foundone { + found = true + fp.Set(FReal) + g.Count = 0 + g.Running = false + } + } + } + if found { + g.look(false) + } +} + +// help gives single character help, or the whole mess if he wants it +// (command.c help). +func (g *RogueGame) help() { + g.msg("character you want help for (* for all): ") + helpch := g.readchar() + g.Msgs.Mpos = 0 + // If it's not a *, print the right help string or an error if he + // typed a funny character. + if helpch != '*' { + g.move(0, 0) + for _, strp := range helpStr { + if strp.Ch == helpch { + g.Msgs.LowerMsg = true + g.msg("%s%s", unctrl(strp.Ch), strp.Desc) + g.Msgs.LowerMsg = false + return + } + } + g.msg("unknown character '%s'", unctrl(helpch)) + return + } + // Here we print help for everything, then wait before we return to + // command mode. + numprint := 0 + for _, strp := range helpStr { + if strp.Print { + numprint++ + } + } + if numprint&1 == 1 { // round odd numbers up + numprint++ + } + numprint /= 2 + if numprint > NumLines-1 { + numprint = NumLines - 1 + } + + hw := g.scr.Hw + hw.Clear() + cnt := 0 + for _, strp := range helpStr { + if !strp.Print { + continue + } + x := 0 + if cnt >= numprint { + x = NumCols / 2 + } + hw.Move(cnt%numprint, x) + if strp.Ch != 0 { + hw.AddStr(unctrl(strp.Ch)) + } + hw.AddStr(strp.Desc) + if cnt++; cnt >= numprint*2 { + break + } + } + hw.MvAddStr(NumLines-1, 0, "--Press space to continue--") + g.scr.RefreshWin(hw) + g.waitFor(' ') + g.msg("") + g.refresh() +} + +// identList is command.c's static ident_list. +var identList = []helpEntry{ + {'|', "wall of a room", false}, + {'-', "wall of a room", false}, + {Gold, "gold", false}, + {Stairs, "a staircase", false}, + {Door, "door", false}, + {Floor, "room floor", false}, + {PlayerCh, "you", false}, + {Passage, "passage", false}, + {Trap, "trap", false}, + {Potion, "potion", false}, + {Scroll, "scroll", false}, + {Food, "food", false}, + {Weapon, "weapon", false}, + {' ', "solid rock", false}, + {Armor, "armor", false}, + {Amulet, "the Amulet of Yendor", false}, + {Ring, "ring", false}, + {Stick, "wand or staff", false}, +} + +// identify tells the player what a certain thing is (command.c identify). +func (g *RogueGame) identify() { + g.msg("what do you want identified? ") + ch := g.readchar() + g.Msgs.Mpos = 0 + if ch == Escape { + g.msg("") + return + } + var str string + if isUpper(ch) { + str = g.Monsters[ch-'A'].Name + } else { + str = "unknown character" + for _, hp := range identList { + if hp.Ch == ch { + str = hp.Desc + break + } + } + } + g.msg("'%s': %s", unctrl(ch), str) +} + +// dLevel: he wants to go down a level (command.c d_level). +func (g *RogueGame) dLevel() { + if g.levitCheck() { + return + } + if g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X) != Stairs { + g.msg("I see no way down") + } else { + g.Depth++ + g.SeenStairs = false + g.NewLevel() + } +} + +// uLevel: he wants to go up a level (command.c u_level). +func (g *RogueGame) uLevel() { + if g.levitCheck() { + return + } + if g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X) == Stairs { + if g.HasAmulet { + g.Depth-- + if g.Depth == 0 { + g.totalWinner() + } + g.NewLevel() + g.msg("you feel a wrenching sensation in your gut") + } else { + g.msg("your way is magically blocked") + } + } else { + g.msg("I see no way up") + } +} + +// levitCheck checks whether she's levitating, and if she is, prints an +// appropriate message (command.c levit_check). +func (g *RogueGame) levitCheck() bool { + if !g.Player.On(IsLevit) { + return false + } + g.msg("You can't. You're floating off the ground!") + return true +} + +// call allows a user to call a potion, scroll, or ring something +// (command.c call). +func (g *RogueGame) call() { + obj := g.getItem("call", Callable) + // Make certain that it is something that we want to name + if obj == nil { + return + } + var op *ObjInfo + var elsewise string + var know *bool + var guess *string + it := &g.Items + switch obj.Type { + case Ring: + op = &it.Rings[obj.Which] + elsewise = it.RingStones[obj.Which] + case Potion: + op = &it.Potions[obj.Which] + elsewise = it.PotColors[obj.Which] + case Scroll: + op = &it.Scrolls[obj.Which] + elsewise = it.ScrNames[obj.Which] + case Stick: + op = &it.Sticks[obj.Which] + elsewise = it.WandMade[obj.Which] + case Food: + g.msg("you can't call that anything") + return + default: + guess = &obj.Label + elsewise = obj.Label + } + fromGuess := false + if op != nil { + know = &op.Know + guess = &op.Guess + if *guess != "" { + elsewise = *guess + fromGuess = true + } + } else { + fromGuess = elsewise != "" && elsewise == *guess + } + if know != nil && *know { + g.msg("that has already been identified") + return + } + if fromGuess { + if !g.Options.Terse { + g.addmsg("Was ") + } + g.msg("called \"%s\"", elsewise) + } + g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? ")) + + buf := elsewise + if g.getStr(&buf, g.scr.Std) == Norm { + *guess = buf + } +} + +// current prints the current weapon/armor (command.c current). +func (g *RogueGame) current(cur *Object, how, where string) { + g.After = false + if cur != nil { + if !g.Options.Terse { + g.addmsg("you are %s (", how) + } + g.InvDescribe = false + g.addmsg("%c) %s", cur.PackCh, g.invName(cur, true)) + g.InvDescribe = true + if where != "" { + g.addmsg(" %s", where) + } + g.endmsg() + } else { + if !g.Options.Terse { + g.addmsg("you are ") + } + g.addmsg("%s nothing", how) + if where != "" { + g.addmsg(" %s", where) + } + g.endmsg() + } +} + +// shell lets them escape for a while (main.c shell). The terminal decides +// how to actually suspend; headless terminals just decline. +func (g *RogueGame) shell() { + g.After = false + if se, ok := g.scr.term.(interface{ ShellEscape() }); ok { + g.InShell = true + se.ShellEscape() + g.InShell = false + g.refresh() + } else { + g.msg("shell escape is not available") + } +} diff --git a/game/game.go b/game/game.go index 60b0597..fbe416e 100644 --- a/game/game.go +++ b/game/game.go @@ -94,6 +94,10 @@ type RogueGame struct { LastPick *Object LLastPick *Object lastDelt Coord // misc.c get_dir static last_delt + // command.c statics + countCh byte + direction byte + newCount bool // dungeon generation working state maze mazeState // rooms.c maze statics @@ -131,6 +135,9 @@ type RogueGame struct { LastScore int AllScore bool ScorePath string + + rogueOpts string // the ROGUEOPTS string, re-parsed by playit as in C + restored bool // game came from a save file; Run skips setup } // NewGame builds a game from cfg, seeds the RNG, and randomizes the item @@ -159,10 +166,13 @@ func NewGame(cfg Config) *RogueGame { g.Msgs.SaveMsg = true g.scr = NewScreen(cfg.Term) g.FileName = cfg.Home + "/rogue.save" + g.rogueOpts = cfg.RogueOpts if cfg.Wizard { g.Player.Flags.Set(SeeMonst) } - // TODO(port): parse cfg.RogueOpts once options.go lands. + if cfg.RogueOpts != "" { + g.ParseOpts(cfg.RogueOpts) + } g.Monsters = monsterTable g.Items.Group = 2 // weapons.c: int group = 2 @@ -179,6 +189,62 @@ func NewGame(cfg Config) *RogueGame { return g } +// Run plays the game to its end: the back half of main.c main() plus +// playit(). It returns after death, victory, quitting, or saving. +func (g *RogueGame) Run() (err error) { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(gameEnd); ok { + return // normal game over / save exit + } + panic(r) + } + }() + if !g.restored { + g.NewLevel() // draw current level + // Start up daemons and fuses + g.StartDaemon(DRunners, 0, After) + g.StartDaemon(DDoctor, 0, After) + g.Fuse(DSwander, 0, wanderTime(g), After) + g.StartDaemon(DStomach, 0, After) + } + g.playit() + return nil +} + +// playit is the main loop of the program (main.c playit). +func (g *RogueGame) playit() { + // set up defaults for modern terminals: curses' md_hasclreol() is + // always true, so the C default inventory style applies + if !g.restored { + g.Options.InvType = InvClear + } + // parse environment declaration of options (C parses ROGUEOPTS again + // here, letting it override the terminal defaults) + if g.rogueOpts != "" { + g.ParseOpts(g.rogueOpts) + } + + 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). +func (g *RogueGame) endit() { + g.fatal("Okay, bye bye!\n") +} + +// fatal prints a message and leaves (main.c fatal). +func (g *RogueGame) fatal(s string) { + g.mvaddstr(NumLines-2, 0, s) + g.refresh() + g.myExit(0) +} + // quit has the player make certain, then exits (main.c quit). The final // scoring display arrives with the endgame phase. func (g *RogueGame) quit(int) { diff --git a/game/level.go b/game/level.go index 45da081..6c846ab 100644 --- a/game/level.go +++ b/game/level.go @@ -50,23 +50,6 @@ func (l *Level) VisibleChar(y, x int) byte { return l.Char(y, x) } -// reset clears the per-level state ahead of NewLevel drawing a fresh level. -func (l *Level) reset() { - for i := range l.Places { - l.Places[i] = Place{} - } - for i := range l.Rooms { - l.Rooms[i] = Room{} - } - for i := range l.Passages { - l.Passages[i] = Room{Flags: IsGone | IsDark} - } - l.Objects = nil - l.Monsters = nil - l.Stairs = Coord{} - l.NTraps = 0 -} - // goldCalc is the GOLDCALC macro: how much a gold pile is worth at depth. func (g *RogueGame) goldCalc() int { return g.rnd(50+10*g.Depth) + 2 diff --git a/game/passages.go b/game/passages.go index a7604bb..e5a0830 100644 --- a/game/passages.go +++ b/game/passages.go @@ -246,6 +246,38 @@ func (g *RogueGame) door(rm *Room, cp Coord) { } } +// addPass adds the passages to the current window — wizard command +// (passages.c add_pass). +func (g *RogueGame) addPass() { + for y := 1; y < NumLines-1; y++ { + for x := 0; x < NumCols; x++ { + pp := g.Level.At(y, x) + if pp.Flags.Has(FPass) || pp.Ch == Door || + (!pp.Flags.Has(FReal) && (pp.Ch == '|' || pp.Ch == '-')) { + ch := pp.Ch + if pp.Flags.Has(FPass) { + ch = Passage + } + pp.Flags.Set(FSeen) + g.move(y, x) + if pp.Monst != nil { + pp.Monst.OldCh = pp.Ch + } else if pp.Flags.Has(FReal) { + g.addch(ch) + } else { + g.standout() + if pp.Flags.Has(FPass) { + g.addch(Passage) + } else { + g.addch(Door) + } + g.standend() + } + } + } + } +} + // passnum assigns a number to each passageway (passages.c passnum). func (g *RogueGame) passnum() { g.pnum = 0 diff --git a/game/rip.go b/game/rip.go index 8498ac7..2228c26 100644 --- a/game/rip.go +++ b/game/rip.go @@ -216,6 +216,26 @@ func (g *RogueGame) killname(monst byte, doart bool) string { return sp } +// DeathDemo implements the -d command line option (main.c): burn some +// random numbers to break patterns, then die a random death. +func (g *RogueGame) DeathDemo() { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(gameEnd); ok { + return + } + panic(r) + } + }() + dnum := g.rnd(100) + for dnum--; dnum > 0; dnum-- { + g.rnd(100) + } + g.Player.Purse = g.rnd(100) + 1 + g.Depth = g.rnd(100) + 1 + g.death(g.deathMonst()) +} + // deathMonst returns a monster appropriate for a random death (rip.c // death_monst). func (g *RogueGame) deathMonst() byte { diff --git a/game/run_test.go b/game/run_test.go new file mode 100644 index 0000000..88136dd --- /dev/null +++ b/game/run_test.go @@ -0,0 +1,104 @@ +package game + +import ( + "strings" + "testing" +) + +// TestRunScriptedSession drives a complete game through Run(): a few +// moves, a rest, an inventory, then Q-quit answered yes. +func TestRunScriptedSession(t *testing.T) { + tt := &testTerm{input: []byte("hjkl.i Qy")} + g := NewGame(Config{Seed: 99, Term: tt}) + if err := g.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + if g.Playing { + t.Error("still playing after quit") + } + // After quitting, the scoreboard is the last thing shown (in C it went + // to stdout after endwin; here it is drawn on the screen). + found := false + for y := 0; y < NumLines; y++ { + if strings.Contains(g.scr.Std.Line(y), "Top Ten") { + found = true + } + } + if !found { + t.Error("score list not on screen after quit") + } +} + +// TestRunManyTurns mashes movement keys for a while as a crash sweep of +// the whole turn loop (daemons, hunger, monsters, combat), ending with a +// quit. The input alternates directions so the hero bumps around rooms. +func TestRunManyTurns(t *testing.T) { + // Spaces between commands double as answers to any --More-- prompts; + // without them a single prompt would swallow the rest of the script + // (wait_for eats everything that isn't a space). + var script []byte + moves := []byte("h h j j k k l l y u b n s s . . ") + for range 200 { + script = append(script, moves...) + } + script = append(script, " Q y Qy"...) + tt := &testTerm{input: script} + g := NewGame(Config{Seed: 31337, Term: tt}) + if err := g.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + if g.Playing { + t.Error("session did not end") + } +} + +// TestRunDownStairs walks the hero onto the stairs by teleporting there in +// wizard style, then descends and keeps playing. +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) + if err := g.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + if g.Depth != 2 { + t.Errorf("depth = %d after descending, want 2", g.Depth) + } +} + +// TestSaveCommandRoundTrip saves via the 'S' command (as a player would) +// and restores the game. +func TestSaveCommandRoundTrip(t *testing.T) { + // The C get_str caps input at MAXINP=50 characters, so the save path + // must be short: work from the temp directory. + t.Chdir(t.TempDir()) + path := "cmd.save" + // 'S' with no default file name goes straight to the name prompt. + script := "S" + path + "\n" + tt := &testTerm{input: []byte(script)} + g := NewGame(Config{Seed: 55, Term: tt}) + g.FileName = "" // force the name prompt + if err := g.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + + h, err := Restore(path, Config{Term: &testTerm{}}) + if err != nil { + t.Fatalf("Restore: %v", err) + } + if h.Depth != g.Depth || h.Player.Purse != g.Player.Purse { + t.Error("restored game does not match saved game") + } + // The restored game must be playable. + h.scr.term.(*testTerm).input = []byte("..Qy") + if err := h.Run(); err != nil { + t.Fatalf("restored Run: %v", err) + } +} diff --git a/game/save.go b/game/save.go new file mode 100644 index 0000000..599074e --- /dev/null +++ b/game/save.go @@ -0,0 +1,553 @@ +package game + +import ( + "encoding/gob" + "fmt" + "os" +) + +// save.c + state.c — game persistence. The hand-written, XOR-encrypted C +// serializer becomes a gob snapshot; what remains hand-written is exactly +// what state.c's rs_fix_thing did: pointers that alias other structures +// (equipment, chase targets, room membership) are encoded as indices. + +// destRef encodes a monster's chase target (state.c rs_write_thing's +// (kind, index) pairs). +type destRef struct { + Kind int // 0 none, 1 hero, 2 monster index, 3 level-object index, 4 room gold + Idx int +} + +// savedCreature is the pointer-free image of a Creature. +type savedCreature struct { + Pos Coord + Turn bool + Type byte + Disguise byte + OldCh byte + Flags CreatureFlags + Stats Stats + RoomIdx int // 0..MaxRooms-1; 100+i for passages; -1 none + Pack []Object +} + +// savedPlayer adds the player-only state. The creature half is a named +// field, not an embedded one: gob drops embedded fields of unexported +// types silently. +type savedPlayer struct { + Body savedCreature + CurArmor int // pack indices; -1 = none + CurWeapon int + CurRing [2]int + PackUsed [26]bool + Inpack int + Purse int + FoodLeft int + HungryState int + NoFood int + MaxStats Stats + VfHit int +} + +// savedPlace is one map cell without its monster pointer. +type savedPlace struct { + Ch byte + Flags PlaceFlags +} + +// SaveState is the complete serialized game (the field list mirrors +// state.c rs_save_file). +type SaveState struct { + Version string + + Seed int32 + Dnum int + Whoami string + Fruit string + Home string + Wizard bool + + Player savedPlayer + + Depth int + MaxDepth int + HasAmulet bool + SeenStairs bool + + Places []savedPlace + Rooms [MaxRooms]Room + Passages [MaxPass]Room + Objects []Object + Monsters []savedCreature + Dests []destRef // parallel to Monsters + Stairs Coord + NTraps int + + After bool + Again bool + NoScoreF bool + Count int + NoCommand int + NoMove int + Quiet int + Running bool + RunCh byte + DoorStop bool + Firstmove bool + MoveOn bool + ToDeath bool + Kamikaze bool + MaxHit int + Take byte + Delta Coord + DirCh byte + LastComm byte + LastDir byte + LastPick int // pack index, -1 + Oldpos Coord + OldrpIdx int + + Daemons DaemonList + Options Options + Items ItemLore + Bestiary [26]MonsterKind + Huh string + + LastScore int + AllScore bool + + Screen []byte // the visible map (Std window contents) +} + +// roomIdx encodes a room pointer as an index (state.c find_room_coord). +func (g *RogueGame) roomIdx(rp *Room) int { + if rp == nil { + return -1 + } + for i := range g.Level.Rooms { + if rp == &g.Level.Rooms[i] { + return i + } + } + for i := range g.Level.Passages { + if rp == &g.Level.Passages[i] { + return 100 + i + } + } + return -1 +} + +// roomAt decodes a room index. +func (g *RogueGame) roomAt(i int) *Room { + switch { + case i >= 100: + return &g.Level.Passages[i-100] + case i >= 0: + return &g.Level.Rooms[i] + } + return nil +} + +// packIdx finds an object in the player's pack (state.c find_list_ptr). +func (g *RogueGame) packIdx(obj *Object) int { + if obj == nil { + return -1 + } + for i, o := range g.Player.Pack { + if o == obj { + return i + } + } + return -1 +} + +// snapshot captures the complete game state. +func (g *RogueGame) snapshot() *SaveState { + p := &g.Player + st := &SaveState{ + Version: Release, + Seed: g.Rng.Seed, + Dnum: g.Dnum, + Whoami: g.Whoami, + Fruit: g.Fruit, + Home: g.Home, + Wizard: g.Wizard, + Depth: g.Depth, + MaxDepth: g.MaxDepth, + HasAmulet: g.HasAmulet, + SeenStairs: g.SeenStairs, + Rooms: g.Level.Rooms, + Passages: g.Level.Passages, + Stairs: g.Level.Stairs, + NTraps: g.Level.NTraps, + After: g.After, + Again: g.Again, + NoScoreF: g.NoScore, + Count: g.Count, + NoCommand: g.NoCommand, + NoMove: g.NoMove, + Quiet: g.Quiet, + Running: g.Running, + RunCh: g.RunCh, + DoorStop: g.DoorStop, + Firstmove: g.Firstmove, + MoveOn: g.MoveOn, + ToDeath: g.ToDeath, + Kamikaze: g.Kamikaze, + MaxHit: g.MaxHit, + Take: g.Take, + Delta: g.Delta, + DirCh: g.DirCh, + LastComm: g.LastComm, + LastDir: g.LastDir, + LastPick: g.packIdx(g.LastPick), + Oldpos: g.Oldpos, + OldrpIdx: g.roomIdx(g.Oldrp), + Daemons: g.Daemons, + Options: g.Options, + Items: g.Items, + Bestiary: g.Monsters, + Huh: g.Msgs.Huh, + LastScore: g.LastScore, + AllScore: g.AllScore, + Screen: g.scr.Std.Contents(), + } + + // the map, sans monster pointers (rebuilt on load) + st.Places = make([]savedPlace, len(g.Level.Places)) + for i := range g.Level.Places { + st.Places[i] = savedPlace{ + Ch: g.Level.Places[i].Ch, + Flags: g.Level.Places[i].Flags, + } + } + + // level objects by value; remember their pointers for dest encoding + objAt := make(map[*Object]int, len(g.Level.Objects)) + for i, o := range g.Level.Objects { + st.Objects = append(st.Objects, *o) + objAt[o] = i + } + + // the player + st.Player = savedPlayer{ + Body: savedCreature{ + Pos: p.Pos, Turn: p.Turn, Type: p.Type, Disguise: p.Disguise, + OldCh: p.OldCh, Flags: p.Flags, Stats: p.Stats, + RoomIdx: g.roomIdx(p.Room), + }, + CurArmor: g.packIdx(p.CurArmor), + CurWeapon: g.packIdx(p.CurWeapon), + CurRing: [2]int{ + g.packIdx(p.CurRing[Left]), g.packIdx(p.CurRing[Right]), + }, + PackUsed: p.PackUsed, Inpack: p.Inpack, Purse: p.Purse, + FoodLeft: p.FoodLeft, HungryState: p.HungryState, NoFood: p.NoFood, + MaxStats: p.MaxStats, VfHit: p.VfHit, + } + for _, o := range p.Pack { + st.Player.Body.Pack = append(st.Player.Body.Pack, *o) + } + + // monsters, with chase targets as (kind, index) references + for _, m := range g.Level.Monsters { + sc := savedCreature{ + Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise, + OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats, + RoomIdx: g.roomIdx(m.Room), + } + for _, o := range m.Pack { + sc.Pack = append(sc.Pack, *o) + } + st.Monsters = append(st.Monsters, sc) + + ref := destRef{} + switch { + case m.Dest == nil: + case m.Dest == &p.Pos: + ref = destRef{Kind: 1} + default: + for mi, om := range g.Level.Monsters { + if m.Dest == &om.Pos { + ref = destRef{Kind: 2, Idx: mi} + } + } + if ref.Kind == 0 { + for _, oo := range g.Level.Objects { + if m.Dest == &oo.Pos { + ref = destRef{Kind: 3, Idx: objAt[oo]} + } + } + } + if ref.Kind == 0 { + for ri := range g.Level.Rooms { + if m.Dest == &g.Level.Rooms[ri].Gold { + ref = destRef{Kind: 4, Idx: ri} + } + } + } + } + st.Dests = append(st.Dests, ref) + } + return st +} + +// applySnapshot rebuilds live game state from a snapshot. +func (g *RogueGame) applySnapshot(st *SaveState) { + p := &g.Player + g.Rng.Seed = st.Seed + g.Dnum = st.Dnum + g.Whoami = st.Whoami + g.Fruit = st.Fruit + g.Home = st.Home + g.Wizard = st.Wizard + g.Depth = st.Depth + g.MaxDepth = st.MaxDepth + g.HasAmulet = st.HasAmulet + g.SeenStairs = st.SeenStairs + g.Level.Rooms = st.Rooms + g.Level.Passages = st.Passages + g.Level.Stairs = st.Stairs + g.Level.NTraps = st.NTraps + g.After = st.After + g.Again = st.Again + g.NoScore = st.NoScoreF + g.Count = st.Count + g.NoCommand = st.NoCommand + g.NoMove = st.NoMove + g.Quiet = st.Quiet + g.Running = st.Running + g.RunCh = st.RunCh + g.DoorStop = st.DoorStop + g.Firstmove = st.Firstmove + g.MoveOn = st.MoveOn + g.ToDeath = st.ToDeath + g.Kamikaze = st.Kamikaze + g.MaxHit = st.MaxHit + g.Take = st.Take + g.Delta = st.Delta + g.DirCh = st.DirCh + g.LastComm = st.LastComm + g.LastDir = st.LastDir + g.Oldpos = st.Oldpos + g.Oldrp = g.roomAt(st.OldrpIdx) + g.Daemons = st.Daemons + g.Options = st.Options + g.Items = st.Items + g.Monsters = st.Bestiary + g.Msgs.Huh = st.Huh + g.LastScore = st.LastScore + g.AllScore = st.AllScore + g.Playing = true + + for i := range g.Level.Places { + g.Level.Places[i] = Place{ + Ch: st.Places[i].Ch, + Flags: st.Places[i].Flags, + } + } + + // level objects + g.Level.Objects = nil + for i := range st.Objects { + o := st.Objects[i] + g.Level.Objects = append(g.Level.Objects, &o) + } + + // the player + sp := &st.Player + p.Pos = sp.Body.Pos + p.Turn = sp.Body.Turn + p.Type = sp.Body.Type + p.Disguise = sp.Body.Disguise + p.OldCh = sp.Body.OldCh + p.Flags = sp.Body.Flags + p.Stats = sp.Body.Stats + p.Room = g.roomAt(sp.Body.RoomIdx) + p.Pack = nil + for i := range sp.Body.Pack { + o := sp.Body.Pack[i] + p.Pack = append(p.Pack, &o) + } + pick := func(i int) *Object { + if i < 0 || i >= len(p.Pack) { + return nil + } + return p.Pack[i] + } + p.CurArmor = pick(sp.CurArmor) + p.CurWeapon = pick(sp.CurWeapon) + p.CurRing[Left] = pick(sp.CurRing[0]) + p.CurRing[Right] = pick(sp.CurRing[1]) + g.LastPick = pick(st.LastPick) + p.PackUsed = sp.PackUsed + p.Inpack = sp.Inpack + p.Purse = sp.Purse + p.FoodLeft = sp.FoodLeft + p.HungryState = sp.HungryState + p.NoFood = sp.NoFood + p.MaxStats = sp.MaxStats + p.VfHit = sp.VfHit + + // monsters, their map index, and their chase targets + g.Level.Monsters = nil + for i := range st.Monsters { + sc := &st.Monsters[i] + m := &Monster{Creature: Creature{ + Pos: sc.Pos, Turn: sc.Turn, Type: sc.Type, Disguise: sc.Disguise, + OldCh: sc.OldCh, Flags: sc.Flags, Stats: sc.Stats, + Room: g.roomAt(sc.RoomIdx), + }} + for j := range sc.Pack { + o := sc.Pack[j] + m.Pack = append(m.Pack, &o) + } + g.Level.Monsters = append(g.Level.Monsters, m) + g.Level.SetMonsterAt(m.Pos.Y, m.Pos.X, m) + } + for i, ref := range st.Dests { + m := g.Level.Monsters[i] + switch ref.Kind { + case 1: + m.Dest = &p.Pos + case 2: + m.Dest = &g.Level.Monsters[ref.Idx].Pos + case 3: + m.Dest = &g.Level.Objects[ref.Idx].Pos + case 4: + m.Dest = &g.Level.Rooms[ref.Idx].Gold + } + } + + g.scr.Std.SetContents(st.Screen) +} + +// saveGame implements the "save game" command (save.c save_game). The C +// goto over/gotfile flow becomes the useDefault flag. +func (g *RogueGame) saveGame() { + g.Msgs.Mpos = 0 +over: + useDefault := false + if g.FileName != "" { + var c byte + for { + g.msg("save file (%s)? ", g.FileName) + c = g.readchar() + g.Msgs.Mpos = 0 + if c == Escape { + g.msg("") + return + } + if c == 'n' || c == 'N' || c == 'y' || c == 'Y' { + break + } + g.msg("please answer Y or N") + } + if c == 'y' || c == 'Y' { + g.addstr("Yes\n") + g.refresh() + useDefault = true + } + } + + for { + var buf string + if useDefault { + buf = g.FileName + useDefault = false + } else { + g.Msgs.Mpos = 0 + g.msg("file name: ") + if g.getStr(&buf, g.scr.Std) == Quit { + g.msg("") + return + } + g.Msgs.Mpos = 0 + } + // test to see if the file exists + if _, err := os.Stat(buf); err == nil { + for { + g.msg("File exists. Do you wish to overwrite it?") + g.Msgs.Mpos = 0 + c := g.readchar() + if c == Escape { + g.msg("") + return + } + if c == 'y' || c == 'Y' { + break + } + if c == 'n' || c == 'N' { + goto over + } + g.msg("Please answer Y or N") + } + g.msg("file name: %s", buf) + os.Remove(g.FileName) + } + g.FileName = buf + if err := g.saveFile(g.FileName); err != nil { + g.msg("%s", err.Error()) + continue + } + break + } + g.myExit(0) +} + +// saveFile writes the saved game (save.c save_file). +func (g *RogueGame) saveFile(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) + if err != nil { + return err + } + defer f.Close() + if err := gob.NewEncoder(f).Encode(g.snapshot()); err != nil { + os.Remove(path) + return err + } + return os.Chmod(path, 0o400) +} + +// AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM +// (save.c auto_save). +func (g *RogueGame) AutoSave() { + if g.FileName != "" { + os.Remove(g.FileName) + g.saveFile(g.FileName) + } +} + +// Restore restores a saved game from a file (save.c restore). The file is +// deleted, as in C, to defeat restarting from the same save. +func Restore(path string, cfg Config) (*RogueGame, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var st SaveState + if err := gob.NewDecoder(f).Decode(&st); err != nil { + return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w", path, err) + } + if st.Version != Release { + return nil, fmt.Errorf("sorry, saved game is out of date") + } + + g := &RogueGame{ + Rng: &Rng{}, + Playing: true, + ScorePath: cfg.ScorePath, + FileName: path, + rogueOpts: cfg.RogueOpts, + restored: true, + } + g.scr = NewScreen(cfg.Term) + g.applySnapshot(&st) + + // defeat multiple restarting from the same place + if err := os.Remove(path); err != nil { + return nil, fmt.Errorf("cannot unlink file: %w", err) + } + return g, nil +} diff --git a/game/save_test.go b/game/save_test.go new file mode 100644 index 0000000..8916283 --- /dev/null +++ b/game/save_test.go @@ -0,0 +1,112 @@ +package game + +import ( + "encoding/gob" + "os" + "path/filepath" + "testing" +) + +func TestSaveRestoreRoundTrip(t *testing.T) { + g := mkGame(t, 4242) + // Dirty up some state so the round trip is meaningful. + g.Player.Purse = 123 + g.Player.FoodLeft = 777 + g.HasAmulet = true + g.Items.Potions[PHealing].Know = true + g.Items.Scrolls[SMap].Guess = "map???" + g.Monsters['F'-'A'].Stats.Dmg = "3x1" // mutated bestiary must survive + if len(g.Level.Monsters) > 0 { + g.Level.Monsters[0].Flags.Set(IsRun) + g.Level.Monsters[0].Dest = &g.Player.Pos + } + + path := filepath.Join(t.TempDir(), "rogue.save") + if err := g.saveFile(path); err != nil { + t.Fatalf("saveFile: %v", err) + } + + h, err := Restore(path, Config{Term: &testTerm{}}) + if err != nil { + t.Fatalf("Restore: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("save file not deleted on restore (C anti-restart rule)") + } + + if h.Player.Purse != 123 || h.Player.FoodLeft != 777 { + t.Errorf("player state lost: purse=%d food=%d", + h.Player.Purse, h.Player.FoodLeft) + } + if !h.HasAmulet { + t.Error("amulet flag lost") + } + if !h.Items.Potions[PHealing].Know { + t.Error("potion identification lost") + } + if h.Items.Scrolls[SMap].Guess != "map???" { + t.Error("scroll guess lost") + } + if h.Monsters['F'-'A'].Stats.Dmg != "3x1" { + t.Error("mutated bestiary lost") + } + if h.Rng.Seed != g.Rng.Seed { + t.Error("RNG state lost") + } + if renderMap(h) != renderMap(g) { + t.Error("restored level map differs") + } + if len(h.Level.Monsters) != len(g.Level.Monsters) { + t.Fatalf("monster count %d != %d", + len(h.Level.Monsters), len(g.Level.Monsters)) + } + if len(g.Level.Monsters) > 0 { + m := h.Level.Monsters[0] + if m.Dest != &h.Player.Pos { + t.Error("monster chase target not re-aliased to the hero") + } + if h.Level.MonsterAt(m.Pos.Y, m.Pos.X) != m { + t.Error("map monster index not rebuilt") + } + if m.Room == nil { + t.Error("monster room pointer not rebuilt") + } + } + // Equipment aliasing: the wielded mace must be the same *Object as the + // one in the pack. + st := g.snapshot() + t.Logf("snapshot indices: weapon=%d armor=%d rings=%v packlen=%d", + st.Player.CurWeapon, st.Player.CurArmor, st.Player.CurRing, + len(st.Player.Body.Pack)) + t.Logf("restored: CurWeapon=%p pack has %d items", h.Player.CurWeapon, + len(h.Player.Pack)) + found := false + for i, o := range h.Player.Pack { + t.Logf(" pack[%d]=%p type=%c which=%d", i, o, o.Type, o.Which) + if o == h.Player.CurWeapon { + found = true + } + } + if !found { + t.Error("restored CurWeapon is not aliased into the pack") + } +} + +func TestRestoreRejectsWrongVersion(t *testing.T) { + g := mkGame(t, 1) + path := filepath.Join(t.TempDir(), "rogue.save") + st := g.snapshot() + st.Version = "0.0.0" + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := gob.NewEncoder(f).Encode(st); err != nil { + t.Fatal(err) + } + f.Close() + if _, err := Restore(path, Config{}); err == nil { + t.Error("restore accepted an out-of-date save") + } +} diff --git a/game/screen.go b/game/screen.go index a9446c2..5d310c2 100644 --- a/game/screen.go +++ b/game/screen.go @@ -138,6 +138,35 @@ func (w *Window) CopyFrom(src *Window) { copy(w.cells, src.cells) } +// Size reports the window dimensions. +func (w *Window) Size() (rows, cols int) { return w.rows, w.cols } + +// CellAt reports the character and standout attribute at a position; used +// by Terminal implementations to render the window. +func (w *Window) CellAt(y, x int) (ch byte, standout bool) { + c := w.at(y, x) + return c.ch, c.standout +} + +// Contents dumps the window characters row-major (the save file keeps the +// visible map, as the C game saved the curses screen). +func (w *Window) Contents() []byte { + out := make([]byte, len(w.cells)) + for i, c := range w.cells { + out[i] = c.ch + } + return out +} + +// SetContents restores a Contents dump. +func (w *Window) SetContents(data []byte) { + for i := range w.cells { + if i < len(data) { + w.cells[i] = cell{ch: data[i]} + } + } +} + // Line returns row y as a trimmed string; used by tests and the death/ // victory screens. func (w *Window) Line(y int) string { diff --git a/game/wizard.go b/game/wizard.go index 4ee19aa..d47f3ad 100644 --- a/game/wizard.go +++ b/game/wizard.go @@ -1,8 +1,96 @@ package game // wizard.c — special wizard commands, some of which are also non-wizard -// commands under strange circumstances. The MASTER-only debug commands -// (create_obj, show_map) arrive with the UI phase. +// commands under strange circumstances. The DES password check is not +// ported: wizard mode is enabled by configuration instead. + +// createObj is the wizard command for getting anything he wants (wizard.c +// create_obj). +func (g *RogueGame) createObj() { + obj := newObject() + g.msg("type of item: ") + obj.Type = g.readchar() + g.Msgs.Mpos = 0 + g.msg("which %c do you want? (0-f)", obj.Type) + ch := g.readchar() + if isDigit(ch) { + obj.Which = int(ch - '0') + } else { + obj.Which = int(ch-'a') + 10 + } + obj.Group = 0 + obj.Count = 1 + g.Msgs.Mpos = 0 + switch { + case obj.Type == Weapon || obj.Type == Armor: + g.msg("blessing? (+,-,n)") + bless := g.readchar() + g.Msgs.Mpos = 0 + if bless == '-' { + obj.Flags.Set(IsCursed) + } + if obj.Type == Weapon { + g.initWeapon(obj, obj.Which) + if bless == '-' { + obj.HPlus -= g.rnd(3) + 1 + } + if bless == '+' { + obj.HPlus += g.rnd(3) + 1 + } + } else { + obj.Arm = aClass[obj.Which] + if bless == '-' { + obj.Arm += g.rnd(3) + 1 + } + if bless == '+' { + obj.Arm -= g.rnd(3) + 1 + } + } + case obj.Type == Ring: + switch obj.Which { + case RProtect, RAddStr, RAddHit, RAddDam: + g.msg("blessing? (+,-,n)") + bless := g.readchar() + g.Msgs.Mpos = 0 + if bless == '-' { + obj.Flags.Set(IsCursed) + obj.Arm = -1 + } else { + obj.Arm = g.rnd(2) + 1 + } + case RAggr, RTeleport: + obj.Flags.Set(IsCursed) + } + case obj.Type == Stick: + g.fixStick(obj) + case obj.Type == Gold: + g.msg("how much?") + buf := "" + if g.getStr(&buf, g.scr.Std) == Norm { + obj.SetGoldVal(cAtoi(buf)) + } + } + g.addPack(obj, false) +} + +// showMap prints out the whole map for the wizard (wizard.c show_map). +func (g *RogueGame) showMap() { + hw := g.scr.Hw + hw.Clear() + for y := 1; y < NumLines-1; y++ { + for x := 0; x < NumCols; x++ { + real := g.Level.FlagsAt(y, x).Has(FReal) + if !real { + hw.Standout(true) + } + hw.MvAddCh(y, x, g.Level.Char(y, x)) + if !real { + hw.Standout(false) + } + } + } + g.showWin("---More (level map)---") +} // whatis identifies what a certain object is (wizard.c whatis). func (g *RogueGame) whatis(insist bool, typ int) { diff --git a/go.mod b/go.mod index 9e28abd..e297093 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,13 @@ module sneak.berlin/go/rogue go 1.25.7 + +require ( + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.13.10 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..992dc6d --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= +github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/term/tcell.go b/term/tcell.go new file mode 100644 index 0000000..c748122 --- /dev/null +++ b/term/tcell.go @@ -0,0 +1,135 @@ +// Package term provides the tcell-backed Terminal for the Rogue port. It +// replaces curses and the 900-line escape-sequence decoder in mdport.c: +// tcell delivers decoded key events, which are translated here to the +// single-byte command codes the game understands. +package term + +import ( + "fmt" + "os" + "os/exec" + + "github.com/gdamore/tcell/v2" + + "sneak.berlin/go/rogue/game" +) + +// Tcell renders game Windows on a tcell screen and turns key events into +// Rogue input bytes. +type Tcell struct { + screen tcell.Screen + last *game.Window // last rendered window, for resize redraws +} + +// New initializes the terminal. The screen must be at least 80x24, as the +// C game required. +func New() (*Tcell, error) { + s, err := tcell.NewScreen() + if err != nil { + return nil, err + } + if err := s.Init(); err != nil { + return nil, err + } + w, h := s.Size() + if h < game.NumLines || w < game.NumCols { + s.Fini() + return nil, fmt.Errorf("sorry, the screen must be at least %dx%d", + game.NumLines, game.NumCols) + } + s.HideCursor() + return &Tcell{screen: s}, nil +} + +// Fini restores the terminal. +func (t *Tcell) Fini() { + t.screen.Fini() +} + +// Render blits a game window to the terminal (curses refresh). +func (t *Tcell) Render(w *game.Window) { + t.last = w + rows, cols := w.Size() + for y := 0; y < rows; y++ { + for x := 0; x < cols; x++ { + ch, standout := w.CellAt(y, x) + style := tcell.StyleDefault + if standout { + style = style.Reverse(true) + } + t.screen.SetContent(x, y, rune(ch), nil, style) + } + } + t.screen.Show() +} + +// ReadChar blocks for the next key, translated to the byte codes the C +// game reads: arrows become hjkl, control keys their C0 codes. +func (t *Tcell) ReadChar() byte { + for { + ev := t.screen.PollEvent() + switch ev := ev.(type) { + case *tcell.EventResize: + if t.last != nil { + t.Render(t.last) + } + case *tcell.EventKey: + switch ev.Key() { + case tcell.KeyUp: + return 'k' + case tcell.KeyDown: + return 'j' + case tcell.KeyLeft: + return 'h' + case tcell.KeyRight: + return 'l' + case tcell.KeyHome: + return 'y' + case tcell.KeyPgUp: + return 'u' + case tcell.KeyEnd: + return 'b' + case tcell.KeyPgDn: + return 'n' + case tcell.KeyEnter: + return '\n' + case tcell.KeyEscape: + return game.Escape + case tcell.KeyBackspace, tcell.KeyBackspace2: + return 8 + case tcell.KeyDelete: + return 0x7f + case tcell.KeyTab: + return '\t' + case tcell.KeyCtrlC: + return 3 + default: + if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ { + return byte(ev.Key()) + } + if r := ev.Rune(); r > 0 && r < 0x80 { + return byte(r) + } + } + } + } +} + +// ShellEscape suspends the screen and runs the user's shell (main.c +// shell + md_shellescape). +func (t *Tcell) ShellEscape() { + if err := t.screen.Suspend(); err != nil { + return + } + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + fmt.Println("[Entering shell; exit to return to the game]") + cmd := exec.Command(shell) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + t.screen.Resume() +}