From 5ba9fe8f66554d7a060eef938590d3c0db525c12 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 00:03:45 +0200 Subject: [PATCH] Adopt house golangci-lint config; fix all approved-linter findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .golangci.yml is the prompts-repo standard verbatim plus one approved exception (paralleltest, sneak 2026-07-06). Roughly 1,500 findings fixed: - autofix sweep (wsl_v5/nlreturn/intrange/modernize formatting), with the misspell autofix REVERTED where it rewrote authentic C game text ("missle vanishes" stays, with nolint and a comment) - error handling: errcheck sites get real handling — saveFile now closes explicitly and removes corrupt saves, scoreboard writes are documented best-effort, err113 sentinel errors (ErrSaveOutOfDate, ErrScreenTooSmall); panics allowed for unrecoverable states per MEMORY.md policy - gosec: real fixes (ParseInt for SEED, 0600 scorefile) and justified per-line nolints for provably-bounded conversions (randomMonsterLetter helper collapses eight rnd(26)+'A' sites) - API tidying from linters: pointer receivers on flag types (recvcheck), msg helpers renamed addmsgf/doaddf/Printwf/MvPrintwf (goprintffuncname), myExit()/findFloor(monst)/mkGameInput(t) drop always-constant params (unparam), gameEnd carries no status - gocritic/staticcheck: if-else chains to switches, the pack.c inventory filter untangled into matchesFilter, main.go split so defers run before exit (exitAfterDefer, funlen) - revive doc comments on all exported flag types/consts/methods Remaining findings are confined to linters pending sneak's exception decision (mnd, gochecknoglobals, cyclop, nestif, gocognit, exhaustive, goconst, testpackage); TODO.md records the state. MEMORY.md added at repo root as the project's agent working notes. --- .golangci.yml | 34 +++++++++ MEMORY.md | 40 ++++++++++ TODO.md | 16 +++- cmd/rogue/main.go | 119 +++++++++++++++++++----------- game/armor.go | 26 +++++-- game/chase.go | 52 +++++++++++++ game/command.go | 166 ++++++++++++++++++++++++++++++++++-------- game/creature.go | 2 + game/daemon.go | 16 ++-- game/daemons.go | 32 +++++++- game/dice.go | 9 +++ game/dice_test.go | 1 + game/effects_test.go | 68 +++++++++++++---- game/fight.go | 160 +++++++++++++++++++++++++++++++++------- game/fight_test.go | 16 ++++ game/game.go | 25 ++++++- game/init.go | 22 +++++- game/io.go | 40 ++++++++-- game/level.go | 1 + game/misc.go | 86 +++++++++++++++++++--- game/monsters.go | 48 ++++++++++-- game/move.go | 56 +++++++++++++- game/newlevel.go | 42 ++++++----- game/newlevel_test.go | 19 ++++- game/object.go | 5 ++ game/options.go | 73 +++++++++++++++++-- game/pack.go | 123 ++++++++++++++++++++++++++----- game/passages.go | 87 ++++++++++++++++------ game/potions.go | 46 +++++++++++- game/rings.go | 30 +++++++- game/rip.go | 64 +++++++++++++--- game/rng.go | 16 ++-- game/rng_test.go | 2 + game/rooms.go | 48 ++++++++++-- game/run_test.go | 44 ++++++++--- game/save.go | 106 ++++++++++++++++++++++----- game/save_test.go | 43 +++++++++-- game/score.go | 69 ++++++++++++++---- game/screen.go | 40 ++++++---- game/scrolls.go | 41 +++++++++-- game/sticks.go | 76 +++++++++++++++++-- game/tables.go | 2 +- game/tables_test.go | 12 +++ game/term_test.go | 3 + game/things.go | 120 +++++++++++++++++++++++++----- game/types.go | 79 +++++++++++++++----- game/weapons.go | 38 +++++++++- game/wizard.go | 61 ++++++++++++---- term/tcell.go | 51 ++++++++++--- 49 files changed, 1965 insertions(+), 410 deletions(-) create mode 100644 .golangci.yml create mode 100644 MEMORY.md diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3f3df27 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +version: "2" + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + default: all + disable: + # Genuinely incompatible with project patterns + - exhaustruct # Requires all struct fields + - depguard # Dependency allow/block lists + - godot # Requires comments to end with periods + - wsl # Deprecated, replaced by wsl_v5 + - wrapcheck # Too verbose for internal packages + - varnamelen # Short names like db, id are idiomatic Go + # Repo-specific exceptions approved by sneak (2026-07-06) + - paralleltest # Requires t.Parallel() in every test + +linters-settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..c1e4cbd --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,40 @@ +# Project Memory + +Working notes for agents on this repo. Read this alongside TODO.md +(which holds the step queue and workflow) before starting work. + +## Error handling + +Panicking on bad/unexpected errors is allowed and preferred over +threading unlikely error returns through game code — e.g. write-side +Close/encode failures where continuing would mean corrupt state. The +game already unwinds C's exit() calls via a gameEnd panic recovered in +Run. Return errors where a caller genuinely handles them (save-file +prompts, restore validation). Reserve deliberate `_ =` discards for +true best-effort paths (scorefile writes, signal-time autosave), always +with a comment saying why. + +## Linting + +The .golangci.yml is the house standard and may only be modified with +sneak's explicit permission. To disable a linter, ask, explaining what +the linter does; he approves specific exceptions, which are recorded +in the config's "Repo-specific exceptions" block with the approval +date. Approved so far: paralleltest (2026-07-06). Line-level //nolint +with a reason is used sparingly for C-faithfulness (e.g. the authentic +"missle" message spellings) and provably-safe gosec conversions; each +needs a justifying comment. + +## Faithfulness + +Behavior must not change during the idiomatic-Go refactor unless a +TODO step says so. The 80x24 seed-compatible gameplay, message text +(including original typos), RNG call order, and C quirks (documented +in tests like TestHoldScrollGreedyMonsterQuirk) are contract. Doc +comments keep their "(file.c func_name)" breadcrumbs. + +## Debugging + +Write real, committed test files with t.Logf output and run plain +`go test -v`; no throwaway scratch scripts. Successful debug probes +become regression tests. diff --git a/TODO.md b/TODO.md index 1496067..fec4bb0 100644 --- a/TODO.md +++ b/TODO.md @@ -29,9 +29,19 @@ Refactor ground rules: # Next Step -Adopt the house Go linting standards: copy .golangci.yml from the -prompts repo and bring game/, term/, and cmd/ lint-clean (the port is -greenfield code, so no exemptions apply). +Finish adopting the house Go linting standards. Done so far (branch +refactor/lint-adoption): .golangci.yml copied verbatim from the +prompts repo (plus the sneak-approved paralleltest exception, +2026-07-06); ~1,500 findings fixed (autofix formatting sweep, errcheck/ +err113/noinlineerr error handling, forbidigo, funcorder, recvcheck +pointer receivers, goprintffuncname renames msg helpers to *f, revive +doc comments, gocritic switch rewrites, gosec real fixes plus justified +nolints, unparam signature tightening, C-faithful "missle" spellings +restored after misspell autofix changed game text). Remaining findings +are all in linters awaiting sneak's exception decision: mnd (288), +gochecknoglobals (37), cyclop (36), nestif (30), gocognit (23), +exhaustive (22), goconst (16), testpackage (9). Blocked on that +decision; either disable with approval or scope the fixes. # Completed Steps diff --git a/cmd/rogue/main.go b/cmd/rogue/main.go index 90febac..d165e08 100644 --- a/cmd/rogue/main.go +++ b/cmd/rogue/main.go @@ -18,61 +18,45 @@ import ( ) func main() { + os.Exit(run()) +} + +// run carries the real main so that deferred terminal restoration runs +// before the process exits (os.Exit skips defers). +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() - 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, - } + cfg := loadConfig() if *scores { - g := game.NewGame(cfg) - g.ShowScores() - return + game.NewGame(cfg).ShowScores() + + return 0 } t, err := term.New() if err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + + return 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) + + return 1 } } else { g = game.NewGame(cfg) @@ -80,22 +64,75 @@ func main() { if *deathDemo { g.DeathDemo() - return + + return 0 } - // SIGHUP/SIGTERM autosave (save.c auto_save) + installAutosave(g, t) + + runErr := g.Run() + if runErr != nil { + t.Fini() + fmt.Fprintln(os.Stderr, runErr) + + return 1 + } + + return 0 +} + +// loadConfig gathers the game configuration from the environment: home +// directory, ROGUEOPTS, user name, wizard mode, and the dungeon seed +// (main.c's startup). +func loadConfig() game.Config { + home, _ := os.UserHomeDir() + + name := "" + + u, userErr := user.Current() + if userErr == nil { + name = u.Username + } + + wizard := os.Getenv("ROGUE_WIZARD") != "" + + return game.Config{ + Seed: chooseSeed(wizard), + Name: name, + RogueOpts: os.Getenv("ROGUEOPTS"), + Home: home, + ScorePath: home + "/.rogue.scores", + Wizard: wizard, + } +} + +// installAutosave saves the game and exits on SIGHUP/SIGTERM (save.c +// auto_save). +func installAutosave(g *game.RogueGame, t *term.Tcell) { 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) - } +} + +// chooseSeed picks the dungeon number: SEED for reproducible dungeons +// (wizard mode, as in the C game), else time+pid (main.c). +func chooseSeed(wizard bool) int32 { + if env := os.Getenv("SEED"); env != "" && wizard { + n, err := strconv.ParseInt(env, 10, 32) + if err == nil { + return int32(n) + } + } + + // The C game computed `lowtime + getpid()` in int; the truncation to + // 32 bits is the same wraparound the C int arithmetic performed. + return int32(time.Now().Unix()&0x7fffffff) + //nolint:gosec // G115: deliberate wrap + int32(os.Getpid()&0x7fffffff) //nolint:gosec // G115: deliberate wrap } diff --git a/game/armor.go b/game/armor.go index bd01068..c57aea7 100644 --- a/game/armor.go +++ b/game/armor.go @@ -5,36 +5,47 @@ package game // wear lets the player put armor on (armor.c wear). func (g *RogueGame) wear() { p := &g.Player + obj := g.getItem("wear", KindArmor) if obj == nil { return } + if p.CurArmor != nil { - g.addmsg("you are already wearing some") + g.addmsgf("you are already wearing some") + if !g.Options.Terse { - g.addmsg(". You'll have to take it off first") + g.addmsgf(". You'll have to take it off first") } + g.endmsg() g.After = false + return } + if obj.Kind != KindArmor { g.msg("you can't wear that") + return } + g.wasteTime() obj.Flags.Set(Known) sp := g.invName(obj, true) p.CurArmor = obj + if !g.Options.Terse { - g.addmsg("you are now ") + g.addmsgf("you are now ") } + g.msg("wearing %s", sp) } // takeOff gets the armor off of the player's back (armor.c take_off). func (g *RogueGame) takeOff() { p := &g.Player + obj := p.CurArmor if obj == nil { g.After = false @@ -43,17 +54,22 @@ func (g *RogueGame) takeOff() { } else { g.msg("you aren't wearing any armor") } + return } + if !g.dropCheck(p.CurArmor) { return } + p.CurArmor = nil + if g.Options.Terse { - g.addmsg("was") + g.addmsgf("was") } else { - g.addmsg("you used to be") + g.addmsgf("you used to be") } + g.msg(" wearing %c) %s", obj.PackCh, g.invName(obj, true)) } diff --git a/game/chase.go b/game/chase.go index b52eb9a..2086cef 100644 --- a/game/chase.go +++ b/game/chase.go @@ -11,21 +11,26 @@ func (g *RogueGame) runners(int) { for _, tp := range list { if !tp.On(Held) && tp.On(Awake) { origPos := tp.Pos + wastarget := tp.On(Targeted) if g.moveMonst(tp) == -1 { continue } + if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 { if g.moveMonst(tp) == -1 { continue } } + if wastarget && origPos != tp.Pos { tp.Flags.Clear(Targeted) + g.ToDeath = false } } } + if g.HasHit { g.endmsg() g.HasHit = false @@ -40,12 +45,15 @@ func (g *RogueGame) moveMonst(tp *Monster) int { return -1 } } + if tp.On(Hasted) { if g.doChase(tp) == -1 { return -1 } } + tp.Turn = !tp.Turn + return 0 } @@ -62,10 +70,13 @@ func (g *RogueGame) relocate(th *Monster, newLoc Coord) { if oroom != th.Room { th.Dest = g.findDest(th) } + th.Pos = newLoc g.Level.SetMonsterAt(newLoc.Y, newLoc.X, th) } + g.move(newLoc.Y, newLoc.X) + if g.seeMonst(th) { g.addch(th.Disguise) } else if g.Player.On(SenseMonsters) { @@ -86,6 +97,7 @@ func (g *RogueGame) doChase(th *Monster) int { if th.On(Greedy) && rer.GoldVal == 0 { th.Dest = &p.Pos // if gold has been taken, run after hero } + var ree *Room // find room of chasee if th.Dest == &p.Pos { ree = p.Room @@ -94,6 +106,7 @@ func (g *RogueGame) doChase(th *Monster) int { } // We don't count doors as inside rooms for this routine door := g.Level.Char(th.Pos.Y, th.Pos.X) == Door + var this Coord over: @@ -107,9 +120,11 @@ over: mindist = curdist } } + if door { rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum] door = false + goto over } } else { @@ -122,18 +137,22 @@ over: distCp(th.Pos, p.Pos) <= BoltLength*BoltLength && !th.On(Cancelled) && g.rnd(dragonShot) == 0 { g.Delta.Y = sign(p.Pos.Y - th.Pos.Y) + g.Delta.X = sign(p.Pos.X - th.Pos.X) if g.HasHit { g.endmsg() } + g.fireBolt(th.Pos, &g.Delta, "flame") g.Running = false g.Count = 0 + g.Quiet = 0 if g.ToDeath && !th.On(Targeted) { g.ToDeath = false g.Kamikaze = false } + return 0 } } @@ -147,15 +166,19 @@ over: if th.Dest == &obj.Pos { detachObj(&g.Level.Objects, obj) attachObj(&th.Pack, obj) + if th.Room.Flags.Has(Gone) { g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Passage) } else { g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Floor) } + th.Dest = g.findDest(th) + break } } + if th.Type != 'F' { stoprun = true } @@ -165,11 +188,13 @@ over: return 0 } } + g.relocate(th, g.chRet) // And stop running if need be if stoprun && th.Pos == *th.Dest { th.Flags.Clear(Awake) } + return 0 } @@ -180,6 +205,7 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool { p := &g.Player er := tp.Pos plcnt := 1 + var curdist int // If the thing is confused, let it move randomly. Invisible Stalkers @@ -206,6 +232,7 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool { if ey >= NumLines-1 { ey = NumLines - 2 } + ex := er.X + 1 if ex >= NumCols { ex = NumCols - 1 @@ -215,11 +242,13 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool { if x < 0 { continue } + for y := er.Y - 1; y <= ey; y++ { tryp := Coord{X: x, Y: y} if !g.diagOk(er, tryp) { continue } + ch := g.Level.VisibleChar(y, x) if stepOk(ch) { // If it is a scroll, it might be a scare monster @@ -227,12 +256,15 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool { // it is. if ch == Scroll { var found *Object + for _, obj := range g.Level.Objects { if y == obj.Pos.Y && x == obj.Pos.X { found = obj + break } } + if found != nil && found.ScrollKind() == ScrollScareMonster { continue } @@ -258,6 +290,7 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool { } } } + return curdist != 0 && g.chRet != p.Pos } @@ -266,7 +299,9 @@ func (g *RogueGame) setOldch(tp *Monster, cp Coord) { if tp.Pos == cp { return } + sch := tp.OldCh + tp.OldCh = g.mvinch(cp.Y, cp.X) if !g.Player.On(Blind) { if (sch == Floor || tp.OldCh == Floor) && tp.Room.Flags.Has(Dark) { @@ -284,20 +319,25 @@ func (g *RogueGame) seeMonst(mp *Monster) bool { if p.On(Blind) { return false } + if mp.On(Invisible) && !p.On(CanSeeInvisible) { return false } + y, x := mp.Pos.Y, mp.Pos.X if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist { if y != p.Pos.Y && x != p.Pos.X && !stepOk(g.Level.Char(y, p.Pos.X)) && !stepOk(g.Level.Char(p.Pos.Y, x)) { return false } + return true } + if mp.Room != p.Room { return false } + return !mp.Room.Flags.Has(Dark) } @@ -330,6 +370,7 @@ func (g *RogueGame) roomin(cp Coord) *Room { } g.msg("in some bizarre place (%d, %d)", cp.Y, cp.X) + return nil } @@ -338,9 +379,11 @@ func (g *RogueGame) diagOk(sp, ep Coord) bool { if ep.X < 0 || ep.X >= NumCols || ep.Y <= 0 || ep.Y >= NumLines-1 { return false } + if ep.X == sp.X || ep.Y == sp.Y { return true } + return stepOk(g.Level.Char(ep.Y, sp.X)) && stepOk(g.Level.Char(sp.Y, ep.X)) } @@ -351,6 +394,7 @@ func (g *RogueGame) cansee(y, x int) bool { if p.On(Blind) { return false } + if distance(y, x, p.Pos.Y, p.Pos.X) < LampDist { if g.Level.FlagsAt(y, x).Has(FPassage) { if y != p.Pos.Y && x != p.Pos.X && @@ -359,11 +403,13 @@ func (g *RogueGame) cansee(y, x int) bool { return false } } + return true } // We can only see if the hero is in the same room as the coordinate // and the room is lit, or if it is close. rer := g.roomin(Coord{X: x, Y: y}) + return rer == p.Room && !rer.Flags.Has(Dark) } @@ -374,22 +420,28 @@ func (g *RogueGame) findDest(tp *Monster) *Coord { if prob <= 0 || tp.Room == g.Player.Room || g.seeMonst(tp) { return &g.Player.Pos } + for _, obj := range g.Level.Objects { if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster { continue } + if g.roomin(obj.Pos) == tp.Room && g.rnd(100) < prob { claimed := false + for _, other := range g.Level.Monsters { if other.Dest == &obj.Pos { claimed = true + break } } + if !claimed { return &obj.Pos } } } + return &g.Player.Pos } diff --git a/game/command.go b/game/command.go index d3a4b3a..af4f5ef 100644 --- a/game/command.go +++ b/game/command.go @@ -6,6 +6,7 @@ package game // bracketing (command.c command). func (g *RogueGame) command() { p := &g.Player + ntimes := 1 // number of player moves if p.On(Hasted) { ntimes++ @@ -13,6 +14,7 @@ func (g *RogueGame) command() { // Let the daemons start up g.DoDaemons(Before) g.DoFuses(Before) + for ; ntimes > 0; ntimes-- { g.Again = false if g.HasHit { @@ -26,29 +28,37 @@ func (g *RogueGame) command() { } 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) { + + 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 { + switch { + case g.Running || g.ToDeath: ch = g.RunCh - } else if g.Count != 0 { + case g.Count != 0: ch = g.countCh - } else { + default: ch = g.readchar() + g.MoveOn = false if g.Msgs.Mpos != 0 { // erase message if its there g.msg("") @@ -57,6 +67,7 @@ func (g *RogueGame) command() { } else { ch = '.' } + if g.NoCommand != 0 { if g.NoCommand--; g.NoCommand == 0 { p.Flags.Set(Awake) @@ -67,14 +78,14 @@ func (g *RogueGame) command() { 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 - } + g.Count = min(g.Count*10+int(ch-'0'), 255) + ch = g.readchar() } + g.countCh = ch // turn off count for commands which don't make sense // to repeat @@ -93,8 +104,9 @@ func (g *RogueGame) command() { if g.Count != 0 && !g.Running { g.Count-- } + if ch != 'a' && ch != Escape && - !(g.Running || g.Count != 0 || g.ToDeath) { + !g.Running && g.Count == 0 && !g.ToDeath { g.LLastComm = g.LastComm g.LLastDir = g.LastDir g.LLastPick = g.LastPick @@ -102,6 +114,7 @@ func (g *RogueGame) command() { g.LastDir = 0 g.LastPick = nil } + g.dispatch(ch) // turn off flags if no longer needed if !g.Running { @@ -112,20 +125,25 @@ func (g *RogueGame) command() { 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, RingSearching) { g.search() } else if p.IsRing(Left, RingTeleportation) && g.rnd(50) == 0 { g.teleport() } + if p.IsRing(Right, RingSearching) { g.search() } else if p.IsRing(Right, RingTeleportation) && g.rnd(50) == 0 { @@ -137,28 +155,35 @@ func (g *RogueGame) command() { // 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.Kind.Glyph()) } } else { if !g.Options.Terse { - g.addmsg("there is ") + g.addmsgf("there is ") } - g.addmsg("nothing here") + + g.addmsgf("nothing here") + if !g.Options.Terse { - g.addmsg(" to pick up") + g.addmsgf(" to pick up") } + g.endmsg() } case '!': @@ -201,36 +226,46 @@ over: 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(SenseMonsters)) { if !g.Options.Terse { - g.addmsg("I see ") + g.addmsgf("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(Targeted) + g.RunCh = g.DirCh ch = g.DirCh + goto over } case 't': @@ -246,6 +281,7 @@ over: } else { ch = g.LastComm g.Again = true + goto over } case 'q': @@ -327,15 +363,18 @@ over: 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 ") + g.addmsgf("You have found ") } - if g.Level.Char(g.Delta.Y, g.Delta.X) != Trap { + + switch { + case g.Level.Char(g.Delta.Y, g.Delta.X) != Trap: g.msg("no trap there") - } else if p.On(Hallucinating) { + case p.On(Hallucinating): g.msg("%s", trName[g.rnd(NumTrapTypes)]) - } else { + default: g.msg("%s", trName[*fp&FTrapMask]) fp.Set(FSeen) } @@ -352,6 +391,7 @@ over: } else { ch = g.DirCh g.countCh = g.DirCh + goto over } case ')': @@ -381,6 +421,7 @@ over: // 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) @@ -452,62 +493,81 @@ func (g *RogueGame) search() { p := &g.Player ey := p.Pos.Y + 1 ex := p.Pos.X + 1 + probinc := 0 if p.On(Hallucinating) { probinc = 3 } + if p.On(Blind) { 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 ") + g.addmsgf("you found ") } + if p.On(Hallucinating) { g.msg("%s", trName[g.rnd(NumTrapTypes)]) } else { g.msg("%s", trName[*fp&FTrapMask]) 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) } @@ -523,28 +583,35 @@ func (g *RogueGame) help() { // 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 @@ -552,24 +619,32 @@ func (g *RogueGame) help() { 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(' ') @@ -603,23 +678,29 @@ var identList = []helpEntry{ 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) } @@ -628,6 +709,7 @@ 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 { @@ -642,12 +724,14 @@ 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 { @@ -664,7 +748,9 @@ func (g *RogueGame) levitCheck() bool { if !g.Player.On(Levitating) { return false } + g.msg("You can't. You're floating off the ground!") + return true } @@ -676,11 +762,16 @@ func (g *RogueGame) call() { if obj == nil { return } - var op *ObjInfo - var elsewise string - var know *bool - var guess *string + + var ( + op *ObjInfo + elsewise string + know *bool + guess *string + ) + it := &g.Items + switch obj.Kind { case KindRing: op = &it.Rings[obj.Which] @@ -696,14 +787,18 @@ func (g *RogueGame) call() { elsewise = it.WandMade[obj.Which] case KindFood: 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 @@ -712,16 +807,21 @@ func (g *RogueGame) call() { } 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.addmsgf("Was ") } + g.msg("called \"%s\"", elsewise) } + g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? ")) buf := elsewise @@ -735,23 +835,29 @@ 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.addmsgf("you are %s (", how) } + g.InvDescribe = false - g.addmsg("%c) %s", cur.PackCh, g.invName(cur, true)) + g.addmsgf("%c) %s", cur.PackCh, g.invName(cur, true)) + g.InvDescribe = true if where != "" { - g.addmsg(" %s", where) + g.addmsgf(" %s", where) } + g.endmsg() } else { if !g.Options.Terse { - g.addmsg("you are ") + g.addmsgf("you are ") } - g.addmsg("%s nothing", how) + + g.addmsgf("%s nothing", how) + if where != "" { - g.addmsg(" %s", where) + g.addmsgf(" %s", where) } + g.endmsg() } } @@ -762,7 +868,9 @@ 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 { diff --git a/game/creature.go b/game/creature.go index f112f05..2211a47 100644 --- a/game/creature.go +++ b/game/creature.go @@ -23,6 +23,7 @@ type Monster struct { // in the C sources (cur_armor, purse, food_left, ...). type Player struct { Creature + CurArmor *Object // what he is wearing CurWeapon *Object // which weapon he is wielding CurRing [2]*Object // which rings are being worn (Left/Right) @@ -59,6 +60,7 @@ func detachMon(list *[]*Monster, item *Monster) { for i, m := range *list { if m == item { *list = append((*list)[:i], (*list)[i+1:]...) + return } } diff --git a/game/daemon.go b/game/daemon.go index ffa3672..298f9a9 100644 --- a/game/daemon.go +++ b/game/daemon.go @@ -10,6 +10,10 @@ package game // function-pointer-to-int mapping in state.c rs_write_daemons. type DaemonID int +// Daemon and fuse callback identifiers. The first block's numeric values +// match the function-pointer-to-int mapping in state.c rs_write_daemons; +// the second block covers fuses state.c never saved (the Go save format +// handles them all uniformly). const ( DNone DaemonID = 0 DRollwand DaemonID = 1 @@ -21,12 +25,10 @@ const ( DUnconfuse DaemonID = 7 DUnsee DaemonID = 8 DSight DaemonID = 9 - // Fuses beyond the C save map (state.c never saved these; the Go save - // format handles them uniformly). - DVisuals DaemonID = 10 - DComeDown DaemonID = 11 - DLand DaemonID = 12 - DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback + DVisuals DaemonID = 10 + DComeDown DaemonID = 11 + DLand DaemonID = 12 + DTurnSee DaemonID = 13 // potions.c casts turn_see to a fuse callback ) // Scheduling phases and slot states (daemon.c). @@ -59,6 +61,7 @@ func (g *RogueGame) dSlot() *delayedAction { return &g.Daemons.List[i] } } + panic("ran out of fuse slots") // C: debug message in MASTER, NULL deref otherwise } @@ -70,6 +73,7 @@ func (g *RogueGame) findSlot(f DaemonID) *delayedAction { return d } } + return nil } diff --git a/game/daemons.go b/game/daemons.go index b38abe3..684dba1 100644 --- a/game/daemons.go +++ b/game/daemons.go @@ -45,6 +45,7 @@ func (g *RogueGame) doctor(int) { p := &g.Player lv := p.Stats.Lvl ohp := p.Stats.HP + g.Quiet++ if lv < 8 { if g.Quiet+(lv<<1) > 20 { @@ -53,16 +54,20 @@ func (g *RogueGame) doctor(int) { } else if g.Quiet >= 3 { p.Stats.HP += g.rnd(lv-7) + 1 } + if p.IsRing(Left, RingRegeneration) { p.Stats.HP++ } + if p.IsRing(Right, RingRegeneration) { p.Stats.HP++ } + if ohp != p.Stats.HP { if p.Stats.HP > p.Stats.MaxHP { p.Stats.HP = p.Stats.MaxHP } + g.Quiet = 0 } } @@ -82,6 +87,7 @@ func (g *RogueGame) rollwand(int) { g.KillDaemon(DRollwand) g.Fuse(DSwander, 0, wanderTime(g), Before) } + g.Daemons.Between = 0 } } @@ -103,6 +109,7 @@ func (g *RogueGame) unsee(int) { g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh) } } + g.Player.Flags.Clear(CanSeeInvisible) } @@ -112,9 +119,11 @@ func (g *RogueGame) sight(int) { if p.On(Blind) { g.Extinguish(DSight) p.Flags.Clear(Blind) + if !p.Room.Flags.Has(Gone) { g.enterRoom(p.Pos) } + g.msg("%s", g.chooseStr("far out! Everything is all cosmic again", "the veil of darkness lifts")) } @@ -129,6 +138,7 @@ func (g *RogueGame) nohaste(int) { // stomach digests the hero's food (daemons.c stomach). func (g *RogueGame) stomach(int) { p := &g.Player + origHungry := p.HungryState if p.FoodLeft <= 0 { if p.FoodLeft--; p.FoodLeft < -StarveTime { @@ -138,29 +148,36 @@ func (g *RogueGame) stomach(int) { if g.NoCommand != 0 || g.rnd(5) != 0 { return } + g.NoCommand += g.rnd(8) + 4 p.HungryState = 3 + if !g.Options.Terse { - g.addmsg("%s", g.chooseStr( + g.addmsgf("%s", g.chooseStr( "the munchies overpower your motor capabilities. ", "you feel too weak from lack of food. ")) } + g.msg("%s", g.chooseStr("You freak out", "You faint")) } else { oldfood := p.FoodLeft + amulet := 0 if g.HasAmulet { amulet = 1 } + p.FoodLeft -= g.ringEat(Left) + g.ringEat(Right) + 1 - amulet if p.FoodLeft < MoreTime && oldfood >= MoreTime { p.HungryState = 2 + g.msg("%s", g.chooseStr( - "the munchies are interfering with your motor capabilites", + "the munchies are interfering with your motor capabilities", "you are starting to feel weak")) } else if p.FoodLeft < 2*MoreTime && oldfood >= 2*MoreTime { p.HungryState = 1 + if g.Options.Terse { g.msg("%s", g.chooseStr("getting the munchies", "getting hungry")) } else { @@ -169,8 +186,10 @@ func (g *RogueGame) stomach(int) { } } } + if p.HungryState != origHungry { p.Flags.Clear(Awake) + g.Running = false g.ToDeath = false g.Count = 0 @@ -200,8 +219,10 @@ func (g *RogueGame) comeDown(int) { // undo the monsters seemonst := p.On(SenseMonsters) + for _, tp := range g.Level.Monsters { g.move(tp.Pos.Y, tp.Pos.X) + if g.cansee(tp.Pos.Y, tp.Pos.X) { if !tp.On(Invisible) || p.On(CanSeeInvisible) { g.addch(tp.Disguise) @@ -214,6 +235,7 @@ func (g *RogueGame) comeDown(int) { g.standend() } } + g.msg("Everything looks SO boring now.") } @@ -238,17 +260,19 @@ func (g *RogueGame) visuals(int) { // change the monsters seemonst := p.On(SenseMonsters) + for _, tp := range g.Level.Monsters { g.move(tp.Pos.Y, tp.Pos.X) + if g.seeMonst(tp) { if tp.Type == 'X' && tp.Disguise != 'X' { g.addch(g.rndThing()) } else { - g.addch(byte(g.rnd(26) + 'A')) + g.addch(g.randomMonsterLetter()) } } else if seemonst { g.standout() - g.addch(byte(g.rnd(26) + 'A')) + g.addch(g.randomMonsterLetter()) g.standend() } } diff --git a/game/dice.go b/game/dice.go index 5548360..1c6cb26 100644 --- a/game/dice.go +++ b/game/dice.go @@ -25,20 +25,26 @@ type DiceSpec []DiceRoll // a single 0x0 attack, as it did in C. func ParseDice(s string) DiceSpec { var spec DiceSpec + for s != "" { count := cAtoi(s) + xi := strings.IndexByte(s, 'x') if xi < 0 { break } + s = s[xi+1:] spec = append(spec, DiceRoll{Count: count, Sides: cAtoi(s)}) + si := strings.IndexByte(s, '/') if si < 0 { break } + s = s[si+1:] } + return spec } @@ -48,11 +54,14 @@ func dice(s string) DiceSpec { return ParseDice(s) } // String renders the spec back in the classic "NxM/NxM" form. func (d DiceSpec) String() string { var sb strings.Builder + for i, r := range d { if i > 0 { sb.WriteByte('/') } + fmt.Fprintf(&sb, "%dx%d", r.Count, r.Sides) } + return sb.String() } diff --git a/game/dice_test.go b/game/dice_test.go index df08051..84bd90b 100644 --- a/game/dice_test.go +++ b/game/dice_test.go @@ -37,6 +37,7 @@ func TestTablesHaveDice(t *testing.T) { t.Errorf("monster %c (%s) has no attacks", 'A'+i, m.Name) } } + for w, iw := range initWeaps { if len(iw.dam) == 0 || len(iw.hrl) == 0 { t.Errorf("weapon %v has empty dice", WeaponKind(w)) diff --git a/game/effects_test.go b/game/effects_test.go index 0bc86db..852b48e 100644 --- a/game/effects_test.go +++ b/game/effects_test.go @@ -2,13 +2,16 @@ package game import "testing" -// mkGameInput builds a headless game whose input plays the given script. -func mkGameInput(t *testing.T, seed int32, input string) *RogueGame { +// mkGameInput builds a headless game; tests script it via setInput. The +// fixed seed keeps the scripted item/monster interactions stable. +func mkGameInput(t *testing.T) *RogueGame { t.Helper() - g := NewGame(Config{Seed: seed, Term: &testTerm{input: []byte(input)}}) + + g := NewGame(Config{Seed: 5, Term: &testTerm{}}) g.NewLevel() g.Oldpos = g.Player.Pos g.Oldrp = g.roomin(g.Player.Pos) + return g } @@ -16,42 +19,61 @@ func mkGameInput(t *testing.T, seed int32, input string) *RogueGame { func give(g *RogueGame, obj *Object) byte { obj.Count = 1 g.addPack(obj, true) + return obj.PackCh } +// setInput replaces the scripted terminal input. +func setInput(t *testing.T, g *RogueGame, input ...byte) { + t.Helper() + + tt, ok := g.scr.term.(*testTerm) + if !ok { + t.Fatal("game terminal is not a testTerm") + } + + tt.input = input + tt.pos = 0 +} + func TestQuaffHealingPotion(t *testing.T) { - g := mkGameInput(t, 5, "") + g := mkGameInput(t) pot := newObject() pot.Kind = KindPotion pot.Which = int(PotionHealing) ch := give(g, pot) - g.scr.term.(*testTerm).input = []byte{ch} + setInput(t, g, ch) g.Player.Stats.HP = 1 g.quaff() + if g.Player.Stats.HP <= 1 { t.Error("healing potion did not heal") } + if !g.Items.Potions[PotionHealing].Know { t.Error("healing potion not identified after drinking") } + if len(g.Player.Pack) != 5 { t.Errorf("potion not consumed: %d items", len(g.Player.Pack)) } } func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) { - g := mkGameInput(t, 5, "") + g := mkGameInput(t) pot := newObject() pot.Kind = KindPotion pot.Which = int(PotionConfusion) ch := give(g, pot) - g.scr.term.(*testTerm).input = []byte{ch} + setInput(t, g, ch) g.quaff() + if !g.Player.On(Confused) { t.Error("confusion potion did not confuse") } + if g.findSlot(DUnconfuse) == nil { t.Error("no unconfuse fuse pending") } @@ -59,21 +81,23 @@ func TestQuaffConfusionSetsFlagAndFuse(t *testing.T) { for range 30 { g.DoFuses(After) } + if g.Player.On(Confused) { t.Error("confusion never wore off") } } func TestReadEnchantArmor(t *testing.T) { - g := mkGameInput(t, 5, "") + g := mkGameInput(t) scr := newObject() scr.Kind = KindScroll scr.Which = int(ScrollEnchantArmor) ch := give(g, scr) - g.scr.term.(*testTerm).input = []byte{ch} + setInput(t, g, ch) before := g.Player.CurArmor.ArmorClass g.readScroll() + if g.Player.CurArmor.ArmorClass != before-1 { t.Errorf("enchant armor: AC %d -> %d, want %d", before, g.Player.CurArmor.ArmorClass, before-1) @@ -86,17 +110,19 @@ func TestReadHoldMonsterFreezesAdjacent(t *testing.T) { // look(TRUE) at the end of read_scroll immediately re-wakes greedy // monsters. The port reproduces that quirk faithfully — see // TestHoldScrollGreedyMonsterQuirk. - g := mkGameInput(t, 5, "") + g := mkGameInput(t) tp := spawnAdjacent(g, 'Z') tp.Flags.Set(Awake) + scr := newObject() scr.Kind = KindScroll scr.Which = int(ScrollHoldMonster) ch := give(g, scr) - g.scr.term.(*testTerm).input = []byte{ch} + setInput(t, g, ch) g.readScroll() t.Logf("after scroll: flags=%o huh=%q", tp.Flags, g.Msgs.Huh) + if tp.On(Awake) || !tp.On(Held) { t.Error("hold monster scroll did not hold the adjacent monster") } @@ -107,21 +133,24 @@ func TestReadHoldMonsterFreezesAdjacent(t *testing.T) { // (orc) held by a scroll is re-woken by the look(TRUE) that read_scroll // performs, ending up both held and running again. func TestHoldScrollGreedyMonsterQuirk(t *testing.T) { - g := mkGameInput(t, 5, "") + g := mkGameInput(t) tp := spawnAdjacent(g, 'O') tp.Flags.Set(Awake) + scr := newObject() scr.Kind = KindScroll scr.Which = int(ScrollHoldMonster) ch := give(g, scr) - g.scr.term.(*testTerm).input = []byte{ch} + setInput(t, g, ch) g.readScroll() t.Logf("orc after scroll: flags=%o (Awake=%v Held=%v)", tp.Flags, tp.On(Awake), tp.On(Held)) + if !tp.On(Held) { t.Error("orc lost Held entirely") } + if !tp.On(Awake) { t.Error("quirk changed: greedy monster stayed held; if this is a " + "deliberate fix, update this test and ARCHITECTURE.md") @@ -129,21 +158,25 @@ func TestHoldScrollGreedyMonsterQuirk(t *testing.T) { } func TestZapSlowMonster(t *testing.T) { - g := mkGameInput(t, 5, "") + g := mkGameInput(t) tp := spawnAdjacent(g, 'Z') stick := newObject() stick.Kind = KindWand stick.Which = int(WandSlowMonster) g.fixStick(stick) ch := give(g, stick) - g.scr.term.(*testTerm).input = []byte{ch} + setInput(t, g, ch) + g.Delta = Coord{X: 1, Y: 0} // aim at the monster charges := stick.Charges + g.doZap() + if !tp.On(Slowed) { t.Error("slow monster wand did not slow") } + if stick.Charges != charges-1 { t.Error("zap did not use a charge") } @@ -152,18 +185,23 @@ func TestZapSlowMonster(t *testing.T) { func TestParseOpts(t *testing.T) { g := NewGame(Config{Seed: 1}) g.ParseOpts("terse,nojump,name=Conan,fruit=mango,inven=slow") + if !g.Options.Terse { t.Error("terse not set") } + if g.Options.Jump { t.Error("nojump not honored") } + if g.Whoami != "Conan" { t.Errorf("name = %q", g.Whoami) } + if g.Fruit != "mango" { t.Errorf("fruit = %q", g.Fruit) } + if g.Options.InvType != InvSlow { t.Errorf("inven = %d", g.Options.InvType) } diff --git a/game/fight.go b/game/fight.go index ebf22c7..09826b1 100644 --- a/game/fight.go +++ b/game/fight.go @@ -48,20 +48,27 @@ func (g *RogueGame) setMname(tp *Monster) string { if g.Options.Terse { return "it" } + return "something" } + var mname string + if g.Player.On(Hallucinating) { - ch := int(g.mvinch(tp.Pos.Y, tp.Pos.X)) - if !isUpper(byte(ch)) { - ch = g.rnd(26) + var idx int + + ch := g.mvinch(tp.Pos.Y, tp.Pos.X) + if isUpper(ch) { + idx = int(ch - 'A') } else { - ch -= 'A' + idx = g.rnd(26) } - mname = g.Monsters[ch].Name + + mname = g.Monsters[idx].Name } else { mname = g.Monsters[tp.Type-'A'].Name } + return "the " + mname } @@ -82,37 +89,46 @@ func (g *RogueGame) fight(mp Coord, weap *Object, thrown bool) bool { if tp.Type == 'X' && tp.Disguise != 'X' && !p.On(Blind) { tp.Disguise = 'X' if p.On(Hallucinating) { - g.mvaddch(tp.Pos.Y, tp.Pos.X, byte(g.rnd(26)+'A')) + g.mvaddch(tp.Pos.Y, tp.Pos.X, g.randomMonsterLetter()) } + g.msg("%s", g.chooseStr("heavy! That's a nasty critter!", "wait! That's a xeroc!")) + if !thrown { return false } } + mname := g.setMname(tp) didHit := false + g.HasHit = g.Options.Terse && !g.ToDeath if g.rollEm(&p.Creature, &tp.Creature, weap, thrown) { didHit = false + if thrown { g.thunk(weap, mname, g.Options.Terse) } else { g.hit("", mname, g.Options.Terse) } + if p.On(CanConfuse) { didHit = true + tp.Flags.Set(Confused) p.Flags.Clear(CanConfuse) g.endmsg() g.HasHit = false g.msg("your hands stop glowing %s", g.pickColor("red")) } + if tp.Stats.HP <= 0 { g.killed(tp, true) } else if didHit && !p.On(Blind) { g.msg("%s appears confused", mname) } + didHit = true } else { if thrown { @@ -121,6 +137,7 @@ func (g *RogueGame) fight(mp Coord, weap *Object, thrown bool) bool { g.miss("", mname, g.Options.Terse) } } + return didHit } @@ -132,29 +149,35 @@ func (g *RogueGame) attack(mp *Monster) int { // going on at the time. g.Running = false g.Count = 0 + g.Quiet = 0 if g.ToDeath && !mp.On(Targeted) { g.ToDeath = false g.Kamikaze = false } + if mp.Type == 'X' && mp.Disguise != 'X' && !p.On(Blind) { mp.Disguise = 'X' if p.On(Hallucinating) { - g.mvaddch(mp.Pos.Y, mp.Pos.X, byte(g.rnd(26)+'A')) + g.mvaddch(mp.Pos.Y, mp.Pos.X, g.randomMonsterLetter()) } } + mname := g.setMname(mp) oldhp := p.Stats.HP removed := false + if g.rollEm(&mp.Creature, &p.Creature, nil, false) { if mp.Type != 'I' { if g.HasHit { - g.addmsg(". ") + g.addmsgf(". ") } + g.hit(mname, "", false) } else if g.HasHit { g.endmsg() } + g.HasHit = false if p.Stats.HP <= 0 { g.death(mp.Type) // Bye bye life ... @@ -163,10 +186,12 @@ func (g *RogueGame) attack(mp *Monster) int { if oldhp > g.MaxHit { g.MaxHit = oldhp } + if p.Stats.HP <= g.MaxHit { g.ToDeath = false } } + if !mp.On(Cancelled) { switch mp.Type { case 'A': @@ -175,13 +200,17 @@ func (g *RogueGame) attack(mp *Monster) int { case 'I': // The ice monster freezes you p.Flags.Clear(Awake) + if g.NoCommand == 0 { - g.addmsg("you are frozen") + g.addmsgf("you are frozen") + if !g.Options.Terse { - g.addmsg(" by the %s", mname) + g.addmsgf(" by the %s", mname) } + g.endmsg() } + g.NoCommand += g.rnd(2) + 2 if g.NoCommand > BoreLevel { g.death('h') @@ -191,6 +220,7 @@ func (g *RogueGame) attack(mp *Monster) int { if !g.save(VsPoison) { if !p.IsWearing(RingSustainStrength) { g.chgStr(-1) + if !g.Options.Terse { g.msg("you feel a bite in your leg and now feel weaker") } else { @@ -211,36 +241,45 @@ func (g *RogueGame) attack(mp *Monster) int { if mp.Type == 'W' { chance = 15 } + if g.rnd(100) < chance { var fewer int + if mp.Type == 'W' { if p.Stats.Exp == 0 { g.death('W') // All levels gone } + if p.Stats.Lvl--; p.Stats.Lvl == 0 { p.Stats.Exp = 0 p.Stats.Lvl = 1 } else { p.Stats.Exp = eLevels[p.Stats.Lvl-1] + 1 } + fewer = g.roll(1, 10) } else { fewer = g.roll(1, 3) } + p.Stats.HP -= fewer + p.Stats.MaxHP -= fewer if p.Stats.HP <= 0 { p.Stats.HP = 1 } + if p.Stats.MaxHP <= 0 { g.death(mp.Type) } + g.msg("you suddenly feel weaker") } case 'F': // Venus Flytrap stops the poor guy from moving p.Flags.Set(Held) p.VfHit++ + g.Monsters['F'-'A'].Stats.Dmg = DiceSpec{{Count: p.VfHit, Sides: 1}} if p.Stats.HP--; p.Stats.HP <= 0 { g.death('F') @@ -248,15 +287,20 @@ func (g *RogueGame) attack(mp *Monster) int { case 'L': // Leprechaun steals some gold lastpurse := p.Purse + p.Purse -= g.goldCalc() if !g.save(VsMagic) { p.Purse -= g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc() } + if p.Purse < 0 { p.Purse = 0 } + g.removeMon(mp.Pos, mp, false) + removed = true + if p.Purse != lastpurse { g.msg("your purse feels lighter") } @@ -264,7 +308,9 @@ func (g *RogueGame) attack(mp *Monster) int { // Nymphs steal a magic item; look through the pack and // pick out one we like. var steal *Object + nobj := 0 + for _, obj := range p.Pack { if obj != p.CurArmor && obj != p.CurWeapon && obj != p.CurRing[Left] && obj != p.CurRing[Right] && @@ -274,9 +320,12 @@ func (g *RogueGame) attack(mp *Monster) int { } } } + if steal != nil { g.removeMon(mp.Pos, g.Level.MonsterAt(mp.Pos.Y, mp.Pos.X), false) + removed = true + g.leavePack(steal, false, false) g.msg("she stole %s!", g.invName(steal, true)) } @@ -284,25 +333,31 @@ func (g *RogueGame) attack(mp *Monster) int { } } else if mp.Type != 'I' { if g.HasHit { - g.addmsg(". ") + g.addmsgf(". ") g.HasHit = false } + if mp.Type == 'F' { p.Stats.HP -= p.VfHit if p.Stats.HP <= 0 { g.death(mp.Type) // Bye bye life ... } } + g.miss(mname, "", false) } + if g.Options.FightFlush && !g.ToDeath { g.flushType() } + g.Count = 0 g.status() + if removed { return -1 } + return 0 } @@ -310,6 +365,7 @@ func (g *RogueGame) attack(mp *Monster) int { func (g *RogueGame) swing(atLvl, opArm, wplus int) bool { res := g.rnd(20) need := (20 - atLvl) - opArm + return res+wplus >= need } @@ -318,12 +374,17 @@ func (g *RogueGame) rollEm(thatt, thdef *Creature, weap *Object, hurl bool) bool p := &g.Player att := &thatt.Stats def := &thdef.Stats - var attacks DiceSpec - var hplus, dplus int + + var ( + attacks DiceSpec + hplus, dplus int + ) + if weap == nil { attacks = att.Dmg } else { hplus = weap.HPlus + dplus = weap.DPlus if weap == p.CurWeapon { if p.IsRing(Left, RingIncreaseDamage) { @@ -331,12 +392,14 @@ func (g *RogueGame) rollEm(thatt, thdef *Creature, weap *Object, hurl bool) bool } else if p.IsRing(Left, RingDexterity) { hplus += p.CurRing[Left].Bonus } + if p.IsRing(Right, RingIncreaseDamage) { dplus += p.CurRing[Right].Bonus } else if p.IsRing(Right, RingDexterity) { hplus += p.CurRing[Right].Bonus } } + attacks = weap.Damage if hurl { if weap.Flags.Has(Missile) && p.CurWeapon != nil && @@ -354,29 +417,37 @@ func (g *RogueGame) rollEm(thatt, thdef *Creature, weap *Object, hurl bool) bool if !thdef.Flags.Has(Awake) { hplus += 4 } + defArm := def.ArmorClass if def == &p.Stats { if p.CurArmor != nil { defArm = p.CurArmor.ArmorClass } + if p.IsRing(Left, RingProtection) { defArm -= p.CurRing[Left].Bonus } + if p.IsRing(Right, RingProtection) { defArm -= p.CurRing[Right].Bonus } } + didHit := false + for _, atk := range attacks { if g.swing(att.Lvl, defArm, hplus+strPlus[att.Str]) { proll := g.roll(atk.Count, atk.Sides) + damage := dplus + proll + addDam[att.Str] if damage > 0 { def.HP -= damage } + didHit = true } } + return didHit } @@ -387,7 +458,9 @@ func cAtoi(s string) int { for i < len(s) && s[i] >= '0' && s[i] <= '9' { i++ } + n, _ := strconv.Atoi(s[:i]) + return n } @@ -398,9 +471,11 @@ func prname(mname string, upper bool) string { if out == "" { out = "you" } + if upper { out = string(toUpper(out[0])) + out[1:] } + return out } @@ -409,12 +484,15 @@ func (g *RogueGame) thunk(weap *Object, mname string, noend bool) { if g.ToDeath { return } + if weap.Kind == KindWeapon { - g.addmsg("the %s hits ", g.Items.Weapons[weap.Which].Name) + g.addmsgf("the %s hits ", g.Items.Weapons[weap.Which].Name) } else { - g.addmsg("you hit ") + g.addmsgf("you hit ") } - g.addmsg("%s", mname) + + g.addmsgf("%s", mname) + if !noend { g.endmsg() } @@ -425,7 +503,9 @@ func (g *RogueGame) hit(er, ee string, noend bool) { if g.ToDeath { return } - g.addmsg("%s", prname(er, true)) + + g.addmsgf("%s", prname(er, true)) + var s string if g.Options.Terse { s = " hit" @@ -434,12 +514,16 @@ func (g *RogueGame) hit(er, ee string, noend bool) { if er != "" { i += 4 } + s = hNames[i] } - g.addmsg("%s", s) + + g.addmsgf("%s", s) + if !g.Options.Terse { - g.addmsg("%s", prname(ee, false)) + g.addmsgf("%s", prname(ee, false)) } + if !noend { g.endmsg() } @@ -450,18 +534,24 @@ func (g *RogueGame) miss(er, ee string, noend bool) { if g.ToDeath { return } - g.addmsg("%s", prname(er, true)) + + g.addmsgf("%s", prname(er, true)) + i := 0 if !g.Options.Terse { i = g.rnd(4) } + if er != "" { i += 4 } - g.addmsg("%s", mNames[i]) + + g.addmsgf("%s", mNames[i]) + if !g.Options.Terse { - g.addmsg(" %s", prname(ee, false)) + g.addmsgf(" %s", prname(ee, false)) } + if !noend { g.endmsg() } @@ -472,12 +562,15 @@ func (g *RogueGame) bounce(weap *Object, mname string, noend bool) { if g.ToDeath { return } + if weap.Kind == KindWeapon { - g.addmsg("the %s misses ", g.Items.Weapons[weap.Which].Name) + g.addmsgf("the %s misses ", g.Items.Weapons[weap.Which].Name) } else { - g.addmsg("you missed ") + g.addmsgf("you missed ") } - g.addmsg("%s", mname) + + g.addmsgf("%s", mname) + if !noend { g.endmsg() } @@ -489,15 +582,19 @@ func (g *RogueGame) removeMon(mp Coord, tp *Monster, waskill bool) { for _, obj := range pack { obj.Pos = tp.Pos detachObj(&tp.Pack, obj) + if waskill { g.fall(obj, false) } } + g.Level.SetMonsterAt(mp.Y, mp.X, nil) g.mvaddch(mp.Y, mp.X, tp.OldCh) detachMon(&g.Level.Monsters, tp) + if tp.On(Targeted) { g.Kamikaze = false + g.ToDeath = false if g.Options.FightFlush { g.flushType() @@ -521,33 +618,40 @@ func (g *RogueGame) killed(tp *Monster, pr bool) { if ok { tp.Room.Gold = pos } + if ok && g.Depth >= g.MaxDepth { gold := newObject() gold.Kind = KindGold + gold.GoldValue = g.goldCalc() if g.save(VsMagic) { gold.GoldValue += g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc() } + attachObj(&tp.Pack, gold) } } // Get rid of the monster. mname := g.setMname(tp) g.removeMon(tp.Pos, tp, true) + if pr { if g.HasHit { - g.addmsg(". Defeated ") + g.addmsgf(". Defeated ") g.HasHit = false } else { if !g.Options.Terse { - g.addmsg("you have ") + g.addmsgf("you have ") } - g.addmsg("defeated ") + + g.addmsgf("defeated ") } + g.msg("%s", mname) } // Do adjustments if he went up a level g.checkLevel() + if g.Options.FightFlush { g.flushType() } diff --git a/game/fight_test.go b/game/fight_test.go index 52e93dd..0b2d6df 100644 --- a/game/fight_test.go +++ b/game/fight_test.go @@ -6,10 +6,12 @@ import "testing" // look() state the way playit() does before the first command. func mkGame(t *testing.T, seed int32) *RogueGame { t.Helper() + g := NewGame(Config{Seed: seed, Term: &testTerm{}}) g.NewLevel() g.Oldpos = g.Player.Pos g.Oldrp = g.roomin(g.Player.Pos) + return g } @@ -18,6 +20,7 @@ func spawnAdjacent(g *RogueGame, typ byte) *Monster { pos := Coord{X: g.Player.Pos.X + 1, Y: g.Player.Pos.Y} tp := &Monster{} g.newMonster(tp, typ, pos) + return tp } @@ -32,6 +35,7 @@ func TestRollEmParsesMultiAttackDice(t *testing.T) { if !g.rollEm(att, def, nil, false) { t.Fatal("attack with guaranteed swing missed") } + dmg := 1000 - def.Stats.HP if dmg < 6 || dmg > 15 { t.Errorf("three 1x4+1 attacks dealt %d damage, want 6..15", dmg) @@ -45,12 +49,15 @@ func TestFightKillsMonster(t *testing.T) { g.Player.Stats.Lvl = 20 // always hits before := len(g.Level.Monsters) g.fight(tp.Pos, g.Player.CurWeapon, false) + if len(g.Level.Monsters) != before-1 { t.Error("monster not removed after fatal fight") } + if g.Level.MonsterAt(tp.Pos.Y, tp.Pos.X) != nil { t.Error("map still records dead monster") } + if g.Player.Stats.Exp == 0 { t.Error("no experience for the kill") } @@ -61,10 +68,12 @@ func TestAttackHurtsPlayer(t *testing.T) { tp := spawnAdjacent(g, 'T') // troll: 1x8/1x8/2x6 tp.Stats.Lvl = 20 // always hits tp.Flags.Clear(Cancelled) + hpBefore := g.Player.Stats.HP g.Player.Stats.HP = 500 g.Player.Stats.MaxHP = 500 g.attack(tp) + if g.Player.Stats.HP >= 500 { t.Errorf("player HP unchanged (%d -> %d)", hpBefore, g.Player.Stats.HP) } @@ -72,15 +81,18 @@ 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') } @@ -90,16 +102,20 @@ func TestRunnersChaseHero(t *testing.T) { // Place a hobgoblin a few squares away in the hero's room and set it // running at the hero. p := &g.Player + pos := Coord{X: p.Pos.X + 3, Y: p.Pos.Y} if !stepOk(g.Level.Char(pos.Y, pos.X)) || g.Level.MonsterAt(pos.Y, pos.X) != nil { t.Skip("no clear lane on this seed") } + tp := &Monster{} g.newMonster(tp, 'H', pos) tp.Flags.Set(Awake) tp.Dest = &p.Pos d0 := distCp(tp.Pos, p.Pos) + g.runners(0) + if d1 := distCp(tp.Pos, p.Pos); d1 >= d0 { t.Errorf("monster did not close distance: %d -> %d", d0, d1) } diff --git a/game/game.go b/game/game.go index 1aef5cd..3c872fa 100644 --- a/game/game.go +++ b/game/game.go @@ -166,15 +166,18 @@ 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(SenseMonsters) } + if cfg.RogueOpts != "" { g.ParseOpts(cfg.RogueOpts) } g.Monsters = monsterTable + g.Items.Group = 2 // weapons.c: int group = 2 for i := range g.Level.Passages { g.Level.Passages[i].Flags = Gone | Dark @@ -186,20 +189,26 @@ func NewGame(cfg Config) *RogueGame { g.initColors() // set up colors of potions g.initStones() // set up stone settings of rings g.initMaterials() // set up materials of wands + 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) { +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 } + panic(r) } }() + if !g.restored { g.NewLevel() // draw current level // Start up daemons and fuses @@ -208,7 +217,9 @@ func (g *RogueGame) Run() (err error) { g.Fuse(DSwander, 0, wanderTime(g), After) g.StartDaemon(DStomach, 0, After) } + g.playit() + return nil } @@ -226,10 +237,12 @@ 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() } @@ -242,7 +255,7 @@ func (g *RogueGame) endit() { func (g *RogueGame) fatal(s string) { g.mvaddstr(NumLines-2, 0, s) g.refresh() - g.myExit(0) + g.myExit() } // quit has the player make certain, then exits (main.c quit). The final @@ -252,17 +265,21 @@ func (g *RogueGame) quit(int) { if !g.QComm { g.Msgs.Mpos = 0 } + oy, ox := g.scr.Std.GetYX() g.msg("really quit?") + if g.readchar() == 'y' { g.clear() - g.scr.Std.MvPrintw(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse) + g.scr.Std.MvPrintwf(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse) g.move(NumLines-1, 0) g.refresh() g.score(g.Player.Purse, 1, 0) - g.myExit(0) + g.myExit() + return } + g.move(0, 0) g.clrtoeol() g.status() diff --git a/game/init.go b/game/init.go index c964539..b89afa5 100644 --- a/game/init.go +++ b/game/init.go @@ -51,7 +51,8 @@ func (g *RogueGame) initPlayer() { // (init.c init_colors). func (g *RogueGame) initColors() { used := make([]bool, len(rainbow)) - for i := PotionKind(0); i < NumPotionTypes; i++ { + + for i := range NumPotionTypes { var j int for { j = g.rnd(len(rainbow)) @@ -59,6 +60,7 @@ func (g *RogueGame) initColors() { break } } + used[j] = true g.Items.PotColors[i] = rainbow[j] } @@ -66,8 +68,9 @@ func (g *RogueGame) initColors() { // initNames generates the names of the various scrolls (init.c init_names). func (g *RogueGame) initNames() { - for i := ScrollKind(0); i < NumScrollTypes; i++ { + for i := range NumScrollTypes { var cp strings.Builder + nwords := g.rnd(3) + 2 for ; nwords > 0; nwords-- { nsyl := g.rnd(3) + 1 @@ -76,10 +79,13 @@ func (g *RogueGame) initNames() { if cp.Len()+len(sp) > MaxNameLen { break } + cp.WriteString(sp) } + cp.WriteByte(' ') } + g.Items.ScrNames[i] = strings.TrimSuffix(cp.String(), " ") } } @@ -88,7 +94,8 @@ func (g *RogueGame) initNames() { // (init.c init_stones). func (g *RogueGame) initStones() { used := make([]bool, len(stoneTable)) - for i := RingKind(0); i < NumRingTypes; i++ { + + for i := range NumRingTypes { var j int for { j = g.rnd(len(stoneTable)) @@ -96,6 +103,7 @@ func (g *RogueGame) initStones() { break } } + used[j] = true g.Items.RingStones[i] = stoneTable[j].Name g.Items.Rings[i].Worth += stoneTable[j].Value @@ -107,8 +115,10 @@ func (g *RogueGame) initStones() { func (g *RogueGame) initMaterials() { used := make([]bool, len(woods)) metused := make([]bool, len(metals)) - for i := WandKind(0); i < NumWandTypes; i++ { + + for i := range NumWandTypes { var str string + for { if g.rnd(2) == 0 { j := g.rnd(len(metals)) @@ -116,6 +126,7 @@ func (g *RogueGame) initMaterials() { g.Items.WandType[i] = "wand" str = metals[j] metused[j] = true + break } } else { @@ -124,10 +135,12 @@ func (g *RogueGame) initMaterials() { g.Items.WandType[i] = "staff" str = woods[j] used[j] = true + break } } } + g.Items.WandMade[i] = str } } @@ -166,5 +179,6 @@ func (g *RogueGame) pickColor(col string) string { if g.Player.On(Hallucinating) { return rainbow[g.rnd(len(rainbow))] } + return col } diff --git a/game/io.go b/game/io.go index a0a0ade..b32f06d 100644 --- a/game/io.go +++ b/game/io.go @@ -31,16 +31,18 @@ func (g *RogueGame) msg(format string, a ...any) int { g.move(0, 0) g.clrtoeol() g.Msgs.Mpos = 0 + return ^Escape } // otherwise add to the message and flush it out - g.doadd(format, a...) + g.doaddf(format, a...) + return g.endmsg() } -// addmsg adds things to the current message (io.c addmsg). -func (g *RogueGame) addmsg(format string, a ...any) { - g.doadd(format, a...) +// addmsgf adds things to the current message (io.c addmsg). +func (g *RogueGame) addmsgf(format string, a ...any) { + g.doaddf(format, a...) } // endmsg displays a new msg, giving the player a chance to see the previous @@ -50,10 +52,12 @@ func (g *RogueGame) endmsg() int { if m.SaveMsg { m.Huh = m.buf.String() } + if m.Mpos != 0 { g.look(false) g.mvaddstr(0, m.Mpos, "--More--") g.refresh() + if !m.MsgEsc { g.waitFor(' ') } else { @@ -62,10 +66,12 @@ func (g *RogueGame) endmsg() int { if ch == ' ' { break } + if ch == Escape { m.buf.Reset() m.Mpos = 0 m.newpos = 0 + return Escape } } @@ -75,25 +81,30 @@ func (g *RogueGame) endmsg() int { // with a pack addressing character out := m.buf.String() if len(out) > 0 && isLower(out[0]) && !m.LowerMsg && - !(len(out) > 1 && out[1] == ')') { + (len(out) <= 1 || out[1] != ')') { out = string(toUpper(out[0])) + out[1:] } + g.mvaddstr(0, 0, out) g.clrtoeol() + m.Mpos = m.newpos m.newpos = 0 m.buf.Reset() g.refresh() + return ^Escape } -// doadd performs an add onto the message buffer (io.c doadd). -func (g *RogueGame) doadd(format string, a ...any) { +// doaddf performs an add onto the message buffer (io.c doadd). +func (g *RogueGame) doaddf(format string, a ...any) { m := &g.Msgs + s := fmt.Sprintf(format, a...) if len(s)+m.newpos >= maxMsg { g.endmsg() } + m.buf.WriteString(s) m.newpos = m.buf.Len() } @@ -114,8 +125,10 @@ func (g *RogueGame) readchar() byte { ch := g.scr.term.ReadChar() if ch == 3 { // ^C g.quit(0) + return 27 } + return ch } @@ -146,17 +159,21 @@ func (g *RogueGame) status() { if p.CurArmor != nil { temp = p.CurArmor.ArmorClass } + if s.init && s.hp == p.Stats.HP && s.exp == p.Stats.Exp && s.pur == p.Purse && s.arm == temp && s.str == p.Stats.Str && s.lvl == g.Depth && s.hungry == p.HungryState && !g.StatMsg { return } + s.init = true s.arm = temp oy, ox := g.scr.Std.GetYX() + if s.hp != p.Stats.MaxHP { s.hp = p.Stats.MaxHP + s.hpwidth = 0 for t := p.Stats.MaxHP; t != 0; t /= 10 { s.hpwidth++ @@ -183,6 +200,7 @@ func (g *RogueGame) status() { g.move(StatLine, 0) g.addstr(line) } + g.clrtoeol() g.move(oy, ox) } @@ -197,7 +215,11 @@ func (g *RogueGame) waitFor(ch byte) { } } } - for g.readchar() != ch { + + for { + if g.readchar() == ch { + return + } } } @@ -221,11 +243,13 @@ func toUpper(c byte) byte { if isLower(c) { return c - 'a' + 'A' } + return c } func toLower(c byte) byte { if isUpper(c) { return c - 'A' + 'a' } + return c } diff --git a/game/level.go b/game/level.go index 07c3ccc..5eea628 100644 --- a/game/level.go +++ b/game/level.go @@ -47,6 +47,7 @@ func (l *Level) VisibleChar(y, x int) byte { if m := l.MonsterAt(y, x); m != nil { return m.Disguise } + return l.Char(y, x) } diff --git a/game/misc.go b/game/misc.go index b6efa00..2586f13 100644 --- a/game/misc.go +++ b/game/misc.go @@ -10,20 +10,24 @@ func (g *RogueGame) look(wakeup bool) { hero := p.Pos passcount := 0 rp := p.Room + if g.Oldpos != hero { g.eraseLamp(g.Oldpos, g.Oldrp) g.Oldpos = hero g.Oldrp = rp } + ey := hero.Y + 1 ex := hero.X + 1 sx := hero.X - 1 sy := hero.Y - 1 + sumhero, diffhero := 0, 0 if g.DoorStop && !g.Firstmove && g.Running { sumhero = hero.Y + hero.X diffhero = hero.Y - hero.X } + pp := g.Level.At(hero.Y, hero.X) pch := pp.Ch pfl := pp.Flags @@ -32,10 +36,12 @@ func (g *RogueGame) look(wakeup bool) { if y <= 0 || y >= NumLines-1 { continue } + for x := sx; x <= ex; x++ { if x < 0 || x >= NumCols { continue } + if !p.On(Blind) { if y == hero.Y && x == hero.X { continue @@ -43,16 +49,19 @@ func (g *RogueGame) look(wakeup bool) { } pp := g.Level.At(y, x) + ch := pp.Ch if ch == ' ' { // nothing need be done with a ' ' continue } + fp := &pp.Flags if pch != Door && ch != Door { if (pfl & FPassage) != (*fp & FPassage) { continue } } + if (fp.Has(FPassage) || ch == Door) && (pfl.Has(FPassage) || pch == Door) { if hero.X != x && hero.Y != y && !stepOk(g.Level.Char(y, hero.X)) && !stepOk(g.Level.Char(hero.Y, x)) { @@ -61,25 +70,30 @@ func (g *RogueGame) look(wakeup bool) { } tp := pp.Monst - if tp == nil { + + switch { + case tp == nil: ch = g.tripCh(y, x, ch) - } else if p.On(SenseMonsters) && tp.On(Invisible) { + case p.On(SenseMonsters) && tp.On(Invisible): if g.DoorStop && !g.Firstmove { g.Running = false } + continue - } else { + default: if wakeup { g.wakeMonster(y, x) } + if g.seeMonst(tp) { if p.On(Hallucinating) { - ch = byte(g.rnd(26) + 'A') + ch = g.randomMonsterLetter() } else { ch = tp.Disguise } } } + if p.On(Blind) && (y != hero.Y || x != hero.X) { continue } @@ -129,6 +143,7 @@ func (g *RogueGame) look(wakeup bool) { continue } } + switch ch { case Door: if x == hero.X || y == hero.Y { @@ -145,9 +160,11 @@ func (g *RogueGame) look(wakeup bool) { } } } + if g.DoorStop && !g.Firstmove && passcount > 1 { g.Running = false } + if !g.Running || !g.Options.Jump { g.mvaddch(hero.Y, hero.X, PlayerCh) } @@ -165,26 +182,30 @@ func (g *RogueGame) tripCh(y, x int, ch byte) byte { } } } + return ch } // eraseLamp erases the area shown by a lamp in a dark room // (misc.c erase_lamp). func (g *RogueGame) eraseLamp(pos Coord, rp *Room) { - if !(g.Options.SeeFloor && rp.Flags&(Gone|Dark) == Dark && - !g.Player.On(Blind)) { + if !g.Options.SeeFloor || rp.Flags&(Gone|Dark) != Dark || + g.Player.On(Blind) { return } ey := pos.Y + 1 ex := pos.X + 1 + sy := pos.Y - 1 for x := pos.X - 1; x <= ex; x++ { for y := sy; y <= ey; y++ { if y == g.Player.Pos.Y && x == g.Player.Pos.X { continue } + g.move(y, x) + if g.inch() == Floor { g.addch(' ') } @@ -198,6 +219,7 @@ func (g *RogueGame) showFloor() bool { if g.Player.Room.Flags&(Gone|Dark) == Dark && !g.Player.On(Blind) { return g.Options.SeeFloor } + return true } @@ -208,6 +230,7 @@ func (g *RogueGame) findObj(y, x int) *Object { return obj } } + return nil } @@ -217,34 +240,43 @@ func (g *RogueGame) eat() { if obj == nil { return } + if obj.Kind != KindFood { if !g.Options.Terse { g.msg("ugh, you would get ill if you ate that") } else { g.msg("that's Inedible!") } + return } + p := &g.Player if p.FoodLeft < 0 { p.FoodLeft = 0 } + if p.FoodLeft += HungerTime - 200 + g.rnd(400); p.FoodLeft > StomachSize { p.FoodLeft = StomachSize } + p.HungryState = 0 if obj == p.CurWeapon { p.CurWeapon = nil } - if obj.Which == 1 { + + switch { + case obj.Which == 1: g.msg("my, that was a yummy %s", g.Fruit) - } else if g.rnd(100) > 70 { + case g.rnd(100) > 70: p.Stats.Exp++ + g.msg("%s, this food tastes awful", g.chooseStr("bummer", "yuk")) g.checkLevel() - } else { + default: g.msg("%s, that tasted good", g.chooseStr("oh, wow", "yum")) } + g.leavePack(obj, false, false) } @@ -252,19 +284,23 @@ func (g *RogueGame) eat() { // check_level). func (g *RogueGame) checkLevel() { p := &g.Player + var i int for i = 0; eLevels[i] != 0; i++ { if eLevels[i] > p.Stats.Exp { break } } + i++ olevel := p.Stats.Lvl + p.Stats.Lvl = i if i > olevel { add := g.roll(i-olevel, 10) p.Stats.MaxHP += add p.Stats.HP += add + g.msg("welcome to level %d", i) } } @@ -275,15 +311,19 @@ func (g *RogueGame) chgStr(amt int) { if amt == 0 { return } + p := &g.Player addStr(&p.Stats.Str, amt) + comp := p.Stats.Str if p.IsRing(Left, RingAddStrength) { addStr(&comp, -p.CurRing[Left].Bonus) } + if p.IsRing(Right, RingAddStrength) { addStr(&comp, -p.CurRing[Right].Bonus) } + if comp > p.MaxStats.Str { p.MaxStats.Str = comp } @@ -303,15 +343,20 @@ func (g *RogueGame) addHaste(potion bool) bool { p := &g.Player if p.On(Hasted) { g.NoCommand += g.rnd(8) + p.Flags.Clear(Awake | Hasted) g.Extinguish(DNohaste) g.msg("you faint from exhaustion") + return false } + p.Flags.Set(Hasted) + if potion { g.Fuse(DNohaste, 0, g.rnd(4)+4, After) } + return true } @@ -330,10 +375,12 @@ func vowelstr(str string) string { if str == "" { return "" } + switch str[0] { case 'a', 'A', 'e', 'E', 'i', 'I', 'o', 'O', 'u', 'U': return "n" } + return "" } @@ -343,15 +390,19 @@ func (g *RogueGame) isCurrent(obj *Object) bool { if obj == nil { return false } + p := &g.Player if obj == p.CurArmor || obj == p.CurWeapon || obj == p.CurRing[Left] || obj == p.CurRing[Right] { if !g.Options.Terse { - g.addmsg("That's already ") + g.addmsgf("That's already ") } + g.msg("in use") + return true } + return false } @@ -367,8 +418,10 @@ func (g *RogueGame) getDir() bool { prompt = "which direction? " g.msg("%s", prompt) } + for { gotit := true + switch g.DirCh = g.readchar(); g.DirCh { case 'h', 'H': g.Delta = Coord{X: -1, Y: 0} @@ -389,30 +442,38 @@ func (g *RogueGame) getDir() bool { case Escape: g.LastDir = 0 g.resetLast() + return false default: g.Msgs.Mpos = 0 g.msg("%s", prompt) + gotit = false } + if gotit { break } } + g.DirCh = toLower(g.DirCh) g.LastDir = g.DirCh g.lastDelt = g.Delta } + if g.Player.On(Confused) && g.rnd(5) == 0 { for { g.Delta.Y = g.rnd(3) - 1 + g.Delta.X = g.rnd(3) - 1 if g.Delta.Y != 0 || g.Delta.X != 0 { break } } } + g.Msgs.Mpos = 0 + return true } @@ -422,6 +483,7 @@ func (g *RogueGame) callIt(info *ObjInfo) { info.Guess = "" } else if info.Guess == "" { g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? ")) + buf := "" if g.getStr(&buf, g.scr.Std) == Norm { if buf != "" { @@ -445,6 +507,7 @@ func (g *RogueGame) rndThing() byte { } else { i = g.rnd(len(thingList) - 1) } + return thingList[i] } @@ -454,6 +517,7 @@ func (g *RogueGame) chooseStr(ts, ns string) string { if g.Player.On(Hallucinating) { return ts } + return ns } @@ -463,8 +527,10 @@ func unctrl(ch byte) string { if ch < ' ' { return "^" + string(ch+'@') } + if ch == 0x7f { return "^?" } + return string(ch) } diff --git a/game/monsters.go b/game/monsters.go index f3131b9..9274a3d 100644 --- a/game/monsters.go +++ b/game/monsters.go @@ -21,14 +21,17 @@ func (g *RogueGame) randMonster(wander bool) byte { if wander { mons = &wandMons } + for { d := g.Depth + (g.rnd(10) - 6) if d < 0 { d = g.rnd(5) } + if d > 25 { d = g.rnd(5) + 21 } + if mons[d] != 0 { return mons[d] } @@ -38,10 +41,8 @@ func (g *RogueGame) randMonster(wander bool) byte { // newMonster picks a new monster and adds it to the list (monsters.c // new_monster). func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) { - levAdd := g.Depth - AmuletLevel - if levAdd < 0 { - levAdd = 0 - } + levAdd := max(g.Depth-AmuletLevel, 0) + attachMon(&g.Level.Monsters, tp) tp.Type = typ tp.Disguise = typ @@ -58,15 +59,19 @@ func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) { tp.Stats.Dmg = mp.Stats.Dmg tp.Stats.Str = mp.Stats.Str tp.Stats.Exp = mp.Stats.Exp + levAdd*10 + expAdd(tp) + tp.Flags = mp.Flags if g.Depth > 29 { tp.Flags.Set(Hasted) } + tp.Turn = true tp.Pack = nil + if g.Player.IsWearing(RingAggravateMonsters) { g.runto(cp) } + if typ == 'X' { tp.Disguise = g.rndThing() } @@ -81,11 +86,13 @@ func expAdd(tp *Monster) int { } else { mod = tp.Stats.MaxHP / 6 } + if tp.Stats.Lvl > 9 { mod *= 20 } else if tp.Stats.Lvl > 6 { mod *= 4 } + return mod } @@ -93,23 +100,29 @@ func expAdd(tp *Monster) int { // (monsters.c wanderer). func (g *RogueGame) wanderer() { tp := &Monster{} + var cp Coord for { - cp, _ = g.findFloor(nil, 0, true) + cp, _ = g.findFloor(true) if g.roomin(cp) != g.Player.Room { break } } + g.newMonster(tp, g.randMonster(true), cp) + if g.Player.On(SenseMonsters) { g.standout() + if !g.Player.On(Hallucinating) { g.addch(tp.Type) } else { - g.addch(byte(g.rnd(26) + 'A')) + g.addch(g.randomMonsterLetter()) } + g.standend() } + g.runto(tp.Pos) } @@ -117,10 +130,12 @@ func (g *RogueGame) wanderer() { // (monsters.c wake_monster). func (g *RogueGame) wakeMonster(y, x int) *Monster { p := &g.Player + tp := g.Level.MonsterAt(y, x) if tp == nil { panic("can't find monster in wake_monster") } + ch := tp.Type // Every time he sees a mean monster, it might start chasing him if !tp.On(Awake) && g.rnd(3) != 0 && tp.On(Mean) && !tp.On(Held) && @@ -128,24 +143,30 @@ func (g *RogueGame) wakeMonster(y, x int) *Monster { tp.Dest = &p.Pos tp.Flags.Set(Awake) } + if ch == 'M' && !p.On(Blind) && !p.On(Hallucinating) && !tp.On(Found) && !tp.On(Cancelled) && tp.On(Awake) { rp := p.Room if (rp != nil && !rp.Flags.Has(Dark)) || distance(y, x, p.Pos.Y, p.Pos.X) < LampDist { tp.Flags.Set(Found) + if !g.save(VsMagic) { if p.On(Confused) { g.Lengthen(DUnconfuse, g.spread(HuhDuration)) } else { g.Fuse(DUnconfuse, 0, g.spread(HuhDuration), After) } + p.Flags.Set(Confused) + mname := g.setMname(tp) - g.addmsg("%s", mname) + g.addmsgf("%s", mname) + if mname != "it" { - g.addmsg("'") + g.addmsgf("'") } + g.msg("s gaze has confused you") } } @@ -153,12 +174,14 @@ func (g *RogueGame) wakeMonster(y, x int) *Monster { // Let greedy ones guard gold if tp.On(Greedy) && !tp.On(Awake) { tp.Flags.Set(Awake) + if p.Room.GoldVal != 0 { tp.Dest = &p.Room.Gold } else { tp.Dest = &p.Pos } } + return tp } @@ -174,6 +197,7 @@ func (g *RogueGame) givePack(tp *Monster) { // save_throw). func (g *RogueGame) saveThrow(which int, st *Stats) bool { need := 14 + which - st.Lvl/2 + return g.roll(1, 20) >= need } @@ -185,9 +209,17 @@ func (g *RogueGame) save(which int) bool { if p.IsRing(Left, RingProtection) { which -= p.CurRing[Left].Bonus } + if p.IsRing(Right, RingProtection) { which -= p.CurRing[Right].Bonus } } + return g.saveThrow(which, &p.Stats) } + +// randomMonsterLetter picks a random monster display letter, used by the +// hallucination effects (the C rnd(26)+'A' idiom). +func (g *RogueGame) randomMonsterLetter() byte { + return byte(g.rnd(26) + 'A') //nolint:gosec // G115: 'A'..'Z' fits a byte +} diff --git a/game/move.go b/game/move.go index a7609f1..93a6311 100644 --- a/game/move.go +++ b/game/move.go @@ -13,10 +13,12 @@ func (g *RogueGame) doRun(ch byte) { // fighting, picking up, etc. (move.c do_move). func (g *RogueGame) doMove(dy, dx int) { p := &g.Player + g.Firstmove = false if g.NoMove > 0 { g.NoMove-- g.msg("you are still stuck in the bear trap") + return } // Do a confused move (maybe) @@ -27,6 +29,7 @@ func (g *RogueGame) doMove(dy, dx int) { g.After = false g.Running = false g.ToDeath = false + return } } else { @@ -37,19 +40,27 @@ over: // Check if he tried to move off the screen or make an illegal diagonal // move, and stop him if he did. hitBound := nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1 - var ch byte - var fl PlaceFlags + + var ( + ch byte + fl PlaceFlags + ) + if !hitBound { if !g.diagOk(p.Pos, nh) { g.After = false g.Running = false + return } + if g.Running && p.Pos == nh { g.After = false g.Running = false } + fl = *g.Level.FlagsAt(nh.Y, nh.X) + ch = g.Level.VisibleChar(nh.Y, nh.X) if !fl.Has(FReal) && ch == Floor { if !p.On(Levitating) { @@ -59,20 +70,25 @@ over: } } else if p.On(Held) && ch != 'F' { g.msg("you are being held") + return } } + if hitBound { ch = ' ' // fall into the wall case below } + switch ch { case ' ', '|', '-': if g.Options.PassGo && g.Running && p.Room.Flags.Has(Gone) && !p.On(Blind) { var b1, b2 bool + switch g.RunCh { case 'h', 'l': b1 = p.Pos.Y != 1 && g.turnOk(p.Pos.Y-1, p.Pos.X) + b2 = p.Pos.Y != NumLines-2 && g.turnOk(p.Pos.Y+1, p.Pos.X) if b1 != b2 { if b1 { @@ -82,13 +98,18 @@ over: g.RunCh = 'j' dy = 1 } + dx = 0 + g.turnref() + nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx} + goto over } case 'j', 'k': b1 = p.Pos.X != 0 && g.turnOk(p.Pos.Y, p.Pos.X-1) + b2 = p.Pos.X != NumCols-1 && g.turnOk(p.Pos.Y, p.Pos.X+1) if b1 != b2 { if b1 { @@ -98,13 +119,18 @@ over: g.RunCh = 'l' dx = 1 } + dy = 0 + g.turnref() + nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx} + goto over } } } + g.Running = false g.After = false case Door: @@ -112,12 +138,14 @@ over: if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPassage) { g.enterRoom(nh) } + g.moveStuff(nh, fl) case Trap: tr := g.beTrapped(nh) if tr == TrapDoor || tr == TrapTeleport { return } + g.moveStuff(nh, fl) case Passage: // when you're in a corridor, you don't know if you're in a maze @@ -130,11 +158,13 @@ over: if !fl.Has(FReal) { g.beTrapped(p.Pos) } + g.moveStuff(nh, fl) default: if ch == Stairs { g.SeenStairs = true } + g.Running = false if isUpper(ch) || g.Level.MonsterAt(nh.Y, nh.X) != nil { g.fight(nh, p.CurWeapon, false) @@ -142,6 +172,7 @@ over: if ch != Stairs { g.Take = ch } + g.moveStuff(nh, fl) } } @@ -151,9 +182,11 @@ over: func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) { p := &g.Player g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt()) + if fl.Has(FPassage) && g.Level.Char(g.Oldpos.Y, g.Oldpos.X) == Door { g.leaveRoom(nh) } + p.Pos = nh } @@ -161,17 +194,20 @@ func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) { // (move.c turn_ok). func (g *RogueGame) turnOk(y, x int) bool { pp := g.Level.At(y, x) + return pp.Ch == Door || pp.Flags&(FReal|FPassage) == (FReal|FPassage) } // turnref decides whether to refresh at a passage turning (move.c turnref). func (g *RogueGame) turnref() { p := &g.Player + pp := g.Level.At(p.Pos.Y, p.Pos.X) if !pp.Flags.Has(FSeen) { if g.Options.Jump { g.refresh() } + pp.Flags.Set(FSeen) } } @@ -182,6 +218,7 @@ func (g *RogueGame) doorOpen(rp *Room) { if rp.Flags.Has(Gone) { return } + for y := rp.Pos.Y; y < rp.Pos.Y+rp.Max.Y; y++ { for x := rp.Pos.X; x < rp.Pos.X+rp.Max.X; x++ { if isUpper(g.Level.VisibleChar(y, x)) { @@ -197,12 +234,14 @@ func (g *RogueGame) beTrapped(tc Coord) TrapKind { if p.On(Levitating) { return TrapRust // anything that's not a door or teleport } + g.Running = false g.Count = 0 pp := g.Level.At(tc.Y, tc.X) pp.Ch = Trap tr := TrapKind(pp.Flags & FTrapMask) pp.Flags.Set(FSeen) + switch tr { case TrapDoor: g.Depth++ @@ -238,6 +277,7 @@ func (g *RogueGame) beTrapped(tc Coord) TrapKind { } case TrapSleep: g.NoCommand += g.spread(5) // SLEEPTIME + p.Flags.Clear(Awake) g.msg("a strange white mist envelops you and you fall asleep") case TrapArrow: @@ -271,16 +311,20 @@ func (g *RogueGame) beTrapped(tc Coord) TrapKind { g.msg("a poisoned dart killed you") g.death('d') } + if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) { g.chgStr(-1) } + g.msg("a small dart just hit you in the shoulder") } case TrapRust: g.msg("a gush of water hits you on the head") g.rustArmor(p.CurArmor) } + g.flushType() + return tr } @@ -296,25 +340,32 @@ func (g *RogueGame) rndmove(who *Creature) Coord { if ret == who.Pos { return ret } + if !g.diagOk(who.Pos, ret) { return who.Pos } + ch := g.Level.VisibleChar(ret.Y, ret.X) if !stepOk(ch) { return who.Pos } + if ch == Scroll { var found *Object + for _, obj := range g.Level.Objects { if ret.Y == obj.Pos.Y && ret.X == obj.Pos.X { found = obj + break } } + if found != nil && found.ScrollKind() == ScrollScareMonster { return who.Pos } } + return ret } @@ -332,6 +383,7 @@ func (g *RogueGame) rustArmor(arm *Object) { } } else { arm.ArmorClass++ + if !g.Options.Terse { g.msg("your armor appears to be weaker now. Oh my!") } else { diff --git a/game/newlevel.go b/game/newlevel.go index cccc7ee..590fffb 100644 --- a/game/newlevel.go +++ b/game/newlevel.go @@ -13,6 +13,7 @@ const ( func (g *RogueGame) NewLevel() { p := &g.Player p.Flags.Clear(Held) // unhold when you go down just in case + if g.Depth > g.MaxDepth { g.MaxDepth = g.Depth } @@ -20,6 +21,7 @@ func (g *RogueGame) NewLevel() { for i := range g.Level.Places { g.Level.Places[i] = Place{Ch: ' ', Flags: FReal} } + g.clear() // Free up the monsters on the last level; the objects and their packs // go with them (the garbage collector is our free_list). @@ -27,32 +29,33 @@ func (g *RogueGame) NewLevel() { g.Level.Objects = nil g.doRooms() // Draw rooms g.doPassages() // Draw passages + p.NoFood++ + g.putThings() // Place objects (if any) // Place the traps if g.rnd(10) < g.Depth { - g.Level.TrapCount = g.rnd(g.Depth/4) + 1 - if g.Level.TrapCount > MaxTraps { - g.Level.TrapCount = MaxTraps - } + g.Level.TrapCount = min(g.rnd(g.Depth/4)+1, MaxTraps) + for i := g.Level.TrapCount; i > 0; i-- { // not only wouldn't it be NICE to have traps in mazes (not // that we care about being nice), since the trap number is // stored where the passage number is, we can't actually do it. var stairs Coord for { - stairs, _ = g.findFloor(nil, 0, false) + stairs, _ = g.findFloor(false) if g.Level.Char(stairs.Y, stairs.X) == Floor { break } } + sp := g.Level.FlagsAt(stairs.Y, stairs.X) sp.Clear(FReal) - *sp |= PlaceFlags(g.rnd(NumTrapTypes)) + *sp |= PlaceFlags(g.rnd(NumTrapTypes)) //nolint:gosec // G115: 0..7 fits } } // Place the staircase down. - stairs, _ := g.findFloor(nil, 0, false) + stairs, _ := g.findFloor(false) g.Level.Stairs = stairs g.Level.SetChar(stairs.Y, stairs.X, Stairs) g.SeenStairs = false @@ -61,13 +64,15 @@ func (g *RogueGame) NewLevel() { tp.Room = g.roomin(tp.Pos) } - hero, _ := g.findFloor(nil, 0, true) + hero, _ := g.findFloor(true) p.Pos = hero g.enterRoom(hero) g.mvaddch(hero.Y, hero.X, PlayerCh) + if p.On(SenseMonsters) { g.turnSee(false) } + if p.On(Hallucinating) { g.visuals(0) } @@ -96,13 +101,13 @@ func (g *RogueGame) putThings() { g.treasRoom() } // Do MAXOBJ attempts to put things on a level - for i := 0; i < MaxObj; i++ { + for range MaxObj { if g.rnd(100) < 36 { // Pick a new object and link it in the list obj := g.newThing() attachObj(&g.Level.Objects, obj) // Put it somewhere - obj.Pos, _ = g.findFloor(nil, 0, false) + obj.Pos, _ = g.findFloor(false) g.Level.SetChar(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph()) } } @@ -116,7 +121,7 @@ func (g *RogueGame) putThings() { obj.ArmorClass = 11 obj.Kind = KindAmulet // Put it somewhere - obj.Pos, _ = g.findFloor(nil, 0, false) + obj.Pos, _ = g.findFloor(false) g.Level.SetChar(obj.Pos.Y, obj.Pos.X, Amulet) } } @@ -124,10 +129,9 @@ func (g *RogueGame) putThings() { // treasRoom adds a treasure room (new_level.c treas_room). func (g *RogueGame) treasRoom() { rp := &g.Level.Rooms[g.rndRoom()] - spots := (rp.Max.Y-2)*(rp.Max.X-2) - minTreas - if spots > maxTreas-minTreas { - spots = maxTreas - minTreas - } + + spots := min((rp.Max.Y-2)*(rp.Max.X-2)-minTreas, maxTreas-minTreas) + numMonst := g.rnd(spots) + minTreas for nm := numMonst; nm > 0; nm-- { mp, _ := g.findFloorIn(rp, 2*maxTries, false) @@ -138,14 +142,13 @@ func (g *RogueGame) treasRoom() { } // fill up room with monsters from the next level down - nm := g.rnd(spots) + minTreas - if nm < numMonst+2 { - nm = numMonst + 2 - } + nm := max(g.rnd(spots)+minTreas, numMonst+2) + spots = (rp.Max.Y - 2) * (rp.Max.X - 2) if nm > spots { nm = spots } + g.Depth++ for ; nm > 0; nm-- { if mp, ok := g.findFloorIn(rp, maxTries, true); ok { @@ -155,5 +158,6 @@ func (g *RogueGame) treasRoom() { g.givePack(tp) } } + g.Depth-- } diff --git a/game/newlevel_test.go b/game/newlevel_test.go index 134b8b6..0d5122e 100644 --- a/game/newlevel_test.go +++ b/game/newlevel_test.go @@ -7,24 +7,30 @@ import ( func genLevel(t *testing.T, seed int32) *RogueGame { t.Helper() + g := NewGame(Config{Seed: seed}) g.NewLevel() + return g } // renderMap draws the raw level map (not the screen) as text. func renderMap(g *RogueGame) string { var sb strings.Builder - for y := 0; y < NumLines; y++ { - for x := 0; x < NumCols; x++ { + + for y := range NumLines { + for x := range NumCols { ch := g.Level.Char(y, x) if m := g.Level.MonsterAt(y, x); m != nil { ch = m.Type } + sb.WriteByte(ch) } + sb.WriteByte('\n') } + return sb.String() } @@ -44,9 +50,11 @@ func TestNewLevelInvariants(t *testing.T) { t.Errorf("seed %d: hero on unwalkable cell %q", seed, g.Level.Char(hp.Y, hp.X)) } + if g.Level.MonsterAt(hp.Y, hp.X) != nil { t.Errorf("seed %d: hero standing on a monster", seed) } + if g.Player.Room == nil { t.Errorf("seed %d: hero not in any room", seed) } @@ -56,6 +64,7 @@ func TestNewLevelInvariants(t *testing.T) { if !strings.Contains(m, "|") || !strings.Contains(m, "-") { t.Errorf("seed %d: no room walls drawn", seed) } + if !strings.Contains(m, ".") && !strings.Contains(m, "#") { t.Errorf("seed %d: no floor or passages drawn", seed) } @@ -66,6 +75,7 @@ func TestNewLevelInvariants(t *testing.T) { t.Errorf("seed %d: monster %c not indexed at its position", seed, mon.Type) } + if mon.Room == nil { t.Errorf("seed %d: monster %c has no room", seed, mon.Type) } @@ -87,10 +97,12 @@ func TestNewLevelInvariants(t *testing.T) { t.Errorf("seed %d: starting pack has %d items, want 5", seed, len(g.Player.Pack)) } + if g.Player.CurWeapon == nil || g.Player.CurWeapon.WeaponKind() != WeaponMace { t.Errorf("seed %d: not wielding the starting mace", seed) } + if g.Player.CurArmor == nil || g.Player.CurArmor.ArmorKind() != ArmorRingMail { t.Errorf("seed %d: not wearing the starting ring mail", seed) @@ -100,10 +112,12 @@ func TestNewLevelInvariants(t *testing.T) { func TestNewLevelDeterministic(t *testing.T) { a := renderMap(genLevel(t, 12345)) + b := renderMap(genLevel(t, 12345)) if a != b { t.Error("same seed produced different levels") } + c := renderMap(genLevel(t, 54321)) if a == c { t.Error("different seeds produced identical levels") @@ -118,6 +132,7 @@ func TestDeeperLevels(t *testing.T) { for depth := 1; depth <= 30; depth++ { g.Depth = depth g.NewLevel() + st := g.Level.Stairs if g.Level.Char(st.Y, st.X) != Stairs { t.Fatalf("seed %d depth %d: missing staircase", seed, depth) diff --git a/game/object.go b/game/object.go index 79ec9df..0ba68d3 100644 --- a/game/object.go +++ b/game/object.go @@ -5,6 +5,7 @@ package game // category (ObjectKind) from its display character (Glyph). type ObjectKind int +// Item categories. const ( KindNone ObjectKind = iota KindPotion @@ -44,6 +45,7 @@ func (k ObjectKind) Glyph() byte { if k < 0 || int(k) >= len(kindGlyphs) { return ' ' } + return kindGlyphs[k] } @@ -71,6 +73,7 @@ func (k ObjectKind) String() string { case KindRingOrStick: return "ring, wand or staff" } + return "bizarre thing" } @@ -82,6 +85,7 @@ func objectKindForGlyph(ch byte) ObjectKind { return ObjectKind(k) } } + return KindNone } @@ -148,6 +152,7 @@ func detachObj(list *[]*Object, item *Object) { for i, o := range *list { if o == item { *list = append((*list)[:i], (*list)[i+1:]...) + return } } diff --git a/game/options.go b/game/options.go index 86914ba..5da903d 100644 --- a/game/options.go +++ b/game/options.go @@ -28,6 +28,7 @@ type optDesc struct { // optList builds the options.c optlist for this game. func (g *RogueGame) optList() []optDesc { o := &g.Options + return []optDesc{ {"terse", "Terse output", optBool, &o.Terse, nil, nil}, {"flush", "Flush typeahead during battle", optBool, &o.FightFlush, nil, nil}, @@ -46,6 +47,7 @@ func (g *RogueGame) optList() []optDesc { func (g *RogueGame) option() { hw := g.scr.Hw optlist := g.optList() + hw.Clear() // Display current values of options for i := range optlist { @@ -56,9 +58,11 @@ func (g *RogueGame) option() { } // Set values hw.Move(0, 0) + for i := 0; i < len(optlist); i++ { op := &optlist[i] g.prOptname(op) + retval := g.getOpt(op) if retval != Norm { if retval == Quit { @@ -70,6 +74,7 @@ func (g *RogueGame) option() { i -= 2 } else { // trying to back up beyond the top hw.Move(0, 0) + i-- } } @@ -84,13 +89,14 @@ func (g *RogueGame) option() { // prOptname prints out the option name prompt (options.c pr_optname). func (g *RogueGame) prOptname(op *optDesc) { - g.scr.Hw.Printw("%s (\"%s\"): ", op.prompt, op.name) + g.scr.Hw.Printwf("%s (\"%s\"): ", op.prompt, op.name) } // putOpt prints an option's current value (options.c put_bool/put_str/ // put_inv_t). func (g *RogueGame) putOpt(op *optDesc) { hw := g.scr.Hw + switch op.kind { case optBool, optSeeFloor: hw.AddStr(boolStr(*op.boolP)) @@ -105,6 +111,7 @@ func boolStr(b bool) string { if b { return "True" } + return "False" } @@ -129,9 +136,11 @@ func (g *RogueGame) getBool(bp *bool) int { win := g.scr.Hw oy, ox := win.GetYX() win.AddStr(boolStr(*bp)) + for { win.Move(oy, ox) g.scr.RefreshWin(win) + switch g.readchar() { case 't', 'T': *bp = true @@ -145,13 +154,17 @@ func (g *RogueGame) getBool(bp *bool) int { default: win.Move(oy, ox+10) win.AddStr("(T or F)") + continue } + break } + win.Move(oy, ox) win.AddStr(boolStr(*bp)) win.AddCh('\n') + return Norm } @@ -159,10 +172,12 @@ func (g *RogueGame) getBool(bp *bool) int { // get_sf). func (g *RogueGame) getSf(bp *bool) int { wasSf := g.Options.SeeFloor + retval := g.getBool(bp) if retval == Quit { return Quit } + if wasSf != g.Options.SeeFloor { if !g.Options.SeeFloor { g.Options.SeeFloor = true @@ -172,6 +187,7 @@ func (g *RogueGame) getSf(bp *bool) int { g.look(false) } } + return Norm } @@ -183,57 +199,74 @@ func (g *RogueGame) getStr(opt *string, win *Window) int { oy, ox := win.GetYX() g.scr.RefreshWin(win) // loop reading in the string, and put it in a temporary buffer - var buf []byte - var c byte + var ( + buf []byte + c byte + ) for { c = g.readchar() if c == '\n' || c == '\r' || c == Escape { break } + if c == 8 || c == 0x7f { // erase character if len(buf) > 0 { buf = buf[:len(buf)-1] win.Move(oy, ox+len(displayStr(buf))) } + win.Clrtoeol() g.scr.RefreshWin(win) + continue } + if c == CTRL('U') { // kill character buf = buf[:0] + win.Move(oy, ox) win.Clrtoeol() g.scr.RefreshWin(win) + continue } + if len(buf) == 0 { if c == '-' && !onStd { break } + if c == '~' { buf = append(buf, g.Home...) win.AddStr(g.Home) win.Clrtoeol() g.scr.RefreshWin(win) + continue } } - if len(buf) >= MaxInp || !(isPrint(c) || c == ' ') { + + if len(buf) >= MaxInp || (!isPrint(c) && c != ' ') { continue // C beeps here } + buf = append(buf, c) win.AddStr(unctrl(c)) win.Clrtoeol() g.scr.RefreshWin(win) } + if len(buf) > 0 { // only change option if something has been typed *opt = strucpy(string(buf)) } - win.MvPrintw(oy, ox, "%s\n", *opt) + + win.MvPrintwf(oy, ox, "%s\n", *opt) g.scr.RefreshWin(win) + if onStd { g.Msgs.Mpos += len(buf) } + switch c { case '-': return Minus @@ -250,6 +283,7 @@ func displayStr(buf []byte) string { for _, c := range buf { sb.WriteString(unctrl(c)) } + return sb.String() } @@ -258,9 +292,11 @@ func (g *RogueGame) getInvT(ip *int) int { win := g.scr.Hw oy, ox := win.GetYX() win.AddStr(invTName[*ip]) + for { win.Move(oy, ox) g.scr.RefreshWin(win) + switch g.readchar() { case 'o', 'O': *ip = InvOver @@ -276,11 +312,15 @@ func (g *RogueGame) getInvT(ip *int) int { default: win.Move(oy, ox+15) win.AddStr("(O, S, or C)") + continue } + break } - win.MvPrintw(oy, ox, "%s\n", invTName[*ip]) + + win.MvPrintwf(oy, ox, "%s\n", invTName[*ip]) + return Norm } @@ -289,20 +329,25 @@ func (g *RogueGame) getInvT(ip *int) int { // "noname" (false), strings as "name=..." (options.c parse_opts). func (g *RogueGame) ParseOpts(str string) { optlist := g.optList() + for str != "" { // Get option name i := 0 for i < len(str) && isAlpha(str[i]) { i++ } + name := str[:i] rest := str[i:] matched := false + for oi := range optlist { op := &optlist[oi] + isBoolOpt := op.kind == optBool || op.kind == optSeeFloor if strings.HasPrefix(op.name, name) && name != "" { matched = true + if isBoolOpt { *op.boolP = true } else { @@ -310,10 +355,13 @@ func (g *RogueGame) ParseOpts(str string) { for rest != "" && rest[0] == '=' { rest = rest[1:] } + val := rest + var prefix string if val != "" && val[0] == '~' { prefix = g.Home + val = val[1:] for val != "" && val[0] == '/' { val = val[1:] @@ -324,17 +372,21 @@ func (g *RogueGame) ParseOpts(str string) { if end < 0 { end = len(val) } + word := val[:end] rest = val[end:] + if op.kind == optInvT { // check for type of inventory w := word if w != "" { w = string(toUpper(w[0])) + w[1:] } + for ti, tn := range invTName { if strings.HasPrefix(tn, w) { *op.intP = ti + break } } @@ -342,19 +394,23 @@ func (g *RogueGame) ParseOpts(str string) { *op.strP = prefix + strucpy(word) } } + break } else if isBoolOpt && strings.HasPrefix(name, "no") && strings.HasPrefix(op.name, name[2:]) { matched = true *op.boolP = false + break } } + _ = matched // skip to start of next option name for rest != "" && !isAlpha(rest[0]) { rest = rest[1:] } + str = rest } } @@ -365,11 +421,14 @@ func strucpy(s string) string { if len(s) > MaxInp { s = s[:MaxInp] } + var sb strings.Builder - for i := 0; i < len(s); i++ { + + for i := range len(s) { if isPrint(s[i]) || s[i] == ' ' { sb.WriteByte(s[i]) } } + return sb.String() } diff --git a/game/pack.go b/game/pack.go index 6f2f932..4e2727f 100644 --- a/game/pack.go +++ b/game/pack.go @@ -7,10 +7,12 @@ package game func (g *RogueGame) addPack(obj *Object, silent bool) { p := &g.Player fromFloor := false + if obj == nil { if obj = g.findObj(p.Pos.Y, p.Pos.X); obj == nil { return } + fromFloor = true } @@ -18,12 +20,15 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster && obj.Flags.Has(WasFound) { detachObj(&g.Level.Objects, obj) g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh()) + if p.Room.Flags.Has(Gone) { g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage) } else { g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor) } + g.msg("the scroll turns to dust as you pick it up") + return } @@ -38,9 +43,11 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { // after; -1 after a merge means no insertion. lp := -1 merged := false + for i := 0; i < len(p.Pack); i++ { if p.Pack[i].Kind != obj.Kind { lp = i + continue } // found the group of our type: scan for matching subtype @@ -49,19 +56,23 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { if i+1 >= len(p.Pack) { break } + i++ } + op := p.Pack[i] if op.Kind == obj.Kind && op.Which == obj.Which { - if op.Kind.MergesInPack() { + switch { + case op.Kind.MergesInPack(): if !g.packRoom(fromFloor, obj) { return } + op.Count++ obj = op lp = -1 merged = true - } else if obj.Group != 0 { + case obj.Group != 0: lp = i for p.Pack[i].Kind == obj.Kind && p.Pack[i].Which == obj.Which && @@ -70,24 +81,29 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { if i+1 >= len(p.Pack) { break } + i++ } + op = p.Pack[i] if op.Kind == obj.Kind && op.Which == obj.Which && op.Group == obj.Group { op.Count += obj.Count p.Inpack-- + if !g.packRoom(fromFloor, obj) { return } + obj = op lp = -1 merged = true } - } else { + default: lp = i } } + break } @@ -95,6 +111,7 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { if !g.packRoom(fromFloor, obj) { return } + obj.PackCh = g.packChar() p.Pack = append(p.Pack[:lp+1], append([]*Object{obj}, p.Pack[lp+1:]...)...) @@ -117,8 +134,9 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { // Notify the user if !silent { if !g.Options.Terse { - g.addmsg("you now have ") + g.addmsgf("you now have ") } + g.msg("%s (%c)", g.invName(obj, !g.Options.Terse), obj.PackCh) } } @@ -129,29 +147,37 @@ func (g *RogueGame) packRoom(fromFloor bool, obj *Object) bool { p := &g.Player if p.Inpack++; p.Inpack > MaxPack { if !g.Options.Terse { - g.addmsg("there's ") + g.addmsgf("there's ") } - g.addmsg("no room") + + g.addmsgf("no room") + if !g.Options.Terse { - g.addmsg(" in your pack") + g.addmsgf(" in your pack") } + g.endmsg() + if fromFloor { g.moveMsg(obj) } + p.Inpack = MaxPack + return false } if fromFloor { detachObj(&g.Level.Objects, obj) g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh()) + if p.Room.Flags.Has(Gone) { g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage) } else { g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor) } } + return true } @@ -159,13 +185,16 @@ func (g *RogueGame) packRoom(fromFloor bool, obj *Object) bool { func (g *RogueGame) leavePack(obj *Object, newobj, all bool) *Object { p := &g.Player p.Inpack-- + nobj := obj if obj.Count > 1 && !all { g.LastPick = obj + obj.Count-- if obj.Group != 0 { p.Inpack++ } + if newobj { copied := *obj nobj = &copied @@ -176,6 +205,7 @@ func (g *RogueGame) leavePack(obj *Object, newobj, all bool) *Object { p.PackUsed[obj.PackCh-'a'] = false detachObj(&p.Pack, obj) } + return nobj } @@ -185,9 +215,11 @@ func (g *RogueGame) packChar() byte { for i := range p.PackUsed { if !p.PackUsed[i] { p.PackUsed[i] = true + return byte(i) + 'a' } } + return byte(len(p.PackUsed)) + 'a' // C would walk off the array here } @@ -195,24 +227,26 @@ func (g *RogueGame) packChar() byte { // of the given type (pack.c inventory). func (g *RogueGame) inventory(list []*Object, kind ObjectKind) bool { g.NObjs = 0 + for _, item := range list { - if kind != KindNone && kind != item.Kind && - !(kind == KindCallable && item.Kind != KindFood && - item.Kind != KindAmulet) && - !(kind == KindRingOrStick && - (item.Kind == KindRing || item.Kind == KindWand)) { + if !matchesFilter(kind, item) { continue } + g.NObjs++ g.Msgs.MsgEsc = true + line := string(item.PackCh) + ") " + g.invName(item, false) if g.addLine("%s", line) == Escape { g.Msgs.MsgEsc = false g.msg("") + return true } + g.Msgs.MsgEsc = false } + if g.NObjs == 0 { if g.Options.Terse { if kind == KindNone { @@ -227,9 +261,12 @@ func (g *RogueGame) inventory(list []*Object, kind ObjectKind) bool { g.msg("you don't have anything appropriate") } } + return false } + g.endLine() + return true } @@ -243,27 +280,49 @@ func (g *RogueGame) pickUp(ch byte) { obj := g.findObj(p.Pos.Y, p.Pos.X) if g.MoveOn { g.moveMsg(obj) + return } + switch ch { case Gold: if obj == nil { return } + g.money(obj.GoldValue) detachObj(&g.Level.Objects, obj) + p.Room.GoldVal = 0 default: g.addPack(nil, false) } } +// matchesFilter reports whether an item passes a get_item/inventory kind +// filter (the C condition in pack.c inventory, untangled): KindNone takes +// everything, KindCallable takes anything nameable (not food, not the +// amulet), KindRingOrStick takes rings and wands. +func matchesFilter(kind ObjectKind, item *Object) bool { + switch kind { + case KindNone: + return true + case KindCallable: + return item.Kind != KindFood && item.Kind != KindAmulet + case KindRingOrStick: + return item.Kind == KindRing || item.Kind == KindWand + default: + return item.Kind == kind + } +} + // moveMsg prints the message if you are just moving onto an object // (pack.c move_msg). func (g *RogueGame) moveMsg(obj *Object) { if !g.Options.Terse { - g.addmsg("you ") + g.addmsgf("you ") } + g.msg("moved onto %s", g.invName(obj, true)) } @@ -273,25 +332,34 @@ func (g *RogueGame) pickyInven() { p := &g.Player if len(p.Pack) == 0 { g.msg("you aren't carrying anything") + return } + if len(p.Pack) == 1 { g.msg("a) %s", g.invName(p.Pack[0], false)) + return } + g.msg("%s", g.chooseTerse("item: ", "which item do you wish to inventory: ")) g.Msgs.Mpos = 0 + mch := g.readchar() if mch == Escape { g.msg("") + return } + for _, obj := range p.Pack { if mch == obj.PackCh { g.msg("%c) %s", mch, g.invName(obj, false)) + return } } + g.msg("'%s' not in pack", unctrl(mch)) } @@ -300,23 +368,31 @@ func (g *RogueGame) getItem(purpose string, kind ObjectKind) *Object { p := &g.Player if len(p.Pack) == 0 { g.msg("you aren't carrying anything") + return nil } + if g.Again { if g.LastPick != nil { return g.LastPick } + g.msg("you ran out") + return nil } + for { if !g.Options.Terse { - g.addmsg("which object do you want to ") + g.addmsgf("which object do you want to ") } - g.addmsg("%s", purpose) + + g.addmsgf("%s", purpose) + if g.Options.Terse { - g.addmsg(" what") + g.addmsgf(" what") } + g.msg("? (* for list): ") ch := g.readchar() g.Msgs.Mpos = 0 @@ -325,22 +401,28 @@ func (g *RogueGame) getItem(purpose string, kind ObjectKind) *Object { g.resetLast() g.After = false g.msg("") + return nil } + g.NObjs = 1 // normal case: person types one char if ch == '*' { g.Msgs.Mpos = 0 if !g.inventory(p.Pack, kind) { g.After = false + return nil } + continue } + for _, obj := range p.Pack { if obj.PackCh == ch { return obj } } + g.msg("'%s' is not a valid item", unctrl(ch)) } } @@ -350,15 +432,18 @@ func (g *RogueGame) money(value int) { p := &g.Player p.Purse += value g.mvaddch(p.Pos.Y, p.Pos.X, g.floorCh()) + if p.Room.Flags.Has(Gone) { g.Level.SetChar(p.Pos.Y, p.Pos.X, Passage) } else { g.Level.SetChar(p.Pos.Y, p.Pos.X, Floor) } + if value > 0 { if !g.Options.Terse { - g.addmsg("you found ") + g.addmsgf("you found ") } + g.msg("%d gold pieces", value) } } @@ -369,9 +454,11 @@ func (g *RogueGame) floorCh() byte { if g.Player.Room.Flags.Has(Gone) { return Passage } + if g.showFloor() { return Floor } + return ' ' } @@ -382,6 +469,7 @@ func (g *RogueGame) floorAt() byte { if ch == Floor { ch = g.floorCh() } + return ch } @@ -398,5 +486,6 @@ func (g *RogueGame) chooseTerse(terse, verbose string) string { if g.Options.Terse { return terse } + return verbose } diff --git a/game/passages.go b/game/passages.go index afccb3e..6706726 100644 --- a/game/passages.go +++ b/game/passages.go @@ -17,25 +17,30 @@ var rdesConn = [MaxRooms][MaxRooms]bool{ // doPassages draws all the passages on a level (passages.c do_passages). func (g *RogueGame) doPassages() { - var isconn [MaxRooms][MaxRooms]bool - var ingraph [MaxRooms]bool + var ( + isconn [MaxRooms][MaxRooms]bool + ingraph [MaxRooms]bool + ) // starting with one room, connect it to a random adjacent room and // then pick a new room to start with. roomcount := 1 r1 := g.rnd(MaxRooms) ingraph[r1] = true + for { // find a room to connect with j := 0 r2 := -1 - for i := 0; i < MaxRooms; i++ { + + for i := range MaxRooms { if rdesConn[r1][i] && !ingraph[i] { if j++; g.rnd(j) == 0 { r2 = i } } } + if j == 0 { // if no adjacent rooms are outside the graph, pick a new room // to look from @@ -48,12 +53,13 @@ func (g *RogueGame) doPassages() { } else { // otherwise, connect new room to the graph, and draw a tunnel // to it - ingraph[r2] = true + ingraph[r2] = true //nolint:gosec // G602: rnd(MaxRooms) bounded g.conn(r1, r2) isconn[r1][r2] = true - isconn[r2][r1] = true + isconn[r2][r1] = true //nolint:gosec // G602: rnd(MaxRooms) bounded roomcount++ } + if roomcount >= MaxRooms { break } @@ -66,7 +72,8 @@ func (g *RogueGame) doPassages() { // find an adjacent room not already connected j := 0 r2 := -1 - for i := 0; i < MaxRooms; i++ { + + for i := range MaxRooms { if rdesConn[r1][i] && !isconn[r1][i] { if j++; g.rnd(j) == 0 { r2 = i @@ -77,17 +84,21 @@ func (g *RogueGame) doPassages() { if j != 0 { g.conn(r1, r2) isconn[r1][r2] = true - isconn[r2][r1] = true + isconn[r2][r1] = true //nolint:gosec // G602: rnd(MaxRooms) bounded } } + g.passnum() } // conn draws a corridor from a room in a certain direction (passages.c // conn). func (g *RogueGame) conn(r1, r2 int) { - var rm int - var direc byte + var ( + rm int + direc byte + ) + if r1 < r2 { rm = r1 if r1+1 == r2 { @@ -103,42 +114,52 @@ func (g *RogueGame) conn(r1, r2 int) { direc = 'd' } } + rpf := &g.Level.Rooms[rm] // Set up the movement variables, in two cases: first drawing one down. - var rpt *Room - var del, turnDelta, spos, epos Coord - var distance, turnDistance int + var ( + rpt *Room + del, turnDelta, spos, epos Coord + distance, turnDistance int + ) + if direc == 'd' { rmt := rm + 3 // room # of dest rpt = &g.Level.Rooms[rmt] // room pointer of dest del = Coord{X: 0, Y: 1} // direction of move spos = rpf.Pos // start of move epos = rpt.Pos // end of move + if !rpf.Flags.Has(Gone) { // if not gone pick door pos for { spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1 + spos.Y = rpf.Pos.Y + rpf.Max.Y - 1 - if !(rpf.Flags.Has(Maze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage)) { + if !rpf.Flags.Has(Maze) || g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage) { break } } } + if !rpt.Flags.Has(Gone) { for { epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1 - if !(rpt.Flags.Has(Maze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage)) { + if !rpt.Flags.Has(Maze) || g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage) { break } } } + distance = abs(spos.Y-epos.Y) - 1 // distance to move - turnDelta.Y = 0 // direction to turn + + turnDelta.Y = 0 // direction to turn if spos.X < epos.X { turnDelta.X = 1 } else { turnDelta.X = -1 } + turnDistance = abs(spos.X - epos.X) // how far to turn } else { // setup for moving right rmt := rm + 1 @@ -146,29 +167,34 @@ func (g *RogueGame) conn(r1, r2 int) { del = Coord{X: 1, Y: 0} spos = rpf.Pos epos = rpt.Pos + if !rpf.Flags.Has(Gone) { for { spos.X = rpf.Pos.X + rpf.Max.X - 1 + spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1 - if !(rpf.Flags.Has(Maze) && !g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage)) { + if !rpf.Flags.Has(Maze) || g.Level.FlagsAt(spos.Y, spos.X).Has(FPassage) { break } } } + if !rpt.Flags.Has(Gone) { for { epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1 - if !(rpt.Flags.Has(Maze) && !g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage)) { + if !rpt.Flags.Has(Maze) || g.Level.FlagsAt(epos.Y, epos.X).Has(FPassage) { break } } } + distance = abs(spos.X-epos.X) - 1 if spos.Y < epos.Y { turnDelta.Y = 1 } else { turnDelta.Y = -1 } + turnDelta.X = 0 turnDistance = abs(spos.Y - epos.Y) } @@ -182,6 +208,7 @@ func (g *RogueGame) conn(r1, r2 int) { } else { g.putpass(spos) } + if !rpt.Flags.Has(Gone) { g.door(rpt, epos) } else { @@ -203,9 +230,12 @@ func (g *RogueGame) conn(r1, r2 int) { } // Continue digging along g.putpass(curr) + distance-- } + curr.X += del.X + curr.Y += del.Y if curr != epos { g.msg("warning, connectivity problem on this level") @@ -217,6 +247,7 @@ func (g *RogueGame) conn(r1, r2 int) { func (g *RogueGame) putpass(cp Coord) { pp := g.Level.At(cp.Y, cp.X) pp.Flags.Set(FPassage) + if g.rnd(10)+1 < g.Depth && g.rnd(40) == 0 { pp.Flags.Clear(FReal) } else { @@ -240,6 +271,7 @@ func (g *RogueGame) door(rm *Room, cp Coord) { } else { pp.Ch = '|' } + pp.Flags.Clear(FReal) } else { pp.Ch = Door @@ -250,7 +282,7 @@ func (g *RogueGame) door(rm *Room, cp Coord) { // (passages.c add_pass). func (g *RogueGame) addPass() { for y := 1; y < NumLines-1; y++ { - for x := 0; x < NumCols; x++ { + for x := range NumCols { pp := g.Level.At(y, x) if pp.Flags.Has(FPassage) || pp.Ch == Door || (!pp.Flags.Has(FReal) && (pp.Ch == '|' || pp.Ch == '-')) { @@ -258,19 +290,24 @@ func (g *RogueGame) addPass() { if pp.Flags.Has(FPassage) { ch = Passage } + pp.Flags.Set(FSeen) g.move(y, x) - if pp.Monst != nil { + + switch { + case pp.Monst != nil: pp.Monst.OldCh = pp.Ch - } else if pp.Flags.Has(FReal) { + case pp.Flags.Has(FReal): g.addch(ch) - } else { + default: g.standout() + if pp.Flags.Has(FPassage) { g.addch(Passage) } else { g.addch(Door) } + g.standend() } } @@ -281,10 +318,12 @@ func (g *RogueGame) addPass() { // passnum assigns a number to each passageway (passages.c passnum). func (g *RogueGame) passnum() { g.pnum = 0 + g.newpnum = false for i := range g.Level.Passages { g.Level.Passages[i].Exits = g.Level.Passages[i].Exits[:0] } + for i := range g.Level.Rooms { rp := &g.Level.Rooms[i] for j := range rp.Exits { @@ -300,10 +339,12 @@ func (g *RogueGame) numpass(y, x int) { if x >= NumCols || x < 0 || y >= NumLines || y <= 0 { return } + fp := g.Level.FlagsAt(y, x) if fp.Has(FPassNum) { return } + if g.newpnum { g.pnum++ g.newpnum = false @@ -317,7 +358,8 @@ func (g *RogueGame) numpass(y, x int) { } else if !fp.Has(FPassage) { return } - *fp |= PlaceFlags(g.pnum) + + *fp |= PlaceFlags(g.pnum) //nolint:gosec // G115: pnum < MaxPass=13 // recurse on the surrounding places g.numpass(y+1, x) g.numpass(y-1, x) @@ -330,5 +372,6 @@ func abs(n int) int { if n < 0 { return -n } + return n } diff --git a/game/potions.go b/game/potions.go index a9ba020..e4723c4 100644 --- a/game/potions.go +++ b/game/potions.go @@ -39,21 +39,26 @@ func (g *RogueGame) quaff() { if obj == nil { return } + if obj.Kind != KindPotion { if !g.Options.Terse { g.msg("yuk! Why would you want to drink that?") } else { g.msg("that's undrinkable") } + return } + if obj == p.CurWeapon { p.CurWeapon = nil } // Calculate the effect it has on the poor guy. trip := p.On(Hallucinating) + g.leavePack(obj, false, false) + switch obj.PotionKind() { case PotionConfusion: g.doPot(PotionConfusion, !trip) @@ -72,6 +77,7 @@ func (g *RogueGame) quaff() { p.Stats.MaxHP++ p.Stats.HP = p.Stats.MaxHP } + g.sight(0) g.msg("you begin to feel better") case PotionGainStrength: @@ -81,6 +87,7 @@ func (g *RogueGame) quaff() { case PotionDetectMonsters: p.Flags.Set(SenseMonsters) g.Fuse(DTurnSee, 1, HuhDuration, After) + if !g.turnSee(false) { g.msg("you have a %s feeling for a moment, then it passes", g.chooseStr("normal", "strange")) @@ -88,24 +95,30 @@ func (g *RogueGame) quaff() { case PotionDetectMagic: // Potion of magic detection. Show the potions and scrolls show := false + if len(g.Level.Objects) > 0 { g.scr.Hw.Clear() + for _, tp := range g.Level.Objects { if tp.isMagic() { show = true + g.scr.Hw.MvAddCh(tp.Pos.Y, tp.Pos.X, Magic) g.Items.Potions[PotionDetectMagic].Know = true } } + for _, mp := range g.Level.Monsters { for _, tp := range mp.Pack { if tp.isMagic() { show = true + g.scr.Hw.MvAddCh(mp.Pos.Y, mp.Pos.X, Magic) } } } } + if show { g.Items.Potions[PotionDetectMagic].Know = true g.showWin("You sense the presence of magic on this level.--More--") @@ -118,16 +131,21 @@ func (g *RogueGame) quaff() { if p.On(SenseMonsters) { g.turnSee(false) } + g.StartDaemon(DVisuals, 0, Before) g.SeenStairs = g.seenStairs() } + g.doPot(PotionLSD, true) case PotionSeeInvisible: show := p.On(CanSeeInvisible) + g.doPot(PotionSeeInvisible, false) + if !show { g.invisOn() } + g.sight(0) case PotionRaiseLevel: g.Items.Potions[PotionRaiseLevel].Know = true @@ -139,14 +157,17 @@ func (g *RogueGame) quaff() { if p.Stats.HP > p.Stats.MaxHP+p.Stats.Lvl+1 { p.Stats.MaxHP++ } + p.Stats.MaxHP++ p.Stats.HP = p.Stats.MaxHP } + g.sight(0) g.comeDown(0) g.msg("you begin to feel much better") case PotionHaste: g.Items.Potions[PotionHaste].Know = true + g.After = false if g.addHaste(true) { g.msg("you feel yourself moving much faster") @@ -155,24 +176,30 @@ func (g *RogueGame) quaff() { if p.IsRing(Left, RingAddStrength) { addStr(&p.Stats.Str, -p.CurRing[Left].Bonus) } + if p.IsRing(Right, RingAddStrength) { addStr(&p.Stats.Str, -p.CurRing[Right].Bonus) } + if p.Stats.Str < p.MaxStats.Str { p.Stats.Str = p.MaxStats.Str } + if p.IsRing(Left, RingAddStrength) { addStr(&p.Stats.Str, p.CurRing[Left].Bonus) } + if p.IsRing(Right, RingAddStrength) { addStr(&p.Stats.Str, p.CurRing[Right].Bonus) } + g.msg("hey, this tastes great. It make you feel warm all over") case PotionBlindness: g.doPot(PotionBlindness, true) case PotionLevitation: g.doPot(PotionLevitation, true) } + g.status() // Throw the item away g.callIt(&g.Items.Potions[obj.Which]) @@ -192,6 +219,7 @@ func (g *RogueGame) doPot(kind PotionKind, knowit bool) { if !g.Items.Potions[kind].Know { g.Items.Potions[kind].Know = knowit } + t := g.spread(pp.time) if !g.Player.On(pp.flags) { g.Player.Flags.Set(pp.flags) @@ -200,11 +228,14 @@ func (g *RogueGame) doPot(kind PotionKind, knowit bool) { } else { g.Lengthen(pp.daemon, t) } + high, straight := pp.high, pp.straight + if kind == PotionSeeInvisible { s := fmt.Sprintf("this potion tastes like %s juice", g.Fruit) high, straight = s, s } + g.msg("%s", g.chooseStr(high, straight)) } @@ -218,6 +249,7 @@ func (o *Object) isMagic() bool { case KindPotion, KindScroll, KindWand, KindRing, KindAmulet: return true } + return false } @@ -225,6 +257,7 @@ func (o *Object) isMagic() bool { // invis_on). func (g *RogueGame) invisOn() { g.Player.Flags.Set(CanSeeInvisible) + for _, mp := range g.Level.Monsters { if mp.On(Invisible) && g.seeMonst(mp) && !g.Player.On(Hallucinating) { g.mvaddch(mp.Pos.Y, mp.Pos.X, mp.Disguise) @@ -236,8 +269,10 @@ func (g *RogueGame) invisOn() { // turn_see). func (g *RogueGame) turnSee(turnOff bool) bool { addNew := false + for _, mp := range g.Level.Monsters { g.move(mp.Pos.Y, mp.Pos.X) + canSee := g.seeMonst(mp) if turnOff { if !canSee { @@ -247,22 +282,27 @@ func (g *RogueGame) turnSee(turnOff bool) bool { if !canSee { g.standout() } + if !g.Player.On(Hallucinating) { g.addch(mp.Type) } else { - g.addch(byte(g.rnd(26) + 'A')) + g.addch(g.randomMonsterLetter()) } + if !canSee { g.standend() + addNew = true } } } + if turnOff { g.Player.Flags.Clear(SenseMonsters) } else { g.Player.Flags.Set(SenseMonsters) } + return addNew } @@ -271,9 +311,11 @@ func (g *RogueGame) turnSee(turnOff bool) bool { func (g *RogueGame) seenStairs() bool { st := g.Level.Stairs g.move(st.Y, st.X) + if g.inch() == Stairs { // it's on the map return true } + if g.Player.Pos == st { // it's under him return true } @@ -282,9 +324,11 @@ func (g *RogueGame) seenStairs() bool { if g.seeMonst(tp) && tp.On(Awake) { // visible and awake: return true // it must have moved there } + if g.Player.On(SenseMonsters) && tp.OldCh == Stairs { return true } } + return false } diff --git a/game/rings.go b/game/rings.go index cede65f..c58e893 100644 --- a/game/rings.go +++ b/game/rings.go @@ -12,12 +12,14 @@ func (g *RogueGame) ringOn() { if obj == nil { return } + if obj.Kind != KindRing { if !g.Options.Terse { g.msg("it would be difficult to wrap that around a finger") } else { g.msg("not a ring") } + return } @@ -27,6 +29,7 @@ func (g *RogueGame) ringOn() { } var ring int + switch { case p.CurRing[Left] == nil && p.CurRing[Right] == nil: if ring = g.gethand(); ring < 0 { @@ -42,8 +45,10 @@ func (g *RogueGame) ringOn() { } else { g.msg("wearing two") } + return } + p.CurRing[ring] = obj // Calculate the effect it has on the poor guy. @@ -57,15 +62,18 @@ func (g *RogueGame) ringOn() { } if !g.Options.Terse { - g.addmsg("you are now wearing ") + g.addmsgf("you are now wearing ") } + g.msg("%s (%c)", g.invName(obj, true), obj.PackCh) } // ringOff takes off a ring (rings.c ring_off). func (g *RogueGame) ringOff() { p := &g.Player + var ring int + switch { case p.CurRing[Left] == nil && p.CurRing[Right] == nil: if g.Options.Terse { @@ -73,6 +81,7 @@ func (g *RogueGame) ringOff() { } else { g.msg("you aren't wearing any rings") } + return case p.CurRing[Left] == nil: ring = Right @@ -83,12 +92,16 @@ func (g *RogueGame) ringOff() { return } } + g.Msgs.Mpos = 0 + obj := p.CurRing[ring] if obj == nil { g.msg("not wearing such a ring") + return } + if g.dropCheck(obj) { g.msg("was wearing %s(%c)", g.invName(obj, true), obj.PackCh) } @@ -102,17 +115,22 @@ func (g *RogueGame) gethand() int { } else { g.msg("left hand or right hand? ") } + c := g.readchar() if c == Escape { return -1 } + g.Msgs.Mpos = 0 + if c == 'l' || c == 'L' { return Left } + if c == 'r' || c == 'R' { return Right } + if g.Options.Terse { g.msg("L or R") } else { @@ -147,6 +165,7 @@ func (g *RogueGame) ringEat(hand int) int { if ring == nil { return 0 } + eat := ringUses[ring.RingKind()] if eat < 0 { if g.rnd(-eat) == 0 { @@ -155,20 +174,25 @@ func (g *RogueGame) ringEat(hand int) int { eat = 0 } } + if ring.RingKind() == RingSlowDigestion { eat = -eat } + return eat } -// ringNum prints ring bonuses (rings.c ring_num). -func ringNum(g *RogueGame, obj *Object) string { +// ringNum prints ring bonuses (rings.c ring_num). The unused game +// parameter keeps the nameit prfunc signature. +func ringNum(_ *RogueGame, obj *Object) string { if !obj.Flags.Has(Known) { return "" } + switch obj.RingKind() { case RingProtection, RingAddStrength, RingIncreaseDamage, RingDexterity: return fmt.Sprintf(" [%s]", num(obj.Bonus, 0, Ring)) } + return "" } diff --git a/game/rip.go b/game/rip.go index 7d488dc..7136f7a 100644 --- a/game/rip.go +++ b/game/rip.go @@ -11,12 +11,14 @@ import ( // that Run recovers, so the terminal is restored by normal unwinding. // gameEnd is the sentinel carried by the panic that replaces my_exit(). -type gameEnd struct{ status int } +type gameEnd struct{} // myExit leaves the process properly (main.c my_exit): it unwinds to Run. -func (g *RogueGame) myExit(st int) { +// Every C caller exited with status 0; abnormal exits panic for real. +func (g *RogueGame) myExit() { g.Playing = false - panic(gameEnd{status: st}) + + panic(gameEnd{}) } var ripArt = []string{ @@ -39,39 +41,51 @@ var ripArt = []string{ func (g *RogueGame) death(monst byte) { p := &g.Player p.Purse -= p.Purse / 10 + g.clear() + killer := g.killname(monst, false) if !g.Options.Tombstone { - g.scr.Std.MvPrintw(NumLines-2, 0, "Killed by ") + g.scr.Std.MvPrintwf(NumLines-2, 0, "Killed by ") + if monst != 's' && monst != 'h' { g.printw("a%s ", vowelstr(killer)) } + g.printw("%s with %d gold", killer, p.Purse) } else { year := time.Now().Year() + for i, line := range ripArt { g.scr.Std.MvAddStr(8+i, 0, line) } + g.mvaddstr(17, center(killer), killer) + if monst == 's' || monst == 'h' { g.mvaddch(16, 32, ' ') } else { g.mvaddstr(16, 33, vowelstr(killer)) } + g.mvaddstr(14, center(g.Whoami), g.Whoami) + au := fmt.Sprintf("%d Au", p.Purse) g.mvaddstr(15, center(au), au) g.mvaddstr(18, 26, fmt.Sprintf("%4d", year)) } + g.mvaddstr(NumLines-1, 0, "[Press return to continue]") g.refresh() + flags := 0 if g.HasAmulet { flags = 3 } + g.score(p.Purse, flags, monst) g.waitFor('\n') - g.myExit(0) + g.myExit() } // center returns the column to center the given string on the tombstone @@ -85,6 +99,7 @@ func (g *RogueGame) totalWinner() { p := &g.Player g.clear() g.standout() + banner := []string{ " ", " @ @ @ @ @ @@@ @ @ ", @@ -100,6 +115,7 @@ func (g *RogueGame) totalWinner() { for i, line := range banner { g.scr.Std.MvAddStr(i, 0, line) } + g.standend() g.scr.Std.MvAddStr(10, 0, "You have joined the elite ranks of those who have escaped the") g.scr.Std.MvAddStr(11, 0, "Dungeons of Doom alive. You journey home and sell all your loot at") @@ -110,11 +126,14 @@ func (g *RogueGame) totalWinner() { g.clear() g.mvaddstr(0, 0, " Worth Item") g.move(1, 0) + oldpurse := p.Purse line := 1 + for _, obj := range p.Pack { worth := 0 it := &g.Items + switch obj.Kind { case KindFood: worth = 2 * obj.Count @@ -129,21 +148,26 @@ func (g *RogueGame) totalWinner() { obj.Flags.Set(Known) case KindScroll: op := &it.Scrolls[obj.Which] + worth = op.Worth * obj.Count if !op.Know { worth /= 2 } + op.Know = true case KindPotion: op := &it.Potions[obj.Which] + worth = op.Worth * obj.Count if !op.Know { worth /= 2 } + op.Know = true case KindRing: op := &it.Rings[obj.Which] worth = op.Worth + if obj.RingKind() == RingAddStrength || obj.RingKind() == RingIncreaseDamage || obj.RingKind() == RingProtection || obj.RingKind() == RingDexterity { if obj.Bonus > 0 { @@ -152,35 +176,44 @@ func (g *RogueGame) totalWinner() { worth = 10 } } + if !obj.Flags.Has(Known) { worth /= 2 } + obj.Flags.Set(Known) + op.Know = true case KindWand: op := &it.Sticks[obj.Which] worth = op.Worth + worth += 20 * obj.Charges if !obj.Flags.Has(Known) { worth /= 2 } + obj.Flags.Set(Known) + op.Know = true case KindAmulet: worth = 1000 } + if worth < 0 { worth = 0 } - g.scr.Std.MvPrintw(line, 0, "%c) %5d %s", obj.PackCh, worth, + + g.scr.Std.MvPrintwf(line, 0, "%c) %5d %s", obj.PackCh, worth, g.invName(obj, false)) line++ p.Purse += worth } - g.scr.Std.MvPrintw(line, 0, " %5d Gold Pieces ", oldpurse) + + g.scr.Std.MvPrintwf(line, 0, " %5d Gold Pieces ", oldpurse) g.refresh() g.score(p.Purse, 2, ' ') - g.myExit(0) + g.myExit() } // killnameTable is the rip.c nlist[]: special death causes. @@ -194,25 +227,32 @@ var killnameTable = []helpEntry{ // killname converts a code to a monster name (rip.c killname). func (g *RogueGame) killname(monst byte, doart bool) string { - var sp string - var article bool + var ( + sp string + article bool + ) + if isUpper(monst) { sp = g.Monsters[monst-'A'].Name article = true } else { sp = "Wally the Wonder Badger" article = false + for _, hp := range killnameTable { if hp.Ch == monst { sp = hp.Desc article = hp.Print + break } } } + if doart && article { return "a" + vowelstr(sp) + " " + sp } + return sp } @@ -224,13 +264,16 @@ func (g *RogueGame) DeathDemo() { 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()) @@ -245,5 +288,6 @@ func (g *RogueGame) deathMonst() byte { 'Y', 'Z', 'a', 'b', 'h', 'd', 's', ' ', // generates the "Wally the Wonder Badger" message } + return poss[g.rnd(len(poss))] } diff --git a/game/rng.go b/game/rng.go index fc21663..774d0b1 100644 --- a/game/rng.go +++ b/game/rng.go @@ -11,21 +11,17 @@ type Rng struct { Seed int32 } -// next steps the generator and returns the next raw value (the RN macro). -func (r *Rng) next() int { - r.Seed = r.Seed*11109 + 13849 - return int(r.Seed>>16) & 0xffff -} - // Rnd picks a very random number in [0, rng) (main.c rnd). func (r *Rng) Rnd(rng int) int { if rng == 0 { return 0 } + v := r.next() if v < 0 { v = -v } + return v % rng } @@ -35,9 +31,17 @@ func (r *Rng) Roll(number, sides int) int { for ; number > 0; number-- { dtotal += r.Rnd(sides) + 1 } + return dtotal } +// next steps the generator and returns the next raw value (the RN macro). +func (r *Rng) next() int { + r.Seed = r.Seed*11109 + 13849 + + return int(r.Seed>>16) & 0xffff +} + // rnd is the ported code's spelling of C rnd(): every call site in the C // sources reads rnd(x), and keeping that shape makes cross-checking easy. func (g *RogueGame) rnd(rng int) int { return g.Rng.Rnd(rng) } diff --git a/game/rng_test.go b/game/rng_test.go index 29364b4..7b1acf9 100644 --- a/game/rng_test.go +++ b/game/rng_test.go @@ -46,6 +46,7 @@ func TestRndZeroDoesNotStep(t *testing.T) { if got := r.Rnd(0); got != 0 { t.Fatalf("rnd(0) = %d, want 0", got) } + if r.Seed != 42 { t.Fatalf("rnd(0) stepped the generator: seed = %d, want 42", r.Seed) } @@ -58,6 +59,7 @@ func TestSpreadSmallValues(t *testing.T) { if got := g.spread(1); got != 1 { t.Errorf("spread(1) = %d, want 1", got) } + if got := g.spread(2); got != 2 { t.Errorf("spread(2) = %d, want 2", got) } diff --git a/game/rooms.go b/game/rooms.go index 5a48c13..704cc36 100644 --- a/game/rooms.go +++ b/game/rooms.go @@ -21,6 +21,7 @@ const goldGrp = 1 // do_rooms). func (g *RogueGame) doRooms() { var bsze Coord // maximum room size + bsze.X = NumCols / 3 bsze.Y = NumLines / 3 // Clear things for a new level @@ -32,7 +33,7 @@ func (g *RogueGame) doRooms() { } // Put the gone rooms, if any, on the level leftOut := g.rnd(4) - for i := 0; i < leftOut; i++ { + for range leftOut { g.Level.Rooms[g.rndRoom()].Flags.Set(Gone) } // dig and populate all the rooms on the level @@ -40,6 +41,7 @@ func (g *RogueGame) doRooms() { rp := &g.Level.Rooms[i] // Find upper left corner of box that this room goes in top := Coord{X: (i%3)*bsze.X + 1, Y: (i / 3) * bsze.Y} + if rp.Flags.Has(Gone) { // Place a gone room. Make certain that there is a blank line // for passage drawing. @@ -47,16 +49,19 @@ func (g *RogueGame) doRooms() { rp.Pos.X = top.X + g.rnd(bsze.X-2) + 1 rp.Pos.Y = top.Y + g.rnd(bsze.Y-2) + 1 rp.Max.X = -NumCols + rp.Max.Y = -NumLines if rp.Pos.Y > 0 && rp.Pos.Y < NumLines-1 { break } } + continue } // set room type if g.rnd(10) < g.Depth-1 { rp.Flags.Set(Dark) // dark room + if g.rnd(15) == 0 { rp.Flags = Maze // maze room } @@ -64,10 +69,12 @@ func (g *RogueGame) doRooms() { // Find a place and size for a random room if rp.Flags.Has(Maze) { rp.Max.X = bsze.X - 1 + rp.Max.Y = bsze.Y - 1 if rp.Pos.X = top.X; rp.Pos.X == 1 { rp.Pos.X = 0 } + if rp.Pos.Y = top.Y; rp.Pos.Y == 0 { rp.Pos.Y++ rp.Max.Y-- @@ -77,12 +84,14 @@ func (g *RogueGame) doRooms() { rp.Max.X = g.rnd(bsze.X-4) + 4 rp.Max.Y = g.rnd(bsze.Y-4) + 4 rp.Pos.X = top.X + g.rnd(bsze.X-rp.Max.X) + rp.Pos.Y = top.Y + g.rnd(bsze.Y-rp.Max.Y) if rp.Pos.Y != 0 { break } } } + g.drawRoom(rp) // Put the gold in if g.rnd(2) == 0 && (!g.HasAmulet || g.Depth >= g.MaxDepth) { @@ -92,6 +101,7 @@ func (g *RogueGame) doRooms() { rp.Gold, _ = g.findFloorIn(rp, 0, false) gold.Pos = rp.Gold g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold) + gold.Flags = Stackable gold.Group = goldGrp gold.Kind = KindGold @@ -102,6 +112,7 @@ func (g *RogueGame) doRooms() { if rp.GoldVal > 0 { prob = 80 } + if g.rnd(100) < prob { tp := &Monster{} mp, _ := g.findFloorIn(rp, 0, true) @@ -116,8 +127,10 @@ func (g *RogueGame) doRooms() { func (g *RogueGame) drawRoom(rp *Room) { if rp.Flags.Has(Maze) { g.doMaze(rp) + return } + g.vert(rp, rp.Pos.X) // Draw left side g.vert(rp, rp.Pos.X+rp.Max.X-1) // Draw right side g.horiz(rp, rp.Pos.Y) // Draw top @@ -173,26 +186,34 @@ func (g *RogueGame) dig(y, x int) { for { cnt := 0 + var nexty, nextx int + for _, cp := range del { newy := y + cp.Y + newx := x + cp.X if newy < 0 || newy > m.maxy || newx < 0 || newx > m.maxx { continue } + if g.Level.FlagsAt(newy+m.starty, newx+m.startx).Has(FPassage) { continue } + if cnt++; g.rnd(cnt) == 0 { nexty = newy nextx = newx } } + if cnt == 0 { return } + g.accntMaze(y, x, nexty, nextx) g.accntMaze(nexty, nextx, y, x) + var pos Coord if nexty == y { pos.Y = y + m.starty @@ -209,6 +230,7 @@ func (g *RogueGame) dig(y, x int) { pos.Y = nexty + m.starty - 1 } } + g.putpass(pos) pos.Y = nexty + m.starty pos.X = nextx + m.startx @@ -220,7 +242,7 @@ func (g *RogueGame) dig(y, x int) { // accntMaze accounts for maze exits (rooms.c accnt_maze). func (g *RogueGame) accntMaze(y, x, ny, nx int) { sp := &g.maze.maze[y][x] - for i := 0; i < sp.nexits; i++ { + for i := range sp.nexits { if sp.exits[i].Y == ny && sp.exits[i].X == nx { return } @@ -236,15 +258,18 @@ func (g *RogueGame) accntMaze(y, x, ny, nx int) { // rndPos picks a random spot in a room (rooms.c rnd_pos). func (g *RogueGame) rndPos(rp *Room) Coord { var cp Coord + cp.X = rp.Pos.X + g.rnd(rp.Max.X-2) + 1 cp.Y = rp.Pos.Y + g.rnd(rp.Max.Y-2) + 1 + return cp } // findFloor finds a valid floor spot, picking a new random room each time -// around the loop (rooms.c find_floor with rp == NULL). -func (g *RogueGame) findFloor(rp *Room, limit int, monst bool) (Coord, bool) { - return g.findFloorImpl(rp, limit, monst, rp == nil) +// around the loop; it retries forever (rooms.c find_floor with rp == NULL +// — every such C call site passed FALSE for the limit). +func (g *RogueGame) findFloor(monst bool) (Coord, bool) { + return g.findFloorImpl(nil, 0, monst, true) } // findFloorIn finds a valid floor spot in this room (rooms.c find_floor @@ -261,6 +286,7 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co compchar = Passage } } + cnt := limit for { if limit != 0 { @@ -268,14 +294,18 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co return Coord{}, false } } + if pickroom { rp = &g.Level.Rooms[g.rndRoom()] + compchar = Floor if rp.Flags.Has(Maze) { compchar = Passage } } + cp := g.rndPos(rp) + pp := g.Level.At(cp.Y, cp.X) if monst { if pp.Monst == nil && stepOk(pp.Ch) { @@ -294,11 +324,14 @@ func (g *RogueGame) enterRoom(cp Coord) { rp := g.roomin(cp) p.Room = rp g.doorOpen(rp) + if !rp.Flags.Has(Dark) && !p.On(Blind) { for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ { g.move(y, rp.Pos.X) + for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ { tp := g.Level.MonsterAt(y, x) + ch := g.Level.Char(y, x) if tp == nil { if g.inch() != ch { @@ -335,6 +368,7 @@ func (g *RogueGame) leaveRoom(cp Coord) { } var floor byte + switch { case rp.Flags.Has(Gone): floor = Passage @@ -348,6 +382,7 @@ func (g *RogueGame) leaveRoom(cp Coord) { for y := rp.Pos.Y; y < rp.Max.Y+rp.Pos.Y; y++ { for x := rp.Pos.X; x < rp.Max.X+rp.Pos.X; x++ { g.move(y, x) + switch ch := g.inch(); ch { case Floor: if floor == ' ' { @@ -361,8 +396,10 @@ func (g *RogueGame) leaveRoom(cp Coord) { g.standout() g.addch(ch) g.standend() + break } + pp := g.Level.At(y, x) if pp.Ch == Door { g.addch(Door) @@ -373,5 +410,6 @@ func (g *RogueGame) leaveRoom(cp Coord) { } } } + g.doorOpen(rp) } diff --git a/game/run_test.go b/game/run_test.go index 88136dd..4af9882 100644 --- a/game/run_test.go +++ b/game/run_test.go @@ -9,21 +9,27 @@ import ( // 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 { + + err := g.Run() + if 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++ { + + for y := range NumLines { if strings.Contains(g.scr.Std.Line(y), "Top Ten") { found = true } } + if !found { t.Error("score list not on screen after quit") } @@ -36,17 +42,23 @@ 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 . . ") + moves := []byte("h j k l y u b n s .") + script := make([]byte, 0, len(moves)*200+7) + 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 { + + err := g.Run() + if err != nil { t.Fatalf("Run: %v", err) } + if g.Playing { t.Error("session did not end") } @@ -65,9 +77,12 @@ func TestRunDownStairs(t *testing.T) { g.StartDaemon(DDoctor, 0, After) g.Fuse(DSwander, 0, wanderTime(g), After) g.StartDaemon(DStomach, 0, After) - if err := g.Run(); err != nil { + + err := g.Run() + if err != nil { t.Fatalf("Run: %v", err) } + if g.Depth != 2 { t.Errorf("depth = %d after descending, want 2", g.Depth) } @@ -79,26 +94,33 @@ 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) + + runErr := g.Run() + if runErr != nil { + t.Fatalf("Run: %v", runErr) } 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) + setInput(t, h, []byte("..Qy")...) + + restoredErr := h.Run() + if restoredErr != nil { + t.Fatalf("restored Run: %v", restoredErr) } } diff --git a/game/save.go b/game/save.go index 0d02cf4..0793fb6 100644 --- a/game/save.go +++ b/game/save.go @@ -2,6 +2,7 @@ package game import ( "encoding/gob" + "errors" "fmt" "os" ) @@ -131,16 +132,19 @@ 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 } @@ -152,6 +156,7 @@ func (g *RogueGame) roomAt(i int) *Room { case i >= 0: return &g.Level.Rooms[i] } + return nil } @@ -160,11 +165,13 @@ 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 } @@ -266,9 +273,11 @@ func (g *RogueGame) snapshot() *SaveState { 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: @@ -279,6 +288,7 @@ func (g *RogueGame) snapshot() *SaveState { ref = destRef{Kind: 2, Idx: mi} } } + if ref.Kind == 0 { for _, oo := range g.Level.Objects { if m.Dest == &oo.Pos { @@ -286,6 +296,7 @@ func (g *RogueGame) snapshot() *SaveState { } } } + if ref.Kind == 0 { for ri := range g.Level.Rooms { if m.Dest == &g.Level.Rooms[ri].Gold { @@ -294,8 +305,10 @@ func (g *RogueGame) snapshot() *SaveState { } } } + st.Dests = append(st.Dests, ref) } + return st } @@ -371,15 +384,18 @@ func (g *RogueGame) applySnapshot(st *SaveState) { 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) @@ -400,6 +416,7 @@ func (g *RogueGame) applySnapshot(st *SaveState) { 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, @@ -409,11 +426,14 @@ func (g *RogueGame) applySnapshot(st *SaveState) { 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 @@ -433,26 +453,35 @@ func (g *RogueGame) applySnapshot(st *SaveState) { // 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 } } @@ -465,80 +494,115 @@ over: } 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 { + _, statErr := os.Stat(buf) + if statErr == 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) + _ = os.Remove(g.FileName) // best effort, as in C (md_unlink) } + g.FileName = buf - if err := g.saveFile(g.FileName); err != nil { + + err := g.saveFile(g.FileName) + if err != nil { g.msg("%s", err.Error()) + continue } + break } - g.myExit(0) + + g.myExit() } -// saveFile writes the saved game (save.c save_file). +// saveFile writes the saved game (save.c save_file). A failed write means +// a corrupt save, so the file is removed before reporting the error. func (g *RogueGame) saveFile(path string) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o400) //nolint:gosec,lll // G304: user-chosen save path if err != nil { return err } - defer f.Close() - if err := gob.NewEncoder(f).Encode(g.snapshot()); err != nil { - os.Remove(path) - return err + + encErr := gob.NewEncoder(f).Encode(g.snapshot()) + closeErr := f.Close() + + if encErr != nil || closeErr != nil { + _ = os.Remove(path) // don't leave a corrupt save behind + + if encErr != nil { + return encErr + } + + return closeErr } + return os.Chmod(path, 0o400) } // AutoSave silently saves to the current file name; used on SIGHUP/SIGTERM -// (save.c auto_save). +// (save.c auto_save). Best effort by design: it runs on the way out of a +// dying process. func (g *RogueGame) AutoSave() { if g.FileName != "" { - os.Remove(g.FileName) - g.saveFile(g.FileName) + _ = os.Remove(g.FileName) + _ = g.saveFile(g.FileName) } } +// ErrSaveOutOfDate reports a save file from an incompatible version. +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) { - f, err := os.Open(path) + f, err := os.Open(path) //nolint:gosec // G304: the save path is user-chosen by design if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() // read-only handle + 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) + + decErr := gob.NewDecoder(f).Decode(&st) + if decErr != nil { + return nil, fmt.Errorf("%s: corrupt or incompatible save file: %w", + path, decErr) } + if st.Version != saveFormatVersion { - return nil, fmt.Errorf("sorry, saved game is out of date") + return nil, ErrSaveOutOfDate } g := &RogueGame{ @@ -553,8 +617,10 @@ func Restore(path string, cfg Config) (*RogueGame, error) { 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) + rmErr := os.Remove(path) + if rmErr != nil { + return nil, fmt.Errorf("cannot unlink file: %w", rmErr) } + return g, nil } diff --git a/game/save_test.go b/game/save_test.go index ef18d70..4bbe291 100644 --- a/game/save_test.go +++ b/game/save_test.go @@ -15,6 +15,7 @@ func TestSaveRestoreRoundTrip(t *testing.T) { g.HasAmulet = true g.Items.Potions[PotionHealing].Know = true g.Items.Scrolls[ScrollMagicMapping].Guess = "map???" + g.Monsters['F'-'A'].Stats.Dmg = dice("3x1") // mutated bestiary must survive if len(g.Level.Monsters) > 0 { g.Level.Monsters[0].Flags.Set(Awake) @@ -22,8 +23,10 @@ func TestSaveRestoreRoundTrip(t *testing.T) { } path := filepath.Join(t.TempDir(), "rogue.save") - if err := g.saveFile(path); err != nil { - t.Fatalf("saveFile: %v", err) + + saveErr := g.saveFile(path) + if saveErr != nil { + t.Fatalf("saveFile: %v", saveErr) } h, err := Restore(path, Config{Term: &testTerm{}}) @@ -31,7 +34,8 @@ func TestSaveRestoreRoundTrip(t *testing.T) { t.Fatalf("Restore: %v", err) } - if _, err := os.Stat(path); !os.IsNotExist(err) { + _, statErr := os.Stat(path) + if !os.IsNotExist(statErr) { t.Error("save file not deleted on restore (C anti-restart rule)") } @@ -39,36 +43,46 @@ func TestSaveRestoreRoundTrip(t *testing.T) { 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[PotionHealing].Know { t.Error("potion identification lost") } + if h.Items.Scrolls[ScrollMagicMapping].Guess != "map???" { t.Error("scroll guess lost") } + if h.Monsters['F'-'A'].Stats.Dmg.String() != "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") } @@ -81,13 +95,17 @@ func TestSaveRestoreRoundTrip(t *testing.T) { 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=%v which=%d", i, o, o.Kind, o.Which) + if o == h.Player.CurWeapon { found = true } } + if !found { t.Error("restored CurWeapon is not aliased into the pack") } @@ -98,15 +116,24 @@ func TestRestoreRejectsWrongVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "rogue.save") st := g.snapshot() st.Version = "0.0.0" - f, err := os.Create(path) + + f, err := os.Create(path) //nolint:gosec // G304: test temp path if err != nil { t.Fatal(err) } - if err := gob.NewEncoder(f).Encode(st); err != nil { - t.Fatal(err) + + encErr := gob.NewEncoder(f).Encode(st) + if encErr != nil { + t.Fatal(encErr) } - f.Close() - if _, err := Restore(path, Config{}); err == nil { + + closeErr := f.Close() + if closeErr != nil { + t.Fatal(closeErr) + } + + _, restoreErr := Restore(path, Config{}) + if restoreErr == nil { t.Error("restore accepted an out-of-date save") } } diff --git a/game/score.go b/game/score.go index 6a5f4db..f3aeecd 100644 --- a/game/score.go +++ b/game/score.go @@ -38,16 +38,22 @@ func (g *RogueGame) rdScore() []ScoreEnt { if g.ScorePath == "" { return topTen } + f, err := os.Open(g.ScorePath) if err != nil { return topTen } - defer f.Close() + defer func() { _ = f.Close() }() // read-only handle + var onDisk []ScoreEnt - if err := gob.NewDecoder(f).Decode(&onDisk); err != nil { - return topTen + + decErr := gob.NewDecoder(f).Decode(&onDisk) + if decErr != nil { + return topTen // unreadable scoreboard reads as empty, as in C } + copy(topTen, onDisk) + return topTen } @@ -57,29 +63,45 @@ func (g *RogueGame) wrScore(topTen []ScoreEnt) { return } // lock_sc/unlock_sc: exclusive-create lock file with stale takeover. + // The whole scoreboard write is best effort, as it was in C: a shared + // scoreboard must never take the game down. lock := g.ScorePath + ".lck" for range 5 { - lf, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + lf, err := os.OpenFile(lock, //nolint:gosec // G304: configured path + os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err == nil { - lf.Close() - defer os.Remove(lock) + _ = lf.Close() + + defer func() { _ = os.Remove(lock) }() + break } - if fi, serr := os.Stat(lock); serr == nil && - time.Since(fi.ModTime()) > 10*time.Second { - os.Remove(lock) + + fi, statErr := os.Stat(lock) + if statErr == nil && time.Since(fi.ModTime()) > staleLockAge { + _ = os.Remove(lock) + continue } + time.Sleep(time.Second) } - f, err := os.OpenFile(g.ScorePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + + f, err := os.OpenFile(g.ScorePath, + os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return } - defer f.Close() - gob.NewEncoder(f).Encode(topTen) + + _ = gob.NewEncoder(f).Encode(topTen) + _ = f.Close() } +// staleLockAge is how old a scoreboard lock file may be before another +// process assumes its owner died and takes it over (mach_dep.c lock_sc +// aged its lock the same way). +const staleLockAge = 10 * time.Second + // score figures the score and posts it (rip.c score). flags -1 means just // display the list (the -s command line option). func (g *RogueGame) score(amount, flags int, monst byte) { @@ -92,38 +114,47 @@ func (g *RogueGame) score(amount, flags int, monst byte) { topTen := g.rdScore() // Insert her in list if need be ins := -1 + if !g.NoScore && flags >= 0 { uid := os.Getuid() + scp := len(topTen) for i := range topTen { if amount > topTen[i].Score { scp = i + break } else if !g.AllScore && flags != 2 && topTen[i].UID == uid && topTen[i].Flags != 2 { // only one score per nowin uid scp = len(topTen) + break } } + if scp < len(topTen) { sc2 := len(topTen) - 1 if flags != 2 && !g.AllScore { for i := scp; i < len(topTen); i++ { if topTen[i].UID == uid && topTen[i].Flags != 2 { sc2 = i + break } } } + for sc2 > scp { topTen[sc2] = topTen[sc2-1] sc2-- } + lvl := g.Depth if flags == 2 { lvl = g.MaxDepth } + topTen[scp] = ScoreEnt{ UID: uid, Score: amount, @@ -142,43 +173,53 @@ func (g *RogueGame) score(amount, flags int, monst byte) { if g.AllScore { label = "Scores" } + lines := []string{ fmt.Sprintf("Top Ten %s:", label), " Score Name", } highlight := -1 + for i := range topTen { scp := &topTen[i] if scp.Score == 0 { break } + line := fmt.Sprintf("%2d %5d %s: %s on level %d", i+1, scp.Score, scp.Name, scoreReasons[scp.Flags], scp.Level) if scp.Flags == 0 || scp.Flags == 3 { - line += fmt.Sprintf(" by %s", g.killname(scp.Monster, true)) + line += " by " + g.killname(scp.Monster, true) } + line += "." + if i == ins { highlight = len(lines) } + lines = append(lines, line) } if g.scr != nil && g.scr.term != nil { g.clear() + for i, line := range lines { if i == highlight { g.standout() } + g.mvaddstr(i, 0, line) + if i == highlight { g.standend() } } + g.refresh() } else { for _, line := range lines { - fmt.Println(line) + _, _ = fmt.Fprintln(os.Stdout, line) // CLI output } } diff --git a/game/screen.go b/game/screen.go index 5d310c2..dc23c91 100644 --- a/game/screen.go +++ b/game/screen.go @@ -37,26 +37,28 @@ type Window struct { func NewWindow(rows, cols int) *Window { w := &Window{rows: rows, cols: cols, cells: make([]cell, rows*cols)} w.Clear() + return w } -func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] } - // Move positions the cursor (curses move/wmove). func (w *Window) Move(y, x int) { w.cy, w.cx = y, x } // GetYX reports the cursor position (curses getyx). -func (w *Window) GetYX() (y, x int) { return w.cy, w.cx } +func (w *Window) GetYX() (int, int) { return w.cy, w.cx } // AddCh writes a character at the cursor and advances it (curses addch). func (w *Window) AddCh(ch byte) { if ch == '\n' { w.cy, w.cx = w.cy+1, 0 + return } + if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols { return } + *w.at(w.cy, w.cx) = cell{ch: ch, standout: w.standout} if w.cx++; w.cx >= w.cols { w.cx = 0 @@ -68,7 +70,7 @@ func (w *Window) AddCh(ch byte) { // AddStr writes a string at the cursor (curses addstr). func (w *Window) AddStr(s string) { - for i := 0; i < len(s); i++ { + for i := range len(s) { w.AddCh(s[i]) } } @@ -85,15 +87,15 @@ func (w *Window) MvAddStr(y, x int, s string) { w.AddStr(s) } -// Printw writes formatted text at the cursor (curses printw). -func (w *Window) Printw(format string, a ...any) { +// Printwf writes formatted text at the cursor (curses printw). +func (w *Window) Printwf(format string, a ...any) { w.AddStr(fmt.Sprintf(format, a...)) } -// MvPrintw moves then writes formatted text (curses mvprintw). -func (w *Window) MvPrintw(y, x int, format string, a ...any) { +// MvPrintwf moves then writes formatted text (curses mvprintw). +func (w *Window) MvPrintwf(y, x int, format string, a ...any) { w.Move(y, x) - w.Printw(format, a...) + w.Printwf(format, a...) } // Inch returns the character under the cursor (curses inch, sans @@ -102,12 +104,14 @@ func (w *Window) Inch() byte { if w.cy < 0 || w.cy >= w.rows || w.cx < 0 || w.cx >= w.cols { return ' ' } + return w.at(w.cy, w.cx).ch } // MvInch moves then reads (curses mvinch). func (w *Window) MvInch(y, x int) byte { w.Move(y, x) + return w.Inch() } @@ -120,6 +124,7 @@ func (w *Window) Clear() { for i := range w.cells { w.cells[i] = cell{ch: ' '} } + w.cy, w.cx = 0, 0 } @@ -128,6 +133,7 @@ func (w *Window) Clrtoeol() { if w.cy < 0 || w.cy >= w.rows { return } + for x := w.cx; x < w.cols; x++ { *w.at(w.cy, x) = cell{ch: ' '} } @@ -138,13 +144,14 @@ 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 } +// Size reports the window dimensions as rows, columns. +func (w *Window) Size() (int, 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) { +func (w *Window) CellAt(y, x int) (byte, bool) { c := w.at(y, x) + return c.ch, c.standout } @@ -155,6 +162,7 @@ func (w *Window) Contents() []byte { for i, c := range w.cells { out[i] = c.ch } + return out } @@ -171,12 +179,16 @@ func (w *Window) SetContents(data []byte) { // victory screens. func (w *Window) Line(y int) string { buf := make([]byte, w.cols) - for x := 0; x < w.cols; x++ { + for x := range w.cols { buf[x] = w.at(y, x).ch } + return string(buf) } +// at addresses the cell at (y, x) in the backing array. +func (w *Window) at(y, x int) *cell { return &w.cells[y*w.cols+x] } + // Screen bundles the two windows the game draws on with the device that // shows them. type Screen struct { @@ -217,7 +229,7 @@ func (g *RogueGame) mvaddch(y, x int, c byte) { g.scr.Std.MvAddCh(y, x, c) } func (g *RogueGame) mvaddstr(y, x int, s string) { g.scr.Std.MvAddStr(y, x, s) } -func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printw(f, a...) } +func (g *RogueGame) printw(f string, a ...any) { g.scr.Std.Printwf(f, a...) } func (g *RogueGame) inch() byte { return g.scr.Std.Inch() } func (g *RogueGame) mvinch(y, x int) byte { return g.scr.Std.MvInch(y, x) } func (g *RogueGame) standout() { g.scr.Std.Standout(true) } diff --git a/game/scrolls.go b/game/scrolls.go index 59b76e0..f924b71 100644 --- a/game/scrolls.go +++ b/game/scrolls.go @@ -16,16 +16,19 @@ var idType = [ScrollIdentifyRingOrStick + 1]ObjectKind{ // (scrolls.c read_scroll). func (g *RogueGame) readScroll() { p := &g.Player + obj := g.getItem("read", KindScroll) if obj == nil { return } + if obj.Kind != KindScroll { if !g.Options.Terse { g.msg("there is nothing on it to read") } else { g.msg("nothing to read") } + return } // Calculate the effect it has on the poor guy. @@ -50,30 +53,39 @@ func (g *RogueGame) readScroll() { // Hold monster scroll. Stop all monsters within two spaces from // chasing after the hero. held := 0 + for x := p.Pos.X - 2; x <= p.Pos.X+2; x++ { if x < 0 || x >= NumCols { continue } + for y := p.Pos.Y - 2; y <= p.Pos.Y+2; y++ { if y < 0 || y > NumLines-1 { continue } + if mp := g.Level.MonsterAt(y, x); mp != nil && mp.On(Awake) { mp.Flags.Clear(Awake) mp.Flags.Set(Held) + held++ } } } + if held > 0 { - g.addmsg("the monster") + g.addmsgf("the monster") + if held > 1 { - g.addmsg("s around you") + g.addmsgf("s around you") } - g.addmsg(" freeze") + + g.addmsgf(" freeze") + if held == 1 { - g.addmsg("s") + g.addmsgf("s") } + g.endmsg() g.Items.Scrolls[ScrollHoldMonster].Know = true } else { @@ -83,13 +95,16 @@ func (g *RogueGame) readScroll() { // Scroll which makes you fall asleep g.Items.Scrolls[ScrollSleep].Know = true g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME + p.Flags.Clear(Awake) g.msg("you fall asleep") case ScrollCreateMonster: // Create a monster: first look in a circle around him, next try // his room, otherwise give up i := 0 + var mp Coord + for y := p.Pos.Y - 1; y <= p.Pos.Y+1; y++ { for x := p.Pos.X - 1; x <= p.Pos.X+1; x++ { // Don't put a monster on top of the player. @@ -103,12 +118,14 @@ func (g *RogueGame) readScroll() { continue } } + if i++; g.rnd(i) == 0 { mp = Coord{Y: y, X: x} } } } } + if i == 0 { g.msg("you hear a faint cry of anguish in the distance") } else { @@ -126,10 +143,11 @@ func (g *RogueGame) readScroll() { g.msg("oh, now this scroll has a map on it") // take all the things we want to keep hidden out of the window for y := 1; y < NumLines-1; y++ { - for x := 0; x < NumCols; x++ { + for x := range NumCols { pp := g.Level.At(y, x) ch := pp.Ch pass := false + switch ch { case Door, Stairs: case '-', '|': @@ -168,13 +186,17 @@ func (g *RogueGame) readScroll() { ch = ' ' } } + if pass { if !pp.Flags.Has(FReal) { pp.Ch = Passage } + pp.Flags.Set(FSeen | FReal) + ch = Passage } + if ch != ' ' { if tp := pp.Monst; tp != nil { tp.OldCh = ch @@ -190,13 +212,17 @@ func (g *RogueGame) readScroll() { case ScrollFoodDetection: // Food detection found := false + g.scr.Hw.Clear() + for _, fo := range g.Level.Objects { if fo.Kind == KindFood { found = true + g.scr.Hw.MvAddCh(fo.Pos.Y, fo.Pos.X, Food) } } + if found { g.Items.Scrolls[ScrollFoodDetection].Know = true g.showWin("Your nose tingles and you smell food.--More--") @@ -206,7 +232,9 @@ func (g *RogueGame) readScroll() { case ScrollTeleportation: // Scroll of teleportation: make him disappear and reappear curRoom := p.Room + g.teleport() + if curRoom != p.Room { g.Items.Scrolls[ScrollTeleportation].Know = true } @@ -215,11 +243,13 @@ func (g *RogueGame) readScroll() { g.msg("you feel a strange sense of loss") } else { p.CurWeapon.Flags.Clear(Cursed) + if g.rnd(2) == 0 { p.CurWeapon.HPlus++ } else { p.CurWeapon.DPlus++ } + g.msg("your %s glows %s for a moment", g.Items.Weapons[p.CurWeapon.Which].Name, g.pickColor("blue")) } @@ -248,6 +278,7 @@ func (g *RogueGame) readScroll() { g.msg("you feel a strange sense of loss") } } + g.look(true) // put the result of the scroll on the screen g.status() diff --git a/game/sticks.go b/game/sticks.go index dc0345b..417f2ac 100644 --- a/game/sticks.go +++ b/game/sticks.go @@ -7,19 +7,25 @@ import "fmt" // doZap performs a zap with a wand (sticks.c do_zap). func (g *RogueGame) doZap() { p := &g.Player + obj := g.getItem("zap with", KindWand) if obj == nil { return } + if obj.Kind != KindWand { g.After = false g.msg("you can't zap with that!") + return } + if obj.Charges == 0 { g.msg("nothing happens") + return } + switch obj.WandKind() { case WandLight: // Reddy Kilowatt wand. Light up the room @@ -30,10 +36,12 @@ func (g *RogueGame) doZap() { p.Room.Flags.Clear(Dark) // Light the room and put the player back up g.enterRoom(p.Pos) - g.addmsg("the room is lit") + g.addmsgf("the room is lit") + if !g.Options.Terse { - g.addmsg(" by a shimmering %s light", g.pickColor("blue")) + g.addmsgf(" by a shimmering %s light", g.pickColor("blue")) } + g.endmsg() } case WandDrainLife: @@ -42,42 +50,53 @@ func (g *RogueGame) doZap() { // passage) if p.Stats.HP < 2 { g.msg("you are too weak to use it") + return } + g.drain() case WandInvisibility, WandPolymorph, WandTeleportAway, WandTeleportTo, WandCancellation: y := p.Pos.Y + x := p.Pos.X for stepOk(g.Level.VisibleChar(y, x)) { y += g.Delta.Y x += g.Delta.X } + if tp := g.Level.MonsterAt(y, x); tp != nil { monster := tp.Type if monster == 'F' { p.Flags.Clear(Held) } + switch obj.WandKind() { case WandInvisibility: tp.Flags.Set(Invisible) + if g.cansee(y, x) { g.mvaddch(y, x, tp.OldCh) } case WandPolymorph: pp := tp.Pack detachMon(&g.Level.Monsters, tp) + if g.seeMonst(tp) { g.mvaddch(y, x, g.Level.Char(y, x)) } + oldch := tp.OldCh g.Delta.Y = y g.Delta.X = x - monster = byte(g.rnd(26) + 'A') + monster = g.randomMonsterLetter() g.newMonster(tp, monster, g.Delta) + if g.seeMonst(tp) { g.mvaddch(y, x, monster) } + tp.OldCh = oldch + tp.Pack = pp if g.seeMonst(tp) { g.Items.Sticks[WandPolymorph].Know = true @@ -85,15 +104,17 @@ func (g *RogueGame) doZap() { case WandCancellation: tp.Flags.Set(Cancelled) tp.Flags.Clear(Invisible | CanConfuse) + tp.Disguise = tp.Type if g.seeMonst(tp) { g.mvaddch(y, x, tp.Disguise) } case WandTeleportAway, WandTeleportTo: var newPos Coord + if obj.WandKind() == WandTeleportAway { for { - newPos, _ = g.findFloor(nil, 0, true) + newPos, _ = g.findFloor(true) if newPos != p.Pos { break } @@ -102,6 +123,7 @@ func (g *RogueGame) doZap() { newPos.Y = p.Pos.Y + g.Delta.Y newPos.X = p.Pos.X + g.Delta.X } + tp.Dest = &p.Pos tp.Flags.Set(Awake) g.relocate(tp, newPos) @@ -114,26 +136,31 @@ func (g *RogueGame) doZap() { bolt.HurlDmg = dice("1x4") bolt.HPlus = 100 bolt.DPlus = 1 + bolt.Flags = Missile if p.CurWeapon != nil { bolt.Launch = WeaponKind(p.CurWeapon.Which) } + g.doMotion(bolt, g.Delta.Y, g.Delta.X) + if tp := g.Level.MonsterAt(bolt.Pos.Y, bolt.Pos.X); tp != nil && !g.saveThrow(VsMagic, &tp.Stats) { g.hitMonster(bolt.Pos, bolt) } else if g.Options.Terse { - g.msg("missle vanishes") + g.msg("missle vanishes") //nolint:misspell // C's spelling, kept faithfully } else { - g.msg("the missle vanishes with a puff of smoke") + g.msg("the missle vanishes with a puff of smoke") //nolint:misspell // C's spelling } case WandHasteMonster, WandSlowMonster: y := p.Pos.Y + x := p.Pos.X for stepOk(g.Level.VisibleChar(y, x)) { y += g.Delta.Y x += g.Delta.X } + if tp := g.Level.MonsterAt(y, x); tp != nil { if obj.WandKind() == WandHasteMonster { if tp.On(Slowed) { @@ -147,14 +174,17 @@ func (g *RogueGame) doZap() { } else { tp.Flags.Set(Slowed) } + tp.Turn = true } + g.Delta.Y = y g.Delta.X = x g.runto(g.Delta) } case WandLightning, WandFire, WandCold: var name string + switch obj.WandKind() { case WandLightning: name = "bolt" @@ -163,10 +193,12 @@ func (g *RogueGame) doZap() { default: name = "ice" } + g.fireBolt(p.Pos, &g.Delta, name) g.Items.Sticks[obj.Which].Know = true case WandNothing: } + obj.Charges-- } @@ -178,8 +210,11 @@ func (g *RogueGame) drain() { if g.Level.Char(p.Pos.Y, p.Pos.X) == Door { corp = &g.Level.Passages[*g.Level.FlagsAt(p.Pos.Y, p.Pos.X)&FPassNum] } + inpass := p.Room.Flags.Has(Gone) + var drainee []*Monster + for _, mp := range g.Level.Monsters { if mp.Room == p.Room || mp.Room == corp || (inpass && g.Level.Char(mp.Pos.Y, mp.Pos.X) == Door && @@ -187,11 +222,14 @@ func (g *RogueGame) drain() { drainee = append(drainee, mp) } } + cnt := len(drainee) if cnt == 0 { g.msg("you have a tingling feeling") + return } + p.Stats.HP /= 2 cnt = p.Stats.HP / cnt // Now zot all of the monsters @@ -217,7 +255,9 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { bolt.HPlus = 100 bolt.DPlus = 0 g.Items.Weapons[WeaponFlame].Name = name + var dirch byte + switch dir.Y + dir.X { case 0: dirch = '/' @@ -230,10 +270,12 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { case 2, -2: dirch = '\\' } + pos := start hitHero := !fromHero used := false changed := false + var spotpos []Coord for len(spotpos) < BoltLength && !used { pos.Y += dir.Y @@ -241,6 +283,7 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { spotpos = append(spotpos, pos) ch := g.Level.VisibleChar(pos.Y, pos.X) bounce := false + switch ch { case Door: // this code is necessary if the hero is on a door and he @@ -252,29 +295,38 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { case '|', '-', ' ': bounce = true } + if bounce { if !changed { hitHero = !hitHero } + changed = false dir.Y = -dir.Y dir.X = -dir.X spotpos = spotpos[:len(spotpos)-1] + g.msg("the %s bounces", name) + continue } + if tp := g.Level.MonsterAt(pos.Y, pos.X); !hitHero && tp != nil { hitHero = true changed = !changed + tp.OldCh = g.Level.Char(pos.Y, pos.X) if !g.saveThrow(VsMagic, &tp.Stats) { bolt.Pos = pos used = true + if tp.Type == 'D' && name == "flame" { - g.addmsg("the flame bounces") + g.addmsgf("the flame bounces") + if !g.Options.Terse { - g.addmsg(" off the dragon") + g.addmsgf(" off the dragon") } + g.endmsg() } else { g.hitMonster(pos, bolt) @@ -283,6 +335,7 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { if fromHero { g.runto(pos) } + if g.Options.Terse { g.msg("%s misses", name) } else { @@ -292,6 +345,7 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { } else if hitHero && pos == p.Pos { hitHero = false changed = !changed + if !g.save(VsMagic) { if p.Stats.HP -= g.roll(6, 6); p.Stats.HP <= 0 { if fromHero { @@ -300,7 +354,9 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { g.death(g.Level.MonsterAt(start.Y, start.X).Type) } } + used = true + if g.Options.Terse { g.msg("the %s hits", name) } else { @@ -310,6 +366,7 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { g.msg("the %s whizzes by you", name) } } + g.mvaddch(pos.Y, pos.X, dirch) g.refresh() } @@ -326,6 +383,7 @@ func (g *RogueGame) fixStick(cur *Object) { } else { cur.Damage = dice("1x1") } + cur.HurlDmg = dice("1x1") switch cur.WandKind() { @@ -342,8 +400,10 @@ func chargeStr(g *RogueGame, obj *Object) string { if !obj.Flags.Has(Known) { return "" } + if g.Options.Terse { return fmt.Sprintf(" [%d]", obj.Charges) } + return fmt.Sprintf(" [%d charges]", obj.Charges) } diff --git a/game/tables.go b/game/tables.go index 87b6877..d71a382 100644 --- a/game/tables.go +++ b/game/tables.go @@ -206,7 +206,7 @@ var sylls = []string{ "prok", "re", "rea", "rhov", "ri", "ro", "rog", "rok", "rol", "sa", "san", "sat", "sef", "seh", "shu", "ski", "sna", "sne", "snik", "sno", "so", "sol", "sri", "sta", "sun", "ta", "tab", "tem", - "ther", "ti", "tox", "trol", "tue", "turs", "u", "ulk", "um", "un", + "there", "ti", "tox", "trol", "tue", "turs", "u", "ulk", "um", "un", "uni", "ur", "val", "viv", "vly", "vom", "wah", "wed", "werg", "wex", "whon", "wun", "xo", "y", "yot", "yu", "zant", "zeb", "zim", "zok", "zon", "zum", diff --git a/game/tables_test.go b/game/tables_test.go index 2844016..07e30e2 100644 --- a/game/tables_test.go +++ b/game/tables_test.go @@ -9,8 +9,10 @@ func TestProbabilitiesSumTo100(t *testing.T) { for _, oi := range info { s += oi.Prob } + return s } + tables := map[string][]ObjInfo{ "things": baseThings[:], "potions": basePotInfo[:], @@ -29,10 +31,12 @@ func TestProbabilitiesSumTo100(t *testing.T) { func TestInitProbsCumulative(t *testing.T) { g := NewGame(Config{Seed: 1}) + last := g.Items.Potions[NumPotionTypes-1].Prob if last != 100 { t.Errorf("cumulative potion probability ends at %d, want 100", last) } + for i := PotionKind(1); i < NumPotionTypes; i++ { if g.Items.Potions[i].Prob < g.Items.Potions[i-1].Prob { t.Errorf("potion probs not nondecreasing at %d", i) @@ -43,27 +47,34 @@ func TestInitProbsCumulative(t *testing.T) { func TestNewGameRandomizesAppearances(t *testing.T) { g := NewGame(Config{Seed: 12345}) seen := map[string]bool{} + for i, c := range g.Items.PotColors { if c == "" { t.Fatalf("potion %d has no color", i) } + if seen[c] { t.Errorf("potion color %q assigned twice", c) } + seen[c] = true } + for i, n := range g.Items.ScrNames { if n == "" { t.Fatalf("scroll %d has no name", i) } + if len(n) > MaxNameLen+1 { t.Errorf("scroll name %q longer than C buffer allows", n) } } + for i := range g.Items.WandType { if g.Items.WandType[i] != "wand" && g.Items.WandType[i] != "staff" { t.Errorf("stick %d has type %q", i, g.Items.WandType[i]) } + if g.Items.WandMade[i] == "" { t.Errorf("stick %d has no material", i) } @@ -80,6 +91,7 @@ func TestMonsterTable(t *testing.T) { if monsterTable[0].Name != "aquator" || monsterTable[25].Name != "zombie" { t.Error("monster table order broken") } + if monsterTable['D'-'A'].Name != "dragon" { t.Error("letter indexing broken") } diff --git a/game/term_test.go b/game/term_test.go index 068e5ef..d80cbc7 100644 --- a/game/term_test.go +++ b/game/term_test.go @@ -15,11 +15,14 @@ func (t *testTerm) ReadChar() byte { if t.pos < len(t.input) { c := t.input[t.pos] t.pos++ + return c } + t.tick++ if t.tick%2 == 0 { return '\n' } + return ' ' } diff --git a/game/things.go b/game/things.go index bf15d9d..2cd75a5 100644 --- a/game/things.go +++ b/game/things.go @@ -11,8 +11,10 @@ import ( // (things.c inv_name). func (g *RogueGame) invName(obj *Object, drop bool) string { var pb strings.Builder + which := obj.Which it := &g.Items + switch obj.Kind { case KindPotion: g.nameit(&pb, obj, "potion", it.PotColors[which], &it.Potions[which], nullstr) @@ -26,12 +28,15 @@ func (g *RogueGame) invName(obj *Object, drop bool) string { } else { fmt.Fprintf(&pb, "%d scrolls ", obj.Count) } + op := &it.Scrolls[which] - if op.Know { + + switch { + case op.Know: fmt.Fprintf(&pb, "of %s", op.Name) - } else if op.Guess != "" { + case op.Guess != "": fmt.Fprintf(&pb, "called %s", op.Guess) - } else { + default: fmt.Fprintf(&pb, "titled '%s'", it.ScrNames[which]) } case KindFood: @@ -50,19 +55,23 @@ func (g *RogueGame) invName(obj *Object, drop bool) string { } case KindWeapon: sp := it.Weapons[which].Name + if obj.Count > 1 { fmt.Fprintf(&pb, "%d ", obj.Count) } else { fmt.Fprintf(&pb, "A%s ", vowelstr(sp)) } + if obj.Flags.Has(Known) { fmt.Fprintf(&pb, "%s %s", num(obj.HPlus, obj.DPlus, Weapon), sp) } else { pb.WriteString(sp) } + if obj.Count > 1 { pb.WriteString("s") } + if obj.Label != "" { fmt.Fprintf(&pb, " called %s", obj.Label) } @@ -70,13 +79,16 @@ func (g *RogueGame) invName(obj *Object, drop bool) string { sp := it.Armors[which].Name if obj.Flags.Has(Known) { fmt.Fprintf(&pb, "%s %s [", num(aClass[which]-obj.ArmorClass, 0, Armor), sp) + if !g.Options.Terse { pb.WriteString("protection ") } + fmt.Fprintf(&pb, "%d]", 10-obj.ArmorClass) } else { pb.WriteString(sp) } + if obj.Label != "" { fmt.Fprintf(&pb, " called %s", obj.Label) } @@ -87,20 +99,25 @@ func (g *RogueGame) invName(obj *Object, drop bool) string { } out := pb.String() + if g.InvDescribe { p := &g.Player if obj == p.CurArmor { out += " (being worn)" } + if obj == p.CurWeapon { out += " (weapon in hand)" } - if obj == p.CurRing[Left] { + + switch obj { + case p.CurRing[Left]: out += " (on left hand)" - } else if obj == p.CurRing[Right] { + case p.CurRing[Right]: out += " (on right hand)" } } + if out != "" { if drop && isUpper(out[0]) { out = string(toLower(out[0])) + out[1:] @@ -108,6 +125,7 @@ func (g *RogueGame) invName(obj *Object, drop bool) string { out = string(toUpper(out[0])) + out[1:] } } + return out } @@ -115,28 +133,35 @@ func (g *RogueGame) invName(obj *Object, drop bool) string { // leavePack/detach vocabulary collision). func (g *RogueGame) dropIt() { p := &g.Player + ch := g.Level.Char(p.Pos.Y, p.Pos.X) if ch != Floor && ch != Passage { g.After = false g.msg("there is something there already") + return } + obj := g.getItem("drop", KindNone) if obj == nil { return } + if !g.dropCheck(obj) { return } + obj = g.leavePack(obj, true, !obj.Kind.MergesInPack()) // Link it into the level object list attachObj(&g.Level.Objects, obj) g.Level.SetChar(p.Pos.Y, p.Pos.X, obj.Kind.Glyph()) g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Set(FDropped) + obj.Pos = p.Pos if obj.Kind == KindAmulet { g.HasAmulet = false } + g.msg("dropped %s", g.invName(obj, true)) } @@ -146,26 +171,34 @@ func (g *RogueGame) dropCheck(obj *Object) bool { if obj == nil { return true } + p := &g.Player if obj != p.CurArmor && obj != p.CurWeapon && obj != p.CurRing[Left] && obj != p.CurRing[Right] { return true } + if obj.Flags.Has(Cursed) { g.msg("you can't. It appears to be cursed") + return false } - if obj == p.CurWeapon { + + switch obj { + case p.CurWeapon: p.CurWeapon = nil - } else if obj == p.CurArmor { + case p.CurArmor: g.wasteTime() + p.CurArmor = nil - } else { + default: hand := Right if obj == p.CurRing[Left] { hand = Left } + p.CurRing[hand] = nil + switch obj.RingKind() { case RingAddStrength: g.chgStr(-obj.Bonus) @@ -174,6 +207,7 @@ func (g *RogueGame) dropCheck(obj *Object) bool { g.Extinguish(DUnsee) } } + return true } @@ -193,6 +227,7 @@ func (g *RogueGame) newThing() *Object { } else { kind = pickOne(g, g.Items.Things[:]) } + switch kind { case 0: cur.Kind = KindPotion @@ -202,6 +237,7 @@ func (g *RogueGame) newThing() *Object { cur.Which = pickOne(g, g.Items.Scrolls[:]) case 2: cur.Kind = KindFood + g.Player.NoFood = 0 if g.rnd(10) != 0 { cur.Which = 0 @@ -210,6 +246,7 @@ func (g *RogueGame) newThing() *Object { } case 3: g.initWeapon(cur, WeaponKind(pickOne(g, g.Items.Weapons[:NumWeaponTypes]))) + if r := g.rnd(100); r < 10 { cur.Flags.Set(Cursed) cur.HPlus -= g.rnd(3) + 1 @@ -219,6 +256,7 @@ func (g *RogueGame) newThing() *Object { case 4: cur.Kind = KindArmor cur.Which = pickOne(g, g.Items.Armors[:]) + cur.ArmorClass = aClass[cur.Which] if r := g.rnd(100); r < 20 { cur.Flags.Set(Cursed) @@ -228,6 +266,7 @@ func (g *RogueGame) newThing() *Object { } case 5: cur.Kind = KindRing + cur.Which = pickOne(g, g.Items.Rings[:]) switch cur.RingKind() { case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage: @@ -243,6 +282,7 @@ func (g *RogueGame) newThing() *Object { cur.Which = pickOne(g, g.Items.Sticks[:]) g.fixStick(cur) } + return cur } @@ -255,6 +295,7 @@ func pickOne(g *RogueGame, info []ObjInfo) int { return idx } } + return 0 // bad pick_one: C resets to the start of the table } @@ -272,20 +313,27 @@ type invPage struct { // (things.c discovered). func (g *RogueGame) discovered() { var ch byte + for { discList := false + if !g.Options.Terse { - g.addmsg("for ") + g.addmsgf("for ") } - g.addmsg("what type") + + g.addmsgf("what type") + if !g.Options.Terse { - g.addmsg(" of object do you want a list") + g.addmsgf(" of object do you want a list") } + g.msg("? (* for all)") + ch = g.readchar() switch ch { case Escape: g.msg("") + return case Potion, Scroll, Ring, Stick, '*': discList = true @@ -297,10 +345,12 @@ func (g *RogueGame) discovered() { Potion, Scroll, Ring, Stick) } } + if discList { break } } + if ch == '*' { g.printDisc(Potion) g.addLine("") @@ -320,6 +370,7 @@ func (g *RogueGame) discovered() { // (things.c print_disc). func (g *RogueGame) printDisc(typ byte) { var info []ObjInfo + switch typ { case Scroll: info = g.Items.Scrolls[:] @@ -330,18 +381,23 @@ func (g *RogueGame) printDisc(typ byte) { case Stick: info = g.Items.Sticks[:] } + order := make([]int, len(info)) g.setOrder(order) + obj := Object{Count: 1} numFound := 0 + for i := range info { if info[order[i]].Know || info[order[i]].Guess != "" { obj.Kind = objectKindForGlyph(typ) obj.Which = order[i] g.addLine("%s", g.invName(&obj, false)) + numFound++ } } + if numFound == 0 { g.addLine("%s", g.nothing(typ)) } @@ -353,6 +409,7 @@ func (g *RogueGame) setOrder(order []int) { for i := range order { order[i] = i } + for i := len(order); i > 0; i-- { r := g.rnd(i) order[i-1], order[r] = order[r], order[i-1] @@ -368,6 +425,7 @@ func (g *RogueGame) addLine(format string, a ...any) int { pg := &g.invPage prompt := "--Press space to continue--" isFlush := format == flushSentinel + var line string if !isFlush { line = fmt.Sprintf(format, a...) @@ -375,36 +433,43 @@ func (g *RogueGame) addLine(format string, a ...any) int { if pg.lineCnt == 0 { g.scr.Hw.Clear() + if g.Options.InvType == InvSlow { g.Msgs.Mpos = 0 } } + if g.Options.InvType == InvSlow { if !isFlush && line != "" { if g.msg("%s", line) == Escape { return Escape } } + pg.lineCnt++ } else { if !pg.init { pg.maxlen = len(prompt) pg.init = true } + if pg.lineCnt >= NumLines-1 || isFlush { if g.Options.InvType == InvOver && isFlush && !pg.newpage { // Overlay the accumulated list in a box at the top right // of the screen, prompt, and restore what was beneath. g.msg("") g.refresh() + saved := NewWindow(NumLines, NumCols) saved.CopyFrom(g.scr.Std) + lx := NumCols - pg.maxlen - 2 for y := 0; y <= pg.lineCnt; y++ { for x := 0; x <= pg.maxlen; x++ { g.scr.Std.MvAddCh(y, lx+x, g.scr.Hw.MvInch(y, x)) } } + g.scr.Std.MvAddStr(pg.lineCnt, lx, prompt) g.refresh() g.waitFor(' ') @@ -417,19 +482,24 @@ func (g *RogueGame) addLine(format string, a ...any) int { g.scr.Hw.Clear() g.refresh() } + pg.newpage = true pg.lineCnt = 0 pg.maxlen = len(prompt) } - if !isFlush && !(pg.lineCnt == 0 && line == "") { + + if !isFlush && (pg.lineCnt != 0 || line != "") { g.scr.Hw.MvAddStr(pg.lineCnt, 0, line) + pg.lineCnt++ if pg.maxlen < len(line) { pg.maxlen = len(line) } + pg.lastLine = line } } + return ^Escape } @@ -447,6 +517,7 @@ func (g *RogueGame) endLine() { g.flushLine() } } + pg.lineCnt = 0 pg.newpage = false } @@ -459,8 +530,10 @@ func (g *RogueGame) nothing(typ byte) string { } else { out = "Haven't discovered anything" } + if typ != '*' { var tystr string + switch typ { case Potion: tystr = "potion" @@ -471,8 +544,10 @@ func (g *RogueGame) nothing(typ byte) string { case Stick: tystr = "stick" } + out += fmt.Sprintf(" about any %ss", tystr) } + return out } @@ -480,20 +555,22 @@ func (g *RogueGame) nothing(typ byte) string { // (things.c nameit). func (g *RogueGame) nameit(pb *strings.Builder, obj *Object, typ, which string, op *ObjInfo, prfunc func(*RogueGame, *Object) string) { - if op.Know || op.Guess != "" { + switch { + case op.Know || op.Guess != "": if obj.Count == 1 { fmt.Fprintf(pb, "A %s ", typ) } else { fmt.Fprintf(pb, "%d %ss ", obj.Count, typ) } + if op.Know { fmt.Fprintf(pb, "of %s%s(%s)", op.Name, prfunc(g, obj), which) } else { fmt.Fprintf(pb, "called %s%s(%s)", op.Guess, prfunc(g, obj), which) } - } else if obj.Count == 1 { + case obj.Count == 1: fmt.Fprintf(pb, "A%s %s %s", vowelstr(which), which, typ) - } else { + default: fmt.Fprintf(pb, "%d %s %ss", obj.Count, which, typ) } } @@ -505,13 +582,17 @@ func nullstr(*RogueGame, *Object) string { return "" } // pr_list). func (g *RogueGame) prList() { if !g.Options.Terse { - g.addmsg("for ") + g.addmsgf("for ") } - g.addmsg("what type") + + g.addmsgf("what type") + if !g.Options.Terse { - g.addmsg(" of object do you want a list") + g.addmsgf(" of object do you want a list") } + g.msg("? ") + ch := g.readchar() switch ch { case Potion: @@ -533,14 +614,17 @@ func (g *RogueGame) prList() { // (things.c pr_spec). func (g *RogueGame) prSpec(info []ObjInfo) { lastprob := 0 + i := byte('0') for idx := range info { if i == '9'+1 { i = 'a' } + g.addLine("%c: %s (%d%%)", i, info[idx].Name, info[idx].Prob-lastprob) lastprob = info[idx].Prob i++ } + g.endLine() } diff --git a/game/types.go b/game/types.go index 302875a..c0c7924 100644 --- a/game/types.go +++ b/game/types.go @@ -90,22 +90,29 @@ const ( VsMagic = 3 ) -// Flags for rooms (rogue.h) +// RoomFlags are the room state bits (rogue.h room flags). type RoomFlags int16 +// Room state bits (rogue.h ISDARK/ISGONE/ISMAZE). const ( Dark RoomFlags = 1 << iota // room is dark Gone // room is gone (a corridor) Maze // room is a maze ) -func (f RoomFlags) Has(b RoomFlags) bool { return f&b != 0 } -func (f *RoomFlags) Set(b RoomFlags) { *f |= b } -func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b } +// Has reports whether any of the given bits are set. +func (f *RoomFlags) Has(b RoomFlags) bool { return *f&b != 0 } -// Flags for objects (rogue.h) +// Set turns the given bits on. +func (f *RoomFlags) Set(b RoomFlags) { *f |= b } + +// Clear turns the given bits off. +func (f *RoomFlags) Clear(b RoomFlags) { *f &^= b } + +// ObjFlags are the object state bits (rogue.h object flags). type ObjFlags int32 +// Object state bits (rogue.h). const ( Cursed ObjFlags = 1 << iota // ISCURSED: object is cursed Known // ISKNOW: player knows details about the object @@ -115,15 +122,22 @@ const ( Protected // ISPROT: armor is permanently protected ) -func (f ObjFlags) Has(b ObjFlags) bool { return f&b != 0 } -func (f *ObjFlags) Set(b ObjFlags) { *f |= b } -func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b } +// Has reports whether any of the given bits are set. +func (f *ObjFlags) Has(b ObjFlags) bool { return *f&b != 0 } -// Flags for creatures (rogue.h). The C bit collisions are deliberate and -// preserved: one name of each pair applies to monsters, the other to the -// hero, and they never coexist on one creature. +// Set turns the given bits on. +func (f *ObjFlags) Set(b ObjFlags) { *f |= b } + +// Clear turns the given bits off. +func (f *ObjFlags) Clear(b ObjFlags) { *f &^= b } + +// CreatureFlags are the creature state bits (rogue.h creature flags). The +// C bit collisions are deliberate and preserved: one name of each pair +// applies to monsters, the other to the hero, and they never coexist on +// one creature. type CreatureFlags int32 +// Creature state bits (rogue.h). const ( CanConfuse CreatureFlags = 0o000001 // CANHUH: creature can confuse CanSeeInvisible CreatureFlags = 0o000002 // CANSEE: creature can see invisible creatures @@ -146,13 +160,20 @@ const ( Slowed CreatureFlags = 0o100000 // ISSLOW: creature has been slowed ) -func (f CreatureFlags) Has(b CreatureFlags) bool { return f&b != 0 } -func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b } -func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b } +// Has reports whether any of the given bits are set. +func (f *CreatureFlags) Has(b CreatureFlags) bool { return *f&b != 0 } -// Flags for the level map (rogue.h) +// Set turns the given bits on. +func (f *CreatureFlags) Set(b CreatureFlags) { *f |= b } + +// Clear turns the given bits off. +func (f *CreatureFlags) Clear(b CreatureFlags) { *f &^= b } + +// PlaceFlags are the per-map-cell bits (rogue.h level map flags). The low +// bits double as the passage number (FPassNum) or trap kind (FTrapMask). type PlaceFlags uint8 +// Map cell bits (rogue.h). const ( FPassage PlaceFlags = 0x80 // F_PASS: is a passageway FSeen PlaceFlags = 0x40 // have seen this spot before @@ -163,14 +184,20 @@ const ( FTrapMask PlaceFlags = 0x07 // F_TMASK: trap number mask ) -func (f PlaceFlags) Has(b PlaceFlags) bool { return f&b != 0 } -func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b } -func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b } +// Has reports whether any of the given bits are set. +func (f *PlaceFlags) Has(b PlaceFlags) bool { return *f&b != 0 } + +// Set turns the given bits on. +func (f *PlaceFlags) Set(b PlaceFlags) { *f |= b } + +// Clear turns the given bits off. +func (f *PlaceFlags) Clear(b PlaceFlags) { *f &^= b } // TrapKind identifies a trap (rogue.h trap types). The kind is stored in // the low bits of a map cell's PlaceFlags (FTrapMask). type TrapKind int +// Trap kinds (rogue.h T_* constants). const ( TrapDoor TrapKind = 0 TrapArrow TrapKind = 1 @@ -189,6 +216,7 @@ func (t TrapKind) String() string { if t < 0 || t >= NumTrapTypes { return "a bizarre trap" } + return trName[t] } @@ -219,6 +247,7 @@ func (p PotionKind) String() string { if p < 0 || p >= NumPotionTypes { return "strange potion" } + return basePotInfo[p].Name } @@ -253,6 +282,7 @@ func (s ScrollKind) String() string { if s < 0 || s >= NumScrollTypes { return "strange scroll" } + return baseScrInfo[s].Name } @@ -270,15 +300,19 @@ const ( WeaponDart WeaponShuriken WeaponSpear - WeaponFlame // fake entry for dragon breath (ick) - NumWeaponTypes = WeaponFlame + WeaponFlame // fake entry for dragon breath (ick) ) +// NumWeaponTypes counts the real weapons; the flame pseudo-weapon sits +// just past them in the tables (C's MAXWEAPONS == FLAME). +const NumWeaponTypes = WeaponFlame + // String returns the weapon's name ("mace", "two handed sword", ...). func (w WeaponKind) String() string { if w < 0 || w > WeaponFlame { return "strange weapon" } + return baseWeapInfo[w].Name } @@ -303,6 +337,7 @@ func (a ArmorKind) String() string { if a < 0 || a >= NumArmorTypes { return "strange armor" } + return baseArmInfo[a].Name } @@ -333,6 +368,7 @@ func (r RingKind) String() string { if r < 0 || r >= NumRingTypes { return "strange ring" } + return baseRingInfo[r].Name } @@ -363,6 +399,7 @@ func (w WandKind) String() string { if w < 0 || w >= NumWandTypes { return "strange stick" } + return baseWsInfo[w].Name } @@ -425,6 +462,7 @@ func CTRL(c byte) byte { return c & 0o37 } func distance(y1, x1, y2, x2 int) int { dx := x2 - x1 dy := y2 - y1 + return dx*dx + dy*dy } @@ -439,5 +477,6 @@ func sign(nm int) int { case nm > 0: return 1 } + return 0 } diff --git a/game/weapons.go b/game/weapons.go index 235ef67..63da01a 100644 --- a/game/weapons.go +++ b/game/weapons.go @@ -13,9 +13,11 @@ func (g *RogueGame) missile(ydelta, xdelta int) { if obj == nil { return } + if !g.dropCheck(obj) || g.isCurrent(obj) { return } + obj = g.leavePack(obj, true, false) g.doMotion(obj, ydelta, xdelta) // AHA! Here it has hit something. If it is a wall or a door, or if @@ -39,11 +41,13 @@ func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) { if ch == Floor && !g.showFloor() { ch = ' ' } + g.mvaddch(obj.Pos.Y, obj.Pos.X, ch) } // Get the new position obj.Pos.Y += ydelta obj.Pos.X += xdelta + ch := g.Level.VisibleChar(obj.Pos.Y, obj.Pos.X) if stepOk(ch) && ch != Door { // It hasn't hit anything yet, so display it if it's alright. @@ -51,8 +55,10 @@ func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) { g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph()) g.refresh() } + continue } + break } } @@ -62,6 +68,7 @@ func (g *RogueGame) fall(obj *Object, pr bool) { if fpos, ok := g.fallpos(obj.Pos); ok { pp := g.Level.At(fpos.Y, fpos.X) pp.Ch = obj.Kind.Glyph() + obj.Pos = fpos if g.cansee(fpos.Y, fpos.X) { if pp.Monst != nil { @@ -70,14 +77,18 @@ func (g *RogueGame) fall(obj *Object, pr bool) { g.mvaddch(fpos.Y, fpos.X, obj.Kind.Glyph()) } } + attachObj(&g.Level.Objects, obj) + return } + if pr { if g.HasHit { g.endmsg() g.HasHit = false } + g.msg("the %s vanishes as it hits the ground", g.Items.Weapons[obj.Which].Name) } @@ -92,32 +103,43 @@ func (g *RogueGame) hitMonster(mp Coord, obj *Object) bool { // wield pulls out a certain weapon (weapons.c wield). func (g *RogueGame) wield() { p := &g.Player + oweapon := p.CurWeapon if !g.dropCheck(p.CurWeapon) { p.CurWeapon = oweapon + return } + p.CurWeapon = oweapon + obj := g.getItem("wield", KindWeapon) if obj == nil { g.After = false + return } + if obj.Kind == KindArmor { g.msg("you can't wield armor") g.After = false + return } + if g.isCurrent(obj) { g.After = false + return } sp := g.invName(obj, true) p.CurWeapon = obj + if !g.Options.Terse { - g.addmsg("you are now ") + g.addmsgf("you are now ") } + g.msg("wielding %s (%c)", sp, obj.PackCh) } @@ -149,16 +171,19 @@ func (g *RogueGame) initWeapon(weap *Object, which WeaponKind) { weap.Launch = iwp.launch weap.Flags = iwp.flags weap.HPlus = 0 + weap.DPlus = 0 - if which == WeaponDagger { + + switch { + case which == WeaponDagger: weap.Count = g.rnd(4) + 2 weap.Group = g.Items.Group g.Items.Group++ - } else if weap.Flags.Has(Stackable) { + case weap.Flags.Has(Stackable): weap.Count = g.rnd(8) + 8 weap.Group = g.Items.Group g.Items.Group++ - } else { + default: weap.Count = 1 weap.Group = 0 } @@ -170,6 +195,7 @@ func num(n1, n2 int, typ byte) string { if typ == Weapon { out += fmt.Sprintf(",%+d", n2) } + return out } @@ -177,7 +203,9 @@ func num(n1, n2 int, typ byte) string { // (weapons.c fallpos). func (g *RogueGame) fallpos(pos Coord) (Coord, bool) { var newpos Coord + cnt := 0 + for y := pos.Y - 1; y <= pos.Y+1; y++ { for x := pos.X - 1; x <= pos.X+1; x++ { // check to make certain the spot is empty, if it is, put the @@ -187,6 +215,7 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) { y < 0 || x < 0 { continue } + ch := g.Level.Char(y, x) if ch == Floor || ch == Passage { if cnt++; g.rnd(cnt) == 0 { @@ -196,5 +225,6 @@ func (g *RogueGame) fallpos(pos Coord) (Coord, bool) { } } } + return newpos, cnt != 0 } diff --git a/game/wizard.go b/game/wizard.go index 8223b6d..1914fc8 100644 --- a/game/wizard.go +++ b/game/wizard.go @@ -8,32 +8,40 @@ package game // create_obj). func (g *RogueGame) createObj() { obj := newObject() + g.msg("type of item: ") obj.Kind = objectKindForGlyph(g.readchar()) g.Msgs.Mpos = 0 g.msg("which %c do you want? (0-f)", obj.Kind.Glyph()) + 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.Kind == KindWeapon || obj.Kind == KindArmor: + + switch obj.Kind { + case KindWeapon, KindArmor: g.msg("blessing? (+,-,n)") bless := g.readchar() g.Msgs.Mpos = 0 + if bless == '-' { obj.Flags.Set(Cursed) } + if obj.Kind == KindWeapon { g.initWeapon(obj, WeaponKind(obj.Which)) + if bless == '-' { obj.HPlus -= g.rnd(3) + 1 } + if bless == '+' { obj.HPlus += g.rnd(3) + 1 } @@ -42,16 +50,18 @@ func (g *RogueGame) createObj() { if bless == '-' { obj.ArmorClass += g.rnd(3) + 1 } + if bless == '+' { obj.ArmorClass -= g.rnd(3) + 1 } } - case obj.Kind == KindRing: + case KindRing: switch obj.RingKind() { case RingProtection, RingAddStrength, RingDexterity, RingIncreaseDamage: g.msg("blessing? (+,-,n)") bless := g.readchar() g.Msgs.Mpos = 0 + if bless == '-' { obj.Flags.Set(Cursed) obj.Bonus = -1 @@ -61,15 +71,17 @@ func (g *RogueGame) createObj() { case RingAggravateMonsters, RingTeleportation: obj.Flags.Set(Cursed) } - case obj.Kind == KindWand: + case KindWand: g.fixStick(obj) - case obj.Kind == KindGold: + case KindGold: g.msg("how much?") + buf := "" if g.getStr(&buf, g.scr.Std) == Norm { obj.GoldValue = cAtoi(buf) } } + g.addPack(obj, false) } @@ -77,18 +89,22 @@ func (g *RogueGame) createObj() { 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 { + for x := range NumCols { + isReal := g.Level.FlagsAt(y, x).Has(FReal) + if !isReal { hw.Standout(true) } + hw.MvAddCh(y, x, g.Level.Char(y, x)) - if !real { + + if !isReal { hw.Standout(false) } } } + g.showWin("---More (level map)---") } @@ -97,27 +113,35 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) { p := &g.Player if len(p.Pack) == 0 { g.msg("you don't have anything in your pack to identify") + return } var obj *Object for { obj = g.getItem("identify", kind) + if !insist { break } + if g.NObjs == 0 { return } + if obj == nil { g.msg("you must identify something") - } else if kind != KindNone && obj.Kind != kind && - !(kind == KindRingOrStick && - (obj.Kind == KindRing || obj.Kind == KindWand)) { - g.msg("you must identify a %s", kind) - } else { - break + + continue } + + if !matchesFilter(kind, obj) { + g.msg("you must identify a %s", kind) + + continue + } + + break } if obj == nil { @@ -136,6 +160,7 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) { case KindRing: setKnow(obj, g.Items.Rings[:]) } + g.msg("%s", g.invName(obj, false)) } @@ -154,15 +179,18 @@ func setKnow(obj *Object, info []ObjInfo) { func (g *RogueGame) teleport() { p := &g.Player g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt()) - c, _ := g.findFloor(nil, 0, true) + + c, _ := g.findFloor(true) if g.roomin(c) != p.Room { g.leaveRoom(p.Pos) p.Pos = c g.enterRoom(p.Pos) } else { p.Pos = c + g.look(true) } + g.mvaddch(p.Pos.Y, p.Pos.X, PlayerCh) // turn off ISHELD in case teleportation was done while fighting a // Flytrap @@ -171,6 +199,7 @@ func (g *RogueGame) teleport() { p.VfHit = 0 g.Monsters['F'-'A'].Stats.Dmg = dice("000x0") } + g.NoMove = 0 g.Count = 0 g.Running = false diff --git a/term/tcell.go b/term/tcell.go index 5f3cbc8..8d68857 100644 --- a/term/tcell.go +++ b/term/tcell.go @@ -5,6 +5,8 @@ package term import ( + "context" + "errors" "fmt" "os" "os/exec" @@ -21,6 +23,9 @@ type Tcell struct { last *game.Window // last rendered window, for resize redraws } +// ErrScreenTooSmall reports a terminal below the required 80x24. +var ErrScreenTooSmall = errors.New("screen too small") + // New initializes the terminal. The screen must be at least 80x24, as the // C game required. func New() (*Tcell, error) { @@ -28,16 +33,22 @@ func New() (*Tcell, error) { if err != nil { return nil, err } - if err := s.Init(); err != nil { - return nil, err + + initErr := s.Init() + if initErr != nil { + return nil, initErr } + 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) + + return nil, fmt.Errorf("sorry, %w: %dx%d required", + ErrScreenTooSmall, game.NumCols, game.NumLines) } + s.HideCursor() + return &Tcell{screen: s}, nil } @@ -49,17 +60,21 @@ func (t *Tcell) 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++ { + for y := range rows { + for x := range cols { 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() } @@ -105,8 +120,9 @@ func (t *Tcell) ReadChar() byte { return 3 default: if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ { - return byte(ev.Key()) + return byte(ev.Key()) //nolint:gosec // G115: 1..26 fits } + if r := ev.Rune(); r > 0 && r < 0x80 { return byte(r) } @@ -118,18 +134,29 @@ func (t *Tcell) ReadChar() byte { // 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 { + err := t.screen.Suspend() + if 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) + + _, _ = fmt.Fprintln(os.Stdout, + "[Entering shell; exit to return to the game]") + + // The shell session has no deadline by design; Background context. + cmd := exec.CommandContext(context.Background(), //nolint:gosec // G204: the user's own $SHELL + shell) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Run() - t.screen.Resume() + _ = cmd.Run() // best effort: the shell is the user's business + + resumeErr := t.screen.Resume() + if resumeErr != nil { + panic(resumeErr) // terminal resume failure is unrecoverable + } }