From cc025eb8089fe656851064d0067d257f5619cdac Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 02:44:33 +0200 Subject: [PATCH 01/32] Convert quaff to a per-potion handler table (refactor step 7) The quaff switch becomes gameData.quaffHandlers, a method-expression table indexed by PotionKind; each case body moved verbatim into a quaff* method. Effect order and RNG call sequence unchanged. --- game/potions.go | 316 +++++++++++++++++++++++++++--------------------- game/tables.go | 22 ++++ 2 files changed, 199 insertions(+), 139 deletions(-) diff --git a/game/potions.go b/game/potions.go index bf6ffc3..8d39e2b 100644 --- a/game/potions.go +++ b/game/potions.go @@ -41,145 +41,8 @@ func (g *RogueGame) quaff() { g.leavePack(obj, false, false) - switch obj.PotionKind() { - case PotionConfusion: - g.applyPotionFuse(PotionConfusion, !trip) - case PotionPoison: - g.Items.Potions[PotionPoison].Know = true - if p.IsWearing(RingSustainStrength) { - g.msg("you feel momentarily sick") - } else { - g.changeStrength(-(g.rnd(3) + 1)) - g.msg("you feel very sick now") - g.comeDown(0) - } - case PotionHealing: - g.Items.Potions[PotionHealing].Know = true - if p.Stats.HP += g.roll(p.Stats.Lvl, 4); p.Stats.HP > p.Stats.MaxHP { - p.Stats.MaxHP++ - p.Stats.HP = p.Stats.MaxHP - } - - g.sight(0) - g.msg("you begin to feel better") - case PotionGainStrength: - g.Items.Potions[PotionGainStrength].Know = true - g.changeStrength(1) - g.msg("you feel stronger, now. What bulging muscles!") - 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")) - } - 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 g.isMagic(tp) { - 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 g.isMagic(tp) { - 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--") - } else { - g.msg("you have a %s feeling for a moment, then it passes", - g.chooseStr("normal", "strange")) - } - case PotionLSD: - if !trip { - if p.On(SenseMonsters) { - g.turnSee(false) - } - - g.StartDaemon(DVisuals, 0, Before) - g.SeenStairs = g.seenStairs() - } - - g.applyPotionFuse(PotionLSD, true) - case PotionSeeInvisible: - show := p.On(CanSeeInvisible) - - g.applyPotionFuse(PotionSeeInvisible, false) - - if !show { - g.invisOn() - } - - g.sight(0) - case PotionRaiseLevel: - g.Items.Potions[PotionRaiseLevel].Know = true - g.msg("you suddenly feel much more skillful") - g.raiseLevel() - case PotionExtraHealing: - g.Items.Potions[PotionExtraHealing].Know = true - if p.Stats.HP += g.roll(p.Stats.Lvl, 8); p.Stats.HP > p.Stats.MaxHP { - 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") - } - case PotionRestoreStrength: - 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.applyPotionFuse(PotionBlindness, true) - case PotionLevitation: - g.applyPotionFuse(PotionLevitation, true) + if h := g.data.quaffHandlers[obj.PotionKind()]; h != nil { + h(g, trip) } g.status() @@ -187,6 +50,181 @@ func (g *RogueGame) quaff() { g.callIt(&g.Items.Potions[obj.Which]) } +// The per-potion effect handlers, dispatched through +// gameData.quaffHandlers. Each is one case of the C quaff switch. + +func (g *RogueGame) quaffConfusion(trip bool) { + g.applyPotionFuse(PotionConfusion, !trip) +} + +func (g *RogueGame) quaffPoison(bool) { + g.Items.Potions[PotionPoison].Know = true + if g.Player.IsWearing(RingSustainStrength) { + g.msg("you feel momentarily sick") + } else { + g.changeStrength(-(g.rnd(3) + 1)) + g.msg("you feel very sick now") + g.comeDown(0) + } +} + +func (g *RogueGame) quaffHealing(bool) { + p := &g.Player + + g.Items.Potions[PotionHealing].Know = true + if p.Stats.HP += g.roll(p.Stats.Lvl, 4); p.Stats.HP > p.Stats.MaxHP { + p.Stats.MaxHP++ + p.Stats.HP = p.Stats.MaxHP + } + + g.sight(0) + g.msg("you begin to feel better") +} + +func (g *RogueGame) quaffGainStrength(bool) { + g.Items.Potions[PotionGainStrength].Know = true + g.changeStrength(1) + g.msg("you feel stronger, now. What bulging muscles!") +} + +func (g *RogueGame) quaffDetectMonsters(bool) { + g.Player.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")) + } +} + +func (g *RogueGame) quaffDetectMagic(bool) { + // 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 g.isMagic(tp) { + 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 g.isMagic(tp) { + 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--") + } else { + g.msg("you have a %s feeling for a moment, then it passes", + g.chooseStr("normal", "strange")) + } +} + +func (g *RogueGame) quaffLSD(trip bool) { + p := &g.Player + if !trip { + if p.On(SenseMonsters) { + g.turnSee(false) + } + + g.StartDaemon(DVisuals, 0, Before) + g.SeenStairs = g.seenStairs() + } + + g.applyPotionFuse(PotionLSD, true) +} + +func (g *RogueGame) quaffSeeInvisible(bool) { + show := g.Player.On(CanSeeInvisible) + + g.applyPotionFuse(PotionSeeInvisible, false) + + if !show { + g.invisOn() + } + + g.sight(0) +} + +func (g *RogueGame) quaffRaiseLevel(bool) { + g.Items.Potions[PotionRaiseLevel].Know = true + g.msg("you suddenly feel much more skillful") + g.raiseLevel() +} + +func (g *RogueGame) quaffExtraHealing(bool) { + p := &g.Player + + g.Items.Potions[PotionExtraHealing].Know = true + if p.Stats.HP += g.roll(p.Stats.Lvl, 8); p.Stats.HP > p.Stats.MaxHP { + 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") +} + +func (g *RogueGame) quaffHaste(bool) { + g.Items.Potions[PotionHaste].Know = true + + g.After = false + if g.addHaste(true) { + g.msg("you feel yourself moving much faster") + } +} + +func (g *RogueGame) quaffRestoreStrength(bool) { + p := &g.Player + 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") +} + +func (g *RogueGame) quaffBlindness(bool) { + g.applyPotionFuse(PotionBlindness, true) +} + +func (g *RogueGame) quaffLevitation(bool) { + g.applyPotionFuse(PotionLevitation, true) +} + // raiseLevel: the guy just magically went up a level (potions.c // raise_level). func (g *RogueGame) raiseLevel() { diff --git a/game/tables.go b/game/tables.go index bb2c4fc..3f44b0e 100644 --- a/game/tables.go +++ b/game/tables.go @@ -122,6 +122,11 @@ type gameData struct { // scoreReasons is the rip.c reason[] scoreboard strings. scoreReasons [4]string + + // quaffHandlers dispatches each potion kind to its effect method: + // the cases of the potions.c quaff switch. The bool is trip — was + // the hero hallucinating when the potion went down. + quaffHandlers [NumPotionTypes]func(g *RogueGame, trip bool) } // ripWall is the repeated blank wall line of the tombstone art. @@ -587,6 +592,23 @@ func newGameData() *gameData { "A total winner", "killed with Amulet", }, + + quaffHandlers: [NumPotionTypes]func(*RogueGame, bool){ + PotionConfusion: (*RogueGame).quaffConfusion, + PotionLSD: (*RogueGame).quaffLSD, + PotionPoison: (*RogueGame).quaffPoison, + PotionGainStrength: (*RogueGame).quaffGainStrength, + PotionSeeInvisible: (*RogueGame).quaffSeeInvisible, + PotionHealing: (*RogueGame).quaffHealing, + PotionDetectMonsters: (*RogueGame).quaffDetectMonsters, + PotionDetectMagic: (*RogueGame).quaffDetectMagic, + PotionRaiseLevel: (*RogueGame).quaffRaiseLevel, + PotionExtraHealing: (*RogueGame).quaffExtraHealing, + PotionHaste: (*RogueGame).quaffHaste, + PotionRestoreStrength: (*RogueGame).quaffRestoreStrength, + PotionBlindness: (*RogueGame).quaffBlindness, + PotionLevitation: (*RogueGame).quaffLevitation, + }, } } From 3047f729aa91a964f3438ee5e596da4898c9bca4 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 02:46:09 +0200 Subject: [PATCH 02/32] Convert readScroll to a per-scroll handler table (refactor step 7) The read_scroll switch becomes gameData.readHandlers, indexed by ScrollKind; the five identify scrolls share one handler. The magic mapping cell logic is extracted into revealSpot. Effect order and RNG call sequence unchanged. --- game/scrolls.go | 525 ++++++++++++++++++++++++++---------------------- game/tables.go | 26 +++ 2 files changed, 312 insertions(+), 239 deletions(-) diff --git a/game/scrolls.go b/game/scrolls.go index f1eea9e..fb03628 100644 --- a/game/scrolls.go +++ b/game/scrolls.go @@ -28,245 +28,8 @@ func (g *RogueGame) readScroll() { // Get rid of the thing g.leavePack(obj, false, false) - switch obj.ScrollKind() { - case ScrollMonsterConfusion: - // Scroll of monster confusion. Give him that power. - p.Flags.Set(CanConfuse) - g.msg("your hands begin to glow %s", g.pickColor("red")) - case ScrollEnchantArmor: - if p.CurArmor != nil { - p.CurArmor.ArmorClass-- - p.CurArmor.Flags.Clear(Cursed) - g.msg("your armor glows %s for a moment", g.pickColor("silver")) - } - case ScrollHoldMonster: - // 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.addmsgf("the monster") - - if held > 1 { - g.addmsgf("s around you") - } - - g.addmsgf(" freeze") - - if held == 1 { - g.addmsgf("s") - } - - g.endmsg() - g.Items.Scrolls[ScrollHoldMonster].Know = true - } else { - g.msg("you feel a strange sense of loss") - } - case ScrollSleep: - // 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. - if y == p.Pos.Y && x == p.Pos.X { - continue - } - // Or anything else nasty - if ch := g.Level.VisibleChar(y, x); stepOk(ch) { - if ch == Scroll { - if fo := g.Level.ObjectAt(y, x); fo != nil && fo.ScrollKind() == ScrollScareMonster { - 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 { - tp := &Monster{} - g.newMonster(tp, g.randMonster(false), mp) - } - case ScrollIdentifyPotion, ScrollIdentifyScroll, ScrollIdentifyWeapon, ScrollIdentifyArmor, ScrollIdentifyRingOrStick: - // Identify, let him figure something out - g.Items.Scrolls[obj.Which].Know = true - g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name) - g.whatis(true, g.data.idType[obj.ScrollKind()]) - case ScrollMagicMapping: - // Scroll of magic mapping. - g.Items.Scrolls[ScrollMagicMapping].Know = true - 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 := range NumCols { - pp := g.Level.At(y, x) - ch := pp.Ch - pass := false - - switch ch { - case Door, Stairs: - case '-', '|': - if !pp.Flags.Has(FReal) { - ch = Door - pp.Ch = Door - pp.Flags.Set(FReal) - } - case ' ': - if pp.Flags.Has(FReal) { - // def: hidden things in walls stay hidden - if pp.Flags.Has(FPassage) { - pass = true - } else { - ch = ' ' - } - } else { - pp.Flags.Set(FReal) - pp.Ch = Passage - pass = true - } - case Passage: - pass = true - case Floor: - if pp.Flags.Has(FReal) { - ch = ' ' - } else { - ch = Trap - pp.Ch = Trap - pp.Flags.Set(FSeen | FReal) - } - default: - if pp.Flags.Has(FPassage) { - pass = true - } else { - 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 - if !p.On(SenseMonsters) { - g.mvaddch(y, x, ch) - } - } else { - g.mvaddch(y, x, ch) - } - } - } - } - 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--") - } else { - g.msg("your nose tingles") - } - 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 - } - case ScrollEnchantWeapon: - if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon { - 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")) - } - case ScrollScareMonster: - // Reading it is a mistake and produces laughter at her poor boo - // boo. - g.msg("you hear maniacal laughter in the distance") - case ScrollRemoveCurse: - uncurse(p.CurArmor) - uncurse(p.CurWeapon) - uncurse(p.CurRing[Left]) - uncurse(p.CurRing[Right]) - g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes", - "you feel as if somebody is watching over you")) - case ScrollAggravateMonsters: - // This scroll aggravates all the monsters on the current level - // and sets them running towards the hero - g.aggravate() - g.msg("you hear a high pitched humming noise") - case ScrollProtectArmor: - if p.CurArmor != nil { - p.CurArmor.Flags.Set(Protected) - g.msg("your armor is covered by a shimmering %s shield", - g.pickColor("gold")) - } else { - g.msg("you feel a strange sense of loss") - } + if h := g.data.readHandlers[obj.ScrollKind()]; h != nil { + h(g, obj) } g.look(true) // put the result of the scroll on the screen @@ -275,6 +38,290 @@ func (g *RogueGame) readScroll() { g.callIt(&g.Items.Scrolls[obj.Which]) } +// The per-scroll effect handlers, dispatched through +// gameData.readHandlers. Each is one case of the C read_scroll switch. + +func (g *RogueGame) readMonsterConfusion(*Object) { + // Scroll of monster confusion. Give him that power. + g.Player.Flags.Set(CanConfuse) + g.msg("your hands begin to glow %s", g.pickColor("red")) +} + +func (g *RogueGame) readEnchantArmor(*Object) { + p := &g.Player + if p.CurArmor != nil { + p.CurArmor.ArmorClass-- + p.CurArmor.Flags.Clear(Cursed) + g.msg("your armor glows %s for a moment", g.pickColor("silver")) + } +} + +func (g *RogueGame) readHoldMonster(*Object) { + // Hold monster scroll. Stop all monsters within two spaces from + // chasing after the hero. + p := &g.Player + 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.addmsgf("the monster") + + if held > 1 { + g.addmsgf("s around you") + } + + g.addmsgf(" freeze") + + if held == 1 { + g.addmsgf("s") + } + + g.endmsg() + g.Items.Scrolls[ScrollHoldMonster].Know = true + } else { + g.msg("you feel a strange sense of loss") + } +} + +func (g *RogueGame) readSleep(*Object) { + // Scroll which makes you fall asleep + g.Items.Scrolls[ScrollSleep].Know = true + g.NoCommand += g.rnd(g.spread(5)) + 4 // SLEEPTIME + + g.Player.Flags.Clear(Awake) + g.msg("you fall asleep") +} + +func (g *RogueGame) readCreateMonster(*Object) { + // Create a monster: first look in a circle around him, next try + // his room, otherwise give up + p := &g.Player + 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. + if y == p.Pos.Y && x == p.Pos.X { + continue + } + // Or anything else nasty + if ch := g.Level.VisibleChar(y, x); stepOk(ch) { + if ch == Scroll { + if fo := g.Level.ObjectAt(y, x); fo != nil && fo.ScrollKind() == ScrollScareMonster { + 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 { + tp := &Monster{} + g.newMonster(tp, g.randMonster(false), mp) + } +} + +func (g *RogueGame) readIdentify(obj *Object) { + // Identify, let him figure something out + g.Items.Scrolls[obj.Which].Know = true + g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name) + g.whatis(true, g.data.idType[obj.ScrollKind()]) +} + +func (g *RogueGame) readMagicMapping(*Object) { + // Scroll of magic mapping. + g.Items.Scrolls[ScrollMagicMapping].Know = true + 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 := range NumCols { + g.revealSpot(y, x) + } + } +} + +// revealSpot uncovers one map cell for magic mapping: secret doors and +// passages become real, hidden traps show, and the visible character is +// drawn (the loop body of the C SCR_MAP case). +func (g *RogueGame) revealSpot(y, x int) { + pp := g.Level.At(y, x) + ch := pp.Ch + pass := false + + switch ch { + case Door, Stairs: + case '-', '|': + if !pp.Flags.Has(FReal) { + ch = Door + pp.Ch = Door + pp.Flags.Set(FReal) + } + case ' ': + if pp.Flags.Has(FReal) { + // def: hidden things in walls stay hidden + if pp.Flags.Has(FPassage) { + pass = true + } else { + ch = ' ' + } + } else { + pp.Flags.Set(FReal) + pp.Ch = Passage + pass = true + } + case Passage: + pass = true + case Floor: + if pp.Flags.Has(FReal) { + ch = ' ' + } else { + ch = Trap + pp.Ch = Trap + pp.Flags.Set(FSeen | FReal) + } + default: + if pp.Flags.Has(FPassage) { + pass = true + } else { + 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 + if !g.Player.On(SenseMonsters) { + g.mvaddch(y, x, ch) + } + } else { + g.mvaddch(y, x, ch) + } + } +} + +func (g *RogueGame) readFoodDetection(*Object) { + // 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--") + } else { + g.msg("your nose tingles") + } +} + +func (g *RogueGame) readTeleportation(*Object) { + // Scroll of teleportation: make him disappear and reappear + p := &g.Player + curRoom := p.Room + + g.teleport() + + if curRoom != p.Room { + g.Items.Scrolls[ScrollTeleportation].Know = true + } +} + +func (g *RogueGame) readEnchantWeapon(*Object) { + p := &g.Player + if p.CurWeapon == nil || p.CurWeapon.Kind != KindWeapon { + 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")) + } +} + +func (g *RogueGame) readScareMonster(*Object) { + // Reading it is a mistake and produces laughter at her poor boo + // boo. + g.msg("you hear maniacal laughter in the distance") +} + +func (g *RogueGame) readRemoveCurse(*Object) { + p := &g.Player + + uncurse(p.CurArmor) + uncurse(p.CurWeapon) + uncurse(p.CurRing[Left]) + uncurse(p.CurRing[Right]) + g.msg("%s", g.chooseStr("you feel in touch with the Universal Onenes", + "you feel as if somebody is watching over you")) +} + +func (g *RogueGame) readAggravateMonsters(*Object) { + // This scroll aggravates all the monsters on the current level + // and sets them running towards the hero + g.aggravate() + g.msg("you hear a high pitched humming noise") +} + +func (g *RogueGame) readProtectArmor(*Object) { + p := &g.Player + if p.CurArmor != nil { + p.CurArmor.Flags.Set(Protected) + g.msg("your armor is covered by a shimmering %s shield", + g.pickColor("gold")) + } else { + g.msg("you feel a strange sense of loss") + } +} + // uncurse uncurses an item (scrolls.c uncurse). func uncurse(obj *Object) { if obj != nil { diff --git a/game/tables.go b/game/tables.go index 3f44b0e..665aaed 100644 --- a/game/tables.go +++ b/game/tables.go @@ -127,6 +127,11 @@ type gameData struct { // the cases of the potions.c quaff switch. The bool is trip — was // the hero hallucinating when the potion went down. quaffHandlers [NumPotionTypes]func(g *RogueGame, trip bool) + + // readHandlers dispatches each scroll kind to its effect method: + // the cases of the scrolls.c read_scroll switch. The handler gets + // the scroll being read (the identify family needs its subtype). + readHandlers [NumScrollTypes]func(g *RogueGame, obj *Object) } // ripWall is the repeated blank wall line of the tombstone art. @@ -609,6 +614,27 @@ func newGameData() *gameData { PotionBlindness: (*RogueGame).quaffBlindness, PotionLevitation: (*RogueGame).quaffLevitation, }, + + readHandlers: [NumScrollTypes]func(*RogueGame, *Object){ + ScrollMonsterConfusion: (*RogueGame).readMonsterConfusion, + ScrollMagicMapping: (*RogueGame).readMagicMapping, + ScrollHoldMonster: (*RogueGame).readHoldMonster, + ScrollSleep: (*RogueGame).readSleep, + ScrollEnchantArmor: (*RogueGame).readEnchantArmor, + ScrollIdentifyPotion: (*RogueGame).readIdentify, + ScrollIdentifyScroll: (*RogueGame).readIdentify, + ScrollIdentifyWeapon: (*RogueGame).readIdentify, + ScrollIdentifyArmor: (*RogueGame).readIdentify, + ScrollIdentifyRingOrStick: (*RogueGame).readIdentify, + ScrollScareMonster: (*RogueGame).readScareMonster, + ScrollFoodDetection: (*RogueGame).readFoodDetection, + ScrollTeleportation: (*RogueGame).readTeleportation, + ScrollEnchantWeapon: (*RogueGame).readEnchantWeapon, + ScrollCreateMonster: (*RogueGame).readCreateMonster, + ScrollRemoveCurse: (*RogueGame).readRemoveCurse, + ScrollAggravateMonsters: (*RogueGame).readAggravateMonsters, + ScrollProtectArmor: (*RogueGame).readProtectArmor, + }, } } From 8e2915f60d43f4f5bfb00ab42740e2762e254fff Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 02:48:01 +0200 Subject: [PATCH 03/32] Convert doZap to a per-wand handler table (refactor step 7) The do_zap switch becomes gameData.zapHandlers, indexed by WandKind; the shared monster-ray preamble is zapRayMonster/zapVictim, teleport away/to and the three bolt wands share handlers, and a false return aborts the zap without spending a charge (drain life on a too-weak hero, as in C). Effect order and RNG call sequence unchanged. --- game/sticks.go | 408 ++++++++++++++++++++++++++++--------------------- game/tables.go | 21 +++ 2 files changed, 257 insertions(+), 172 deletions(-) diff --git a/game/sticks.go b/game/sticks.go index 0b2d51a..4d1d816 100644 --- a/game/sticks.go +++ b/game/sticks.go @@ -12,8 +12,6 @@ const ( // doZap performs a zap with a wand (sticks.c do_zap). func (g *RogueGame) doZap() { - p := &g.Player - obj, ok := g.promptPackItem("zap with", KindWand) if !ok { return @@ -32,182 +30,248 @@ func (g *RogueGame) doZap() { return } - switch obj.WandKind() { - case WandLight: - // Reddy Kilowatt wand. Light up the room - g.Items.Sticks[WandLight].Know = true - if p.Room.Flags.Has(Gone) { - g.msg("the corridor glows and then fades") - } else { - p.Room.Flags.Clear(Dark) - // Light the room and put the player back up - g.enterRoom(p.Pos) - g.addmsgf("the room is lit") - - if !g.Options.Terse { - g.addmsgf(" by a shimmering %s light", g.pickColor("blue")) - } - - g.endmsg() + if h := g.data.zapHandlers[obj.WandKind()]; h != nil { + if !h(g, obj) { + return // the zap aborted; no charge is used } - case WandDrainLife: - // take away 1/2 of hero's hit points, then take it away evenly - // from the monsters in the room (or next to hero if he is in a - // 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 - g.Level.RemoveMonster(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 = 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 - } - 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(true) - if newPos != p.Pos { - break - } - } - } else { - 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) - } - } - case WandMagicMissile: - g.Items.Sticks[WandMagicMissile].Know = true - bolt := newObject() - bolt.Kind = KindGold // C set o_type='*': draws a '*' and is not a weapon - 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") //nolint:misspell // C's spelling, kept faithfully - } else { - 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) { - tp.Flags.Clear(Slowed) - } else { - tp.Flags.Set(Hasted) - } - } else { - if tp.On(Hasted) { - tp.Flags.Clear(Hasted) - } 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" - case WandFire: - name = "flame" - default: - name = "ice" - } - - g.fireBolt(p.Pos, &g.Delta, name) - g.Items.Sticks[obj.Which].Know = true - case WandNothing: } obj.Charges-- } +// zapRayMonster walks the zap ray from the hero to the first blocking +// spot and returns the monster standing there, if any (the shared +// preamble of the C monster-affecting zap cases). +func (g *RogueGame) zapRayMonster() *Monster { + p := &g.Player + + y := p.Pos.Y + + x := p.Pos.X + for stepOk(g.Level.VisibleChar(y, x)) { + y += g.Delta.Y + x += g.Delta.X + } + + return g.Level.MonsterAt(y, x) +} + +// zapVictim is zapRayMonster plus the flytrap release the C code does +// before the invisibility-family effects. +func (g *RogueGame) zapVictim() *Monster { + tp := g.zapRayMonster() + if tp != nil && tp.Type == 'F' { + g.Player.Flags.Clear(Held) + } + + return tp +} + +// The per-wand effect handlers, dispatched through gameData.zapHandlers. +// Each is one case of the C do_zap switch; returning false aborts the +// zap without using a charge. + +func (g *RogueGame) zapLight(*Object) bool { + // Reddy Kilowatt wand. Light up the room + p := &g.Player + + g.Items.Sticks[WandLight].Know = true + if p.Room.Flags.Has(Gone) { + g.msg("the corridor glows and then fades") + } else { + p.Room.Flags.Clear(Dark) + // Light the room and put the player back up + g.enterRoom(p.Pos) + g.addmsgf("the room is lit") + + if !g.Options.Terse { + g.addmsgf(" by a shimmering %s light", g.pickColor("blue")) + } + + g.endmsg() + } + + return true +} + +func (g *RogueGame) zapDrainLife(*Object) bool { + // take away 1/2 of hero's hit points, then take it away evenly + // from the monsters in the room (or next to hero if he is in a + // passage) + if g.Player.Stats.HP < 2 { + g.msg("you are too weak to use it") + + return false + } + + g.drain() + + return true +} + +func (g *RogueGame) zapInvisibility(*Object) bool { + if tp := g.zapVictim(); tp != nil { + tp.Flags.Set(Invisible) + + if g.canSee(tp.Pos.Y, tp.Pos.X) { + g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.OldCh) + } + } + + return true +} + +func (g *RogueGame) zapPolymorph(*Object) bool { + tp := g.zapVictim() + if tp == nil { + return true + } + + y, x := tp.Pos.Y, tp.Pos.X + + pp := tp.Pack + g.Level.RemoveMonster(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 := 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 + } + + return true +} + +func (g *RogueGame) zapCancellation(*Object) bool { + if tp := g.zapVictim(); tp != nil { + tp.Flags.Set(Cancelled) + tp.Flags.Clear(Invisible | CanConfuse) + + tp.Disguise = tp.Type + if g.seeMonst(tp) { + g.mvaddch(tp.Pos.Y, tp.Pos.X, tp.Disguise) + } + } + + return true +} + +func (g *RogueGame) zapTeleport(obj *Object) bool { + p := &g.Player + + tp := g.zapVictim() + if tp == nil { + return true + } + + var newPos Coord + + if obj.WandKind() == WandTeleportAway { + for { + newPos, _ = g.findFloor(true) + if newPos != p.Pos { + break + } + } + } else { + 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) + + return true +} + +func (g *RogueGame) zapMagicMissile(*Object) bool { + p := &g.Player + + g.Items.Sticks[WandMagicMissile].Know = true + bolt := newObject() + bolt.Kind = KindGold // C set o_type='*': draws a '*' and is not a weapon + 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") //nolint:misspell // C's spelling, kept faithfully + } else { + g.msg("the missle vanishes with a puff of smoke") //nolint:misspell // C's spelling + } + + return true +} + +func (g *RogueGame) zapSpeed(obj *Object) bool { + if tp := g.zapRayMonster(); tp != nil { + if obj.WandKind() == WandHasteMonster { + if tp.On(Slowed) { + tp.Flags.Clear(Slowed) + } else { + tp.Flags.Set(Hasted) + } + } else { + if tp.On(Hasted) { + tp.Flags.Clear(Hasted) + } else { + tp.Flags.Set(Slowed) + } + + tp.Turn = true + } + + g.Delta.Y = tp.Pos.Y + g.Delta.X = tp.Pos.X + g.runTo(g.Delta) + } + + return true +} + +func (g *RogueGame) zapBolt(obj *Object) bool { + var name string + + switch obj.WandKind() { + case WandLightning: + name = "bolt" + case WandFire: + name = "flame" + default: + name = "ice" + } + + g.fireBolt(g.Player.Pos, &g.Delta, name) + g.Items.Sticks[obj.Which].Know = true + + return true +} + // drain does the drain-hit-points-from-player schtick (sticks.c drain). func (g *RogueGame) drain() { p := &g.Player diff --git a/game/tables.go b/game/tables.go index 665aaed..2f9b2ac 100644 --- a/game/tables.go +++ b/game/tables.go @@ -132,6 +132,11 @@ type gameData struct { // the cases of the scrolls.c read_scroll switch. The handler gets // the scroll being read (the identify family needs its subtype). readHandlers [NumScrollTypes]func(g *RogueGame, obj *Object) + + // zapHandlers dispatches each wand kind to its effect method: the + // cases of the sticks.c do_zap switch. A false return aborts the + // zap without using a charge (drain life on a too-weak hero). + zapHandlers [NumWandTypes]func(g *RogueGame, obj *Object) bool } // ripWall is the repeated blank wall line of the tombstone art. @@ -635,6 +640,22 @@ func newGameData() *gameData { ScrollAggravateMonsters: (*RogueGame).readAggravateMonsters, ScrollProtectArmor: (*RogueGame).readProtectArmor, }, + + zapHandlers: [NumWandTypes]func(*RogueGame, *Object) bool{ + WandLight: (*RogueGame).zapLight, + WandInvisibility: (*RogueGame).zapInvisibility, + WandLightning: (*RogueGame).zapBolt, + WandFire: (*RogueGame).zapBolt, + WandCold: (*RogueGame).zapBolt, + WandPolymorph: (*RogueGame).zapPolymorph, + WandMagicMissile: (*RogueGame).zapMagicMissile, + WandHasteMonster: (*RogueGame).zapSpeed, + WandSlowMonster: (*RogueGame).zapSpeed, + WandDrainLife: (*RogueGame).zapDrainLife, + WandTeleportAway: (*RogueGame).zapTeleport, + WandTeleportTo: (*RogueGame).zapTeleport, + WandCancellation: (*RogueGame).zapCancellation, + }, } } From 1a25beead80f574aad9679790da35eaf8c173776 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 02:49:37 +0200 Subject: [PATCH 04/32] Decompose fireBolt (refactor step 7) The fire_bolt loop splits into boltDirChar, boltBounces, boltStrikesMonster, and boltStrikesHero; loop state (hitHero/changed/ used) stays in fireBolt. Effect order and RNG calls unchanged. --- game/sticks.go | 181 ++++++++++++++++++++++++++++--------------------- 1 file changed, 103 insertions(+), 78 deletions(-) diff --git a/game/sticks.go b/game/sticks.go index 4d1d816..4302294 100644 --- a/game/sticks.go +++ b/game/sticks.go @@ -326,21 +326,7 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { bolt.DPlus = 0 g.Items.Weapons[WeaponFlame].Name = name - var dirch byte - - switch dir.Y + dir.X { - case 0: - dirch = '/' - case 1, -1: - if dir.Y == 0 { - dirch = '-' - } else { - dirch = '|' - } - case 2, -2: - dirch = '\\' - } - + dirch := boltDirChar(*dir) pos := start hitHero := !fromHero used := false @@ -351,22 +337,9 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { pos.Y += dir.Y pos.X += dir.X 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 - // fires at the wall the door is in, it would otherwise loop - // infinitely - if p.Pos != pos { - bounce = true - } - case '|', '-', ' ': - bounce = true - } - - if bounce { + if boltBounces(ch, p.Pos, pos) { if !changed { hitHero = !hitHero } @@ -384,57 +357,11 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { 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.addmsgf("the flame bounces") - - if !g.Options.Terse { - g.addmsgf(" off the dragon") - } - - g.endmsg() - } else { - g.hitMonster(pos, bolt) - } - } else if ch != 'M' || tp.Disguise == 'M' { - if fromHero { - g.runTo(pos) - } - - if g.Options.Terse { - g.msg("%s misses", name) - } else { - g.msg("the %s whizzes past %s", name, g.setMname(tp)) - } - } + used = g.boltStrikesMonster(tp, bolt, pos, ch, name, fromHero) } 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 { - g.death('b') - } else { - g.death(g.Level.MonsterAt(start.Y, start.X).Type) - } - } - - used = true - - if g.Options.Terse { - g.msg("the %s hits", name) - } else { - g.msg("you are hit by the %s", name) - } - } else { - g.msg("the %s whizzes by you", name) - } + used = g.boltStrikesHero(start, name, fromHero) } g.mvaddch(pos.Y, pos.X, dirch) @@ -446,6 +373,104 @@ func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { } } +// boltDirChar picks the character a traveling bolt is drawn with for its +// direction (the dirch switch of sticks.c fire_bolt). +func boltDirChar(dir Coord) byte { + switch dir.Y + dir.X { + case 0: + return '/' + case 1, -1: + if dir.Y == 0 { + return '-' + } + + return '|' + case 2, -2: + return '\\' + } + + return 0 // unreachable for the eight legal directions, as in C +} + +// boltBounces reports whether a bolt bounces off this spot: walls, and +// any door except the one the hero stands on (which would otherwise loop +// infinitely, per the C comment in fire_bolt). +func boltBounces(ch byte, heroPos, pos Coord) bool { + switch ch { + case Door: + return heroPos != pos + case '|', '-', ' ': + return true + } + + return false +} + +// boltStrikesMonster resolves a bolt arriving on a monster's square (the +// monster arm of the fire_bolt loop). It reports whether the bolt was +// used up. +func (g *RogueGame) boltStrikesMonster(tp *Monster, bolt *Object, pos Coord, ch byte, name string, fromHero bool) bool { + tp.OldCh = g.Level.Char(pos.Y, pos.X) + if !g.saveThrow(VsMagic, &tp.Stats) { + bolt.Pos = pos + + if tp.Type == 'D' && name == "flame" { + g.addmsgf("the flame bounces") + + if !g.Options.Terse { + g.addmsgf(" off the dragon") + } + + g.endmsg() + } else { + g.hitMonster(pos, bolt) + } + + return true + } + + if ch != 'M' || tp.Disguise == 'M' { + if fromHero { + g.runTo(pos) + } + + if g.Options.Terse { + g.msg("%s misses", name) + } else { + g.msg("the %s whizzes past %s", name, g.setMname(tp)) + } + } + + return false +} + +// boltStrikesHero resolves a bolt arriving on the hero (the hero arm of +// the fire_bolt loop). It reports whether the bolt was used up. +func (g *RogueGame) boltStrikesHero(start Coord, name string, fromHero bool) bool { + p := &g.Player + if g.save(VsMagic) { + g.msg("the %s whizzes by you", name) + + return false + } + + if p.Stats.HP -= g.roll(6, 6); p.Stats.HP <= 0 { + if fromHero { + g.death('b') + } else { + g.death(g.Level.MonsterAt(start.Y, start.X).Type) + } + } + + if g.Options.Terse { + g.msg("the %s hits", name) + } else { + g.msg("you are hit by the %s", name) + } + + return true +} + // fixStick sets up a new wand or staff (sticks.c fix_stick). func (g *RogueGame) fixStick(cur *Object) { if g.Items.WandType[cur.Which] == staffName { From ebe477ba2875cbe44fa39993dc916dc5ab71fc5a Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 02:53:09 +0200 Subject: [PATCH 05/32] Decompose remaining effects-file hot spots (refactor step 7) drain gains drainReaches; zapSpeed gains hasteTarget/slowTarget; readHoldMonster gains holdMonstersNear; readCreateMonster gains createMonsterSpot; revealSpot splits into revealChar/revealWall/ revealSolid/revealFloor; turnSee gains showSensed. potions.go, scrolls.go, and sticks.go are complexity-clean. Behavior and RNG call order unchanged. --- game/potions.go | 41 +++++++----- game/scrolls.go | 163 ++++++++++++++++++++++++++++++------------------ game/sticks.go | 71 ++++++++++++++------- 3 files changed, 176 insertions(+), 99 deletions(-) diff --git a/game/potions.go b/game/potions.go index 8d39e2b..70c2e09 100644 --- a/game/potions.go +++ b/game/potions.go @@ -298,22 +298,8 @@ func (g *RogueGame) turnSee(turnOff bool) bool { if !canSee { g.addch(mp.OldCh) } - } else { - if !canSee { - g.standout() - } - - if !g.Player.On(Hallucinating) { - g.addch(mp.Type) - } else { - g.addch(g.randomMonsterLetter()) - } - - if !canSee { - g.standend() - - addNew = true - } + } else if g.showSensed(mp, canSee) { + addNew = true } } @@ -326,6 +312,29 @@ func (g *RogueGame) turnSee(turnOff bool) bool { return addNew } +// showSensed draws one monster for monster sense, standout when it is +// otherwise invisible; it reports whether the monster was newly revealed +// (the turn-on arm of the C turn_see loop). +func (g *RogueGame) showSensed(mp *Monster, canSee bool) bool { + if !canSee { + g.standout() + } + + if !g.Player.On(Hallucinating) { + g.addch(mp.Type) + } else { + g.addch(g.randomMonsterLetter()) + } + + if !canSee { + g.standend() + + return true + } + + return false +} + // seenStairs reports whether the player has seen the stairs (potions.c // seen_stairs). func (g *RogueGame) seenStairs() bool { diff --git a/game/scrolls.go b/game/scrolls.go index fb03628..75303cc 100644 --- a/game/scrolls.go +++ b/game/scrolls.go @@ -59,6 +59,31 @@ func (g *RogueGame) readEnchantArmor(*Object) { func (g *RogueGame) readHoldMonster(*Object) { // Hold monster scroll. Stop all monsters within two spaces from // chasing after the hero. + held := g.holdMonstersNear() + + if held > 0 { + g.addmsgf("the monster") + + if held > 1 { + g.addmsgf("s around you") + } + + g.addmsgf(" freeze") + + if held == 1 { + g.addmsgf("s") + } + + g.endmsg() + g.Items.Scrolls[ScrollHoldMonster].Know = true + } else { + g.msg("you feel a strange sense of loss") + } +} + +// holdMonstersNear freezes every awake monster within two spaces of the +// hero and reports how many froze (the scan of the C S_HOLD case). +func (g *RogueGame) holdMonstersNear() int { p := &g.Player held := 0 @@ -81,24 +106,7 @@ func (g *RogueGame) readHoldMonster(*Object) { } } - if held > 0 { - g.addmsgf("the monster") - - if held > 1 { - g.addmsgf("s around you") - } - - g.addmsgf(" freeze") - - if held == 1 { - g.addmsgf("s") - } - - g.endmsg() - g.Items.Scrolls[ScrollHoldMonster].Know = true - } else { - g.msg("you feel a strange sense of loss") - } + return held } func (g *RogueGame) readSleep(*Object) { @@ -113,6 +121,19 @@ func (g *RogueGame) readSleep(*Object) { func (g *RogueGame) readCreateMonster(*Object) { // Create a monster: first look in a circle around him, next try // his room, otherwise give up + mp, ok := g.createMonsterSpot() + if !ok { + g.msg("you hear a faint cry of anguish in the distance") + } else { + tp := &Monster{} + g.newMonster(tp, g.randMonster(false), mp) + } +} + +// createMonsterSpot reservoir-samples a legal spot around the hero for a +// created monster; ok is false when every neighbor is blocked (the scan +// of the C S_CREATE case). +func (g *RogueGame) createMonsterSpot() (Coord, bool) { p := &g.Player i := 0 @@ -139,12 +160,7 @@ func (g *RogueGame) readCreateMonster(*Object) { } } - if i == 0 { - g.msg("you hear a faint cry of anguish in the distance") - } else { - tp := &Monster{} - g.newMonster(tp, g.randMonster(false), mp) - } + return mp, i != 0 } func (g *RogueGame) readIdentify(obj *Object) { @@ -166,45 +182,41 @@ func (g *RogueGame) readMagicMapping(*Object) { } } -// revealSpot uncovers one map cell for magic mapping: secret doors and -// passages become real, hidden traps show, and the visible character is -// drawn (the loop body of the C SCR_MAP case). +// revealSpot uncovers one map cell for magic mapping and draws it (the +// loop body of the C SCR_MAP case). func (g *RogueGame) revealSpot(y, x int) { pp := g.Level.At(y, x) + + ch := revealChar(pp) + if ch != ' ' { + if tp := pp.Monst; tp != nil { + tp.OldCh = ch + if !g.Player.On(SenseMonsters) { + g.mvaddch(y, x, ch) + } + } else { + g.mvaddch(y, x, ch) + } + } +} + +// revealChar decides what magic mapping shows at a cell, making secret +// doors, hidden passages, and hidden traps real as a side effect; ' ' +// means show nothing (the switch of the C SCR_MAP loop). +func revealChar(pp *Place) byte { ch := pp.Ch pass := false switch ch { case Door, Stairs: case '-', '|': - if !pp.Flags.Has(FReal) { - ch = Door - pp.Ch = Door - pp.Flags.Set(FReal) - } + ch = revealWall(pp) case ' ': - if pp.Flags.Has(FReal) { - // def: hidden things in walls stay hidden - if pp.Flags.Has(FPassage) { - pass = true - } else { - ch = ' ' - } - } else { - pp.Flags.Set(FReal) - pp.Ch = Passage - pass = true - } + pass = revealSolid(pp) case Passage: pass = true case Floor: - if pp.Flags.Has(FReal) { - ch = ' ' - } else { - ch = Trap - pp.Ch = Trap - pp.Flags.Set(FSeen | FReal) - } + ch = revealFloor(pp) default: if pp.Flags.Has(FPassage) { pass = true @@ -223,16 +235,47 @@ func (g *RogueGame) revealSpot(y, x int) { ch = Passage } - if ch != ' ' { - if tp := pp.Monst; tp != nil { - tp.OldCh = ch - if !g.Player.On(SenseMonsters) { - g.mvaddch(y, x, ch) - } - } else { - g.mvaddch(y, x, ch) - } + return ch +} + +// revealWall handles a wall cell for magic mapping: a secret door +// becomes a real door (the '-'/'|' arm). +func revealWall(pp *Place) byte { + if !pp.Flags.Has(FReal) { + pp.Ch = Door + pp.Flags.Set(FReal) + + return Door } + + return pp.Ch +} + +// revealSolid handles a blank cell for magic mapping, reporting whether +// it is passage: hidden passages become real; hidden things in walls +// stay hidden (the ' ' arm). +func revealSolid(pp *Place) bool { + if pp.Flags.Has(FReal) { + return pp.Flags.Has(FPassage) + } + + pp.Flags.Set(FReal) + pp.Ch = Passage + + return true +} + +// revealFloor handles a floor cell for magic mapping: a hidden trap +// becomes a real, seen trap; real floor shows nothing (the FLOOR arm). +func revealFloor(pp *Place) byte { + if pp.Flags.Has(FReal) { + return ' ' + } + + pp.Ch = Trap + pp.Flags.Set(FSeen | FReal) + + return Trap } func (g *RogueGame) readFoodDetection(*Object) { diff --git a/game/sticks.go b/game/sticks.go index 4302294..5675d16 100644 --- a/game/sticks.go +++ b/game/sticks.go @@ -229,31 +229,46 @@ func (g *RogueGame) zapMagicMissile(*Object) bool { } func (g *RogueGame) zapSpeed(obj *Object) bool { - if tp := g.zapRayMonster(); tp != nil { - if obj.WandKind() == WandHasteMonster { - if tp.On(Slowed) { - tp.Flags.Clear(Slowed) - } else { - tp.Flags.Set(Hasted) - } - } else { - if tp.On(Hasted) { - tp.Flags.Clear(Hasted) - } else { - tp.Flags.Set(Slowed) - } - - tp.Turn = true - } - - g.Delta.Y = tp.Pos.Y - g.Delta.X = tp.Pos.X - g.runTo(g.Delta) + tp := g.zapRayMonster() + if tp == nil { + return true } + if obj.WandKind() == WandHasteMonster { + hasteTarget(tp) + } else { + slowTarget(tp) + } + + g.Delta.Y = tp.Pos.Y + g.Delta.X = tp.Pos.X + g.runTo(g.Delta) + return true } +// hasteTarget cancels a slow or applies a haste (the WS_HASTE_M arm of +// do_zap). +func hasteTarget(tp *Monster) { + if tp.On(Slowed) { + tp.Flags.Clear(Slowed) + } else { + tp.Flags.Set(Hasted) + } +} + +// slowTarget cancels a haste or applies a slow (the WS_SLOW_M arm of +// do_zap). +func slowTarget(tp *Monster) { + if tp.On(Hasted) { + tp.Flags.Clear(Hasted) + } else { + tp.Flags.Set(Slowed) + } + + tp.Turn = true +} + func (g *RogueGame) zapBolt(obj *Object) bool { var name string @@ -286,9 +301,7 @@ func (g *RogueGame) drain() { 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 && - &g.Level.Passages[*g.Level.FlagsAt(mp.Pos.Y, mp.Pos.X)&FPassNum] == p.Room) { + if g.drainReaches(mp, corp, inpass) { drainee = append(drainee, mp) } } @@ -312,6 +325,18 @@ func (g *RogueGame) drain() { } } +// drainReaches reports whether the drain-life wand reaches this monster: +// the hero's room, the passage behind the door he stands on, or — when +// he is in a passage — a door of that same passage (the drainee +// condition of sticks.c drain). +func (g *RogueGame) drainReaches(mp *Monster, corp *Room, inpass bool) bool { + p := &g.Player + + return mp.Room == p.Room || mp.Room == corp || + (inpass && g.Level.Char(mp.Pos.Y, mp.Pos.X) == Door && + &g.Level.Passages[*g.Level.FlagsAt(mp.Pos.Y, mp.Pos.X)&FPassNum] == p.Room) +} + // fireBolt fires a bolt in a given direction from a specific starting // place (sticks.c fire_bolt). func (g *RogueGame) fireBolt(start Coord, dir *Coord, name string) { From a20f50065523a16284d8d00b78ecd044daa36328 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 02:58:44 +0200 Subject: [PATCH 06/32] Decompose fight.go: hit-handler table, weapon/armor helpers (step 7) The monster special-power switch in attack becomes gameData.hitHandlers (indexed by monster letter); attack splits into monsterHit/monsterMiss; fight gains revealXeroc and heroHits; rollAttacks gains weaponAttack, wieldedRingBonus, and defenderArmor; killed gains killedSpecial. fight.go is complexity-clean. Behavior and RNG call order unchanged. --- game/fight.go | 691 ++++++++++++++++++++++++++++--------------------- game/tables.go | 16 ++ 2 files changed, 416 insertions(+), 291 deletions(-) diff --git a/game/fight.go b/game/fight.go index 53c6caf..aace02a 100644 --- a/game/fight.go +++ b/game/fight.go @@ -48,66 +48,81 @@ func (g *RogueGame) fight(mp Coord, weap *Object, thrown bool) bool { g.Count = 0 g.Quiet = 0 g.runTo(mp) - // Let him know it was really a xeroc (if it was one). - 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, g.randomMonsterLetter()) - } - g.msg("%s", g.chooseStr("heavy! That's a nasty critter!", - "wait! That's a xeroc!")) - - if !thrown { - return false - } + if g.revealXeroc(tp) && !thrown { + return false } mname := g.setMname(tp) - didHit := false g.HasHit = g.Options.Terse && !g.ToDeath if g.rollAttacks(&p.Creature, &tp.Creature, weap, thrown) { - didHit = false + g.heroHits(tp, mname, weap, thrown) - 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 { - g.bounce(weap, mname, g.Options.Terse) - } else { - g.miss("", mname, g.Options.Terse) - } + return true } - return didHit + if thrown { + g.bounce(weap, mname, g.Options.Terse) + } else { + g.miss("", mname, g.Options.Terse) + } + + return false } -// attack has the monster attack the player (fight.c attack). removed +// revealXeroc lets him know it was really a xeroc (if it was one); it +// reports whether one was unmasked (the X block of fight.c fight). +func (g *RogueGame) revealXeroc(tp *Monster) bool { + p := &g.Player + if tp.Type != 'X' || tp.Disguise == 'X' || p.On(Blind) { + return false + } + + tp.Disguise = 'X' + if p.On(Hallucinating) { + 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!")) + + return true +} + +// heroHits lands the hero's blow on a monster: messages, the confusing +// touch, and the kill check (the hit arm of fight.c fight). +func (g *RogueGame) heroHits(tp *Monster, mname string, weap *Object, thrown bool) { + p := &g.Player + confused := false + + if thrown { + g.thunk(weap, mname, g.Options.Terse) + } else { + g.hit("", mname, g.Options.Terse) + } + + if p.On(CanConfuse) { + confused = 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 confused && !p.On(Blind) { + g.msg("%s appears confused", mname) + } +} + +// attack has the monster attack the player (fight.c attack). The result // reports that the monster took itself off the level during its own // attack (the C -1 return). -func (g *RogueGame) attack(mp *Monster) (removed bool) { +func (g *RogueGame) attack(mp *Monster) bool { p := &g.Player // Since this is an attack, stop running and any healing that was // going on at the time. @@ -129,185 +144,12 @@ func (g *RogueGame) attack(mp *Monster) (removed bool) { mname := g.setMname(mp) oldhp := p.Stats.HP + removed := false if g.rollAttacks(&mp.Creature, &p.Creature, nil, false) { - if mp.Type != 'I' { - if g.HasHit { - 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 ... - } else if !g.Kamikaze { - oldhp -= p.Stats.HP - 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': - // If an aquator hits, you can lose armor class. - g.rustArmor(p.CurArmor) - case 'I': - // The ice monster freezes you - p.Flags.Clear(Awake) - - if g.NoCommand == 0 { - g.addmsgf("you are frozen") - - if !g.Options.Terse { - g.addmsgf(" by the %s", mname) - } - - g.endmsg() - } - - g.NoCommand += g.rnd(2) + 2 - if g.NoCommand > BoreLevel { - g.death('h') - } - case 'R': - // Rattlesnakes have poisonous bites - if !g.save(VsPoison) { - if !p.IsWearing(RingSustainStrength) { - g.changeStrength(-1) - - if !g.Options.Terse { - g.msg("you feel a bite in your leg and now feel weaker") - } else { - g.msg("a bite has weakened you") - } - } else if !g.ToDeath { - if !g.Options.Terse { - g.msg("a bite momentarily weakens you") - } else { - g.msg("bite has no effect") - } - } - } - case 'W', 'V': - // Wraiths might drain energy levels, and Vampires can - // steal max_hp - chance := 30 - 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 = g.data.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') - } - 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") - } - case 'N': - // 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] && - g.isMagic(obj) { - if nobj++; g.rnd(nobj) == 0 { - steal = obj - } - } - } - - 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.inventoryName(steal, true)) - } - } - } - } else if mp.Type != 'I' { - if g.HasHit { - 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) + removed = g.monsterHit(mp, mname, oldhp) + } else { + g.monsterMiss(mp, mname) } if g.Options.FightFlush && !g.ToDeath { @@ -320,6 +162,235 @@ func (g *RogueGame) attack(mp *Monster) (removed bool) { return removed } +// monsterHit lands a monster's blow on the hero: messages, death and +// to-death bookkeeping, then the monster's special power (the hit arm +// of fight.c attack). It reports whether the monster removed itself. +func (g *RogueGame) monsterHit(mp *Monster, mname string, oldhp int) bool { + p := &g.Player + if mp.Type != 'I' { + if g.HasHit { + 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 ... + } else if !g.Kamikaze { + oldhp -= p.Stats.HP + if oldhp > g.MaxHit { + g.MaxHit = oldhp + } + + if p.Stats.HP <= g.MaxHit { + g.ToDeath = false + } + } + + if !mp.On(Cancelled) { + if h := g.data.hitHandlers[mp.Type-'A']; h != nil { + return h(g, mp, mname) + } + } + + return false +} + +// monsterMiss handles a monster's whiffed swing (the miss arm of +// fight.c attack); ice monsters miss silently. +func (g *RogueGame) monsterMiss(mp *Monster, mname string) { + if mp.Type == 'I' { + return + } + + p := &g.Player + + if g.HasHit { + 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) +} + +// The monster special-power handlers, dispatched through +// gameData.hitHandlers when an uncancelled monster's hit lands. Each is +// one case of the C attack switch; a true return means the monster +// removed itself from the level. + +func (g *RogueGame) hitAquator(*Monster, string) bool { + // If an aquator hits, you can lose armor class. + g.rustArmor(g.Player.CurArmor) + + return false +} + +func (g *RogueGame) hitIceMonster(_ *Monster, mname string) bool { + // The ice monster freezes you + g.Player.Flags.Clear(Awake) + + if g.NoCommand == 0 { + g.addmsgf("you are frozen") + + if !g.Options.Terse { + g.addmsgf(" by the %s", mname) + } + + g.endmsg() + } + + g.NoCommand += g.rnd(2) + 2 + if g.NoCommand > BoreLevel { + g.death('h') + } + + return false +} + +func (g *RogueGame) hitRattlesnake(*Monster, string) bool { + // Rattlesnakes have poisonous bites + if g.save(VsPoison) { + return false + } + + if !g.Player.IsWearing(RingSustainStrength) { + g.changeStrength(-1) + g.msg("%s", g.chooseTerse("a bite has weakened you", + "you feel a bite in your leg and now feel weaker")) + } else if !g.ToDeath { + g.msg("%s", g.chooseTerse("bite has no effect", + "a bite momentarily weakens you")) + } + + return false +} + +func (g *RogueGame) hitLifeDrainer(mp *Monster, _ string) bool { + // Wraiths might drain energy levels, and Vampires can steal max_hp + p := &g.Player + + chance := 30 + if mp.Type == 'W' { + chance = 15 + } + + if g.rnd(100) >= chance { + return false + } + + 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 = g.data.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") + + return false +} + +func (g *RogueGame) hitFlytrap(*Monster, string) bool { + // Venus Flytrap stops the poor guy from moving + p := &g.Player + 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') + } + + return false +} + +func (g *RogueGame) hitLeprechaun(mp *Monster, _ string) bool { + // Leprechaun steals some gold + p := &g.Player + 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) + + if p.Purse != lastpurse { + g.msg("your purse feels lighter") + } + + return true +} + +func (g *RogueGame) hitNymph(mp *Monster, _ string) bool { + // Nymphs steal a magic item; look through the pack and pick out one + // we like. + p := &g.Player + + 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] && + g.isMagic(obj) { + if nobj++; g.rnd(nobj) == 0 { + steal = obj + } + } + } + + if steal == nil { + return false + } + + g.removeMon(mp.Pos, g.Level.MonsterAt(mp.Pos.Y, mp.Pos.X), false) + g.leavePack(steal, false, false) + g.msg("she stole %s!", g.inventoryName(steal, true)) + + return true +} + // swing returns true if the swing hits (fight.c swing). func (g *RogueGame) swing(atLvl, opArm, wplus int) bool { res := g.rnd(20) @@ -330,7 +401,6 @@ func (g *RogueGame) swing(atLvl, opArm, wplus int) bool { // rollAttacks rolls several attacks (fight.c roll_em). func (g *RogueGame) rollAttacks(thatt, thdef *Creature, weap *Object, hurl bool) bool { - p := &g.Player att := &thatt.Stats def := &thdef.Stats @@ -342,34 +412,7 @@ func (g *RogueGame) rollAttacks(thatt, thdef *Creature, weap *Object, hurl bool) if weap == nil { attacks = att.Dmg } else { - hplus = weap.HPlus - - dplus = weap.DPlus - if weap == p.CurWeapon { - if p.IsRing(Left, RingIncreaseDamage) { - dplus += p.CurRing[Left].Bonus - } 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 && - WeaponKind(p.CurWeapon.Which) == weap.Launch { - attacks = weap.HurlDmg - hplus += p.CurWeapon.HPlus - dplus += p.CurWeapon.DPlus - } else if weap.Launch < 0 { - attacks = weap.HurlDmg - } - } + attacks, hplus, dplus = g.weaponAttack(weap, hurl) } // If the creature being attacked is not running (asleep or held) then // the attacker gets a plus four bonus to hit. @@ -377,21 +420,7 @@ func (g *RogueGame) rollAttacks(thatt, thdef *Creature, weap *Object, hurl bool) 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 - } - } - + defArm := g.defenderArmor(thdef) didHit := false for _, atk := range attacks { @@ -410,6 +439,78 @@ func (g *RogueGame) rollAttacks(thatt, thdef *Creature, weap *Object, hurl bool) return didHit } +// weaponAttack picks the dice and to-hit/damage bonuses a weapon swings +// with: ring bonuses when wielded, and launcher pairing for hurled +// missiles (the weapon preamble of fight.c roll_em). +func (g *RogueGame) weaponAttack(weap *Object, hurl bool) (DiceSpec, int, int) { + p := &g.Player + + hplus := weap.HPlus + + dplus := weap.DPlus + if weap == p.CurWeapon { + hplus, dplus = g.wieldedRingBonus(hplus, dplus) + } + + attacks := weap.Damage + if hurl { + if weap.Flags.Has(Missile) && p.CurWeapon != nil && + WeaponKind(p.CurWeapon.Which) == weap.Launch { + attacks = weap.HurlDmg + hplus += p.CurWeapon.HPlus + dplus += p.CurWeapon.DPlus + } else if weap.Launch < 0 { + attacks = weap.HurlDmg + } + } + + return attacks, hplus, dplus +} + +// wieldedRingBonus folds damage and dexterity ring bonuses into the +// wielded weapon's to-hit/damage pluses (fight.c roll_em). +func (g *RogueGame) wieldedRingBonus(hplus, dplus int) (int, int) { + p := &g.Player + if p.IsRing(Left, RingIncreaseDamage) { + dplus += p.CurRing[Left].Bonus + } 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 + } + + return hplus, dplus +} + +// defenderArmor computes the defender's effective armor class: worn +// armor and protection rings when the hero defends (the def_arm +// computation of fight.c roll_em). +func (g *RogueGame) defenderArmor(thdef *Creature) int { + p := &g.Player + def := &thdef.Stats + + 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 + } + } + + return defArm +} + // cAtoi parses a leading integer like C atoi: trailing non-digits are // ignored rather than an error. func cAtoi(s string) int { @@ -566,30 +667,7 @@ func (g *RogueGame) killed(tp *Monster, pr bool) { p := &g.Player p.Stats.Exp += tp.Stats.Exp - // If the monster was a venus flytrap, un-hold him - switch tp.Type { - case 'F': - p.Flags.Clear(Held) - p.VfHit = 0 - g.Monsters['F'-'A'].Stats.Dmg = dice("000x0") - case 'L': - pos, ok := g.fallpos(tp.Pos) - 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) - } - } + g.killedSpecial(tp) // Get rid of the monster. mname := g.setMname(tp) g.removeMon(tp.Pos, tp, true) @@ -616,6 +694,37 @@ func (g *RogueGame) killed(tp *Monster, pr bool) { } } +// killedSpecial handles deaths with side effects: a flytrap releases its +// grip and a leprechaun drops its gold (the switch of fight.c killed). +func (g *RogueGame) killedSpecial(tp *Monster) { + p := &g.Player + + // If the monster was a venus flytrap, un-hold him + switch tp.Type { + case 'F': + p.Flags.Clear(Held) + p.VfHit = 0 + g.Monsters['F'-'A'].Stats.Dmg = dice("000x0") + case 'L': + pos, ok := g.fallpos(tp.Pos) + 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) + } + } +} + // flushType flushes typeahead for the fight_flush option (mach_dep.c // flush_type / curses flushinp). func (g *RogueGame) flushType() { diff --git a/game/tables.go b/game/tables.go index 2f9b2ac..2d66273 100644 --- a/game/tables.go +++ b/game/tables.go @@ -137,6 +137,11 @@ type gameData struct { // cases of the sticks.c do_zap switch. A false return aborts the // zap without using a charge (drain life on a too-weak hero). zapHandlers [NumWandTypes]func(g *RogueGame, obj *Object) bool + + // hitHandlers dispatches a monster's special power when its hit + // lands, indexed by monster letter - 'A': the cases of the fight.c + // attack switch. A true return means the monster removed itself. + hitHandlers [26]func(g *RogueGame, mp *Monster, mname string) bool } // ripWall is the repeated blank wall line of the tombstone art. @@ -656,6 +661,17 @@ func newGameData() *gameData { WandTeleportTo: (*RogueGame).zapTeleport, WandCancellation: (*RogueGame).zapCancellation, }, + + hitHandlers: [26]func(*RogueGame, *Monster, string) bool{ + 0: (*RogueGame).hitAquator, // 'A' - 'A' + 'F' - 'A': (*RogueGame).hitFlytrap, + 'I' - 'A': (*RogueGame).hitIceMonster, + 'L' - 'A': (*RogueGame).hitLeprechaun, + 'N' - 'A': (*RogueGame).hitNymph, + 'R' - 'A': (*RogueGame).hitRattlesnake, + 'V' - 'A': (*RogueGame).hitLifeDrainer, + 'W' - 'A': (*RogueGame).hitLifeDrainer, + }, } } From 4a248eb392dfc59295799fc587593ff84212aab4 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:03:50 +0200 Subject: [PATCH 07/32] Decompose look and promptDirection (refactor step 7) look's nine-square scan splits into lookAround/lookCell with a lookScan state struct and guard helpers (lookSkips, lookForeignPassage, lookDiagonalBlocked, lookCellChar, lookShow, lookRunCheck, atRunEdge); promptDirection gains deltaFor and confuseDirection. misc.go is complexity-clean. Behavior and RNG call order unchanged. --- game/misc.go | 436 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 262 insertions(+), 174 deletions(-) diff --git a/game/misc.go b/game/misc.go index f9ad13f..75a01df 100644 --- a/game/misc.go +++ b/game/misc.go @@ -4,11 +4,23 @@ package game // and small utilities. call_it arrives with the scroll/potion phase (it // needs the get_str line editor). +// lookScan carries the state of one look() glance while it examines the +// nine squares around the hero. +type lookScan struct { + hero Coord + pch byte // map character under the hero + pfl PlaceFlags // map flags under the hero + wakeup bool + doorStop bool // door-stop checking applies (mid-run) + sy, sx, ey, ex int + sumhero, diffhero int + passcount int +} + // look takes a quick glance all around the player (misc.c look). func (g *RogueGame) look(wakeup bool) { p := &g.Player hero := p.Pos - passcount := 0 rp := p.Room if g.Oldpos != hero { @@ -17,151 +29,28 @@ func (g *RogueGame) look(wakeup bool) { g.Oldrp = rp } - ey := hero.Y + 1 - ex := hero.X + 1 - sx := hero.X - 1 - sy := hero.Y - 1 + s := lookScan{ + hero: hero, + wakeup: wakeup, + sy: hero.Y - 1, + sx: hero.X - 1, + ey: hero.Y + 1, + ex: hero.X + 1, + } - sumhero, diffhero := 0, 0 - if g.DoorStop && !g.Firstmove && g.Running { - sumhero = hero.Y + hero.X - diffhero = hero.Y - hero.X + s.doorStop = g.DoorStop && !g.Firstmove + if s.doorStop && g.Running { + s.sumhero = hero.Y + hero.X + s.diffhero = hero.Y - hero.X } pp := g.Level.At(hero.Y, hero.X) - pch := pp.Ch - pfl := pp.Flags + s.pch = pp.Ch + s.pfl = pp.Flags - for y := sy; y <= ey; y++ { - if y <= 0 || y >= NumLines-1 { - continue - } + g.lookAround(&s) - for x := sx; x <= ex; x++ { - if x < 0 || x >= NumCols { - continue - } - - if !p.On(Blind) { - if y == hero.Y && x == hero.X { - continue - } - } - - 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)) { - continue - } - } - - tp := pp.Monst - - switch { - case tp == nil: - ch = g.tripCh(y, x, ch) - case p.On(SenseMonsters) && tp.On(Invisible): - if g.DoorStop && !g.Firstmove { - g.Running = false - } - - continue - default: - if wakeup { - g.wakeMonster(y, x) - } - - if g.seeMonst(tp) { - if p.On(Hallucinating) { - ch = g.randomMonsterLetter() - } else { - ch = tp.Disguise - } - } - } - - if p.On(Blind) && (y != hero.Y || x != hero.X) { - continue - } - - g.move(y, x) - - if p.Room.Flags.Has(Dark) && !g.Options.SeeFloor && ch == Floor { - ch = ' ' - } - - if tp != nil || ch != g.inch() { - g.addch(ch) - } - - if g.DoorStop && !g.Firstmove && g.Running { - switch g.RunCh { - case 'h': - if x == ex { - continue - } - case 'j': - if y == sy { - continue - } - case 'k': - if y == ey { - continue - } - case 'l': - if x == sx { - continue - } - case 'y': - if (y+x)-sumhero >= 1 { - continue - } - case 'u': - if (y-x)-diffhero >= 1 { - continue - } - case 'n': - if (y+x)-sumhero <= -1 { - continue - } - case 'b': - if (y-x)-diffhero <= -1 { - continue - } - } - - switch ch { - case Door: - if x == hero.X || y == hero.Y { - g.Running = false - } - case Passage: - if x == hero.X || y == hero.Y { - passcount++ - } - case Floor, '|', '-', ' ': - default: - g.Running = false - } - } - } - } - - if g.DoorStop && !g.Firstmove && passcount > 1 { + if s.doorStop && s.passcount > 1 { g.Running = false } @@ -170,6 +59,192 @@ func (g *RogueGame) look(wakeup bool) { } } +// lookAround runs the nine-square scan of look(). +func (g *RogueGame) lookAround(s *lookScan) { + for y := s.sy; y <= s.ey; y++ { + if y <= 0 || y >= NumLines-1 { + continue + } + + for x := s.sx; x <= s.ex; x++ { + if x < 0 || x >= NumCols { + continue + } + + g.lookCell(s, y, x) + } + } +} + +// lookCell examines one square around the hero: visibility rules, trip +// and monster rendering, drawing, and run-stop checks (the loop body of +// misc.c look). +func (g *RogueGame) lookCell(s *lookScan, y, x int) { + pp := g.Level.At(y, x) + if g.lookSkips(s, pp, y, x) { + return + } + + tp := pp.Monst + + ch, skip := g.lookCellChar(s, tp, y, x, pp.Ch) + if skip { + return + } + + if !g.lookShow(s, tp, ch, y, x) { + return + } + + if s.doorStop && g.Running { + g.lookRunCheck(s, ch, y, x) + } +} + +// lookSkips reports whether look ignores this square entirely: the +// hero's own square when sighted, blank rock, passage squares of +// another network, and diagonals the hero could not step to (the guard +// chain of the misc.c look loop). +func (g *RogueGame) lookSkips(s *lookScan, pp *Place, y, x int) bool { + if !g.Player.On(Blind) && y == s.hero.Y && x == s.hero.X { + return true + } + + if pp.Ch == ' ' { // nothing need be done with a ' ' + return true + } + + return lookForeignPassage(s, pp.Flags, pp.Ch) || + g.lookDiagonalBlocked(s, pp.Flags, pp.Ch, y, x) +} + +// lookForeignPassage hides passage squares belonging to a different +// passage network than the hero's (misc.c look). +func lookForeignPassage(s *lookScan, fp PlaceFlags, ch byte) bool { + if s.pch != Door && ch != Door { + return (s.pfl & FPassage) != (fp & FPassage) + } + + return false +} + +// lookDiagonalBlocked hides diagonal door/passage squares the hero could +// not actually step to (misc.c look). +func (g *RogueGame) lookDiagonalBlocked(s *lookScan, fp PlaceFlags, ch byte, y, x int) bool { + if !fp.Has(FPassage) && ch != Door { + return false + } + + if !s.pfl.Has(FPassage) && s.pch != Door { + return false + } + + return s.hero.X != x && s.hero.Y != y && + !stepOk(g.Level.Char(y, s.hero.X)) && !stepOk(g.Level.Char(s.hero.Y, x)) +} + +// lookShow draws the square if it changed; it reports false when a +// blind hero cannot see it at all (the draw part of the look loop). +func (g *RogueGame) lookShow(s *lookScan, tp *Monster, ch byte, y, x int) bool { + p := &g.Player + if p.On(Blind) && (y != s.hero.Y || x != s.hero.X) { + return false + } + + g.move(y, x) + + if p.Room.Flags.Has(Dark) && !g.Options.SeeFloor && ch == Floor { + ch = ' ' + } + + if tp != nil || ch != g.inch() { + g.addch(ch) + } + + return true +} + +// lookCellChar picks what the square shows: trip rendering for empty +// squares, waking and disguises for monsters. skip means the square is +// not drawn at all (the monster switch of the look loop). +func (g *RogueGame) lookCellChar(s *lookScan, tp *Monster, y, x int, ch byte) (byte, bool) { + p := &g.Player + + switch { + case tp == nil: + return g.tripCh(y, x, ch), false + case p.On(SenseMonsters) && tp.On(Invisible): + if g.DoorStop && !g.Firstmove { + g.Running = false + } + + return ch, true + default: + if s.wakeup { + g.wakeMonster(y, x) + } + + if g.seeMonst(tp) { + if p.On(Hallucinating) { + return g.randomMonsterLetter(), false + } + + return tp.Disguise, false + } + + return ch, false + } +} + +// lookRunCheck decides whether what this square shows should stop a run +// (the DoorStop tail of the misc.c look loop). Squares on the running +// edge are ignored. +func (g *RogueGame) lookRunCheck(s *lookScan, ch byte, y, x int) { + if s.atRunEdge(g.RunCh, y, x) { + return + } + + switch ch { + case Door: + if x == s.hero.X || y == s.hero.Y { + g.Running = false + } + case Passage: + if x == s.hero.X || y == s.hero.Y { + s.passcount++ + } + case Floor, '|', '-', ' ': + default: + g.Running = false + } +} + +// atRunEdge reports whether (y, x) sits on the leading edge of the run +// direction, where door-stop checking does not apply (the first RunCh +// switch of the misc.c look loop). +func (s *lookScan) atRunEdge(runCh byte, y, x int) bool { + switch runCh { + case 'h': + return x == s.ex + case 'j': + return y == s.sy + case 'k': + return y == s.ey + case 'l': + return x == s.sx + case 'y': + return (y+x)-s.sumhero >= 1 + case 'u': + return (y-x)-s.diffhero >= 1 + case 'n': + return (y+x)-s.sumhero <= -1 + case 'b': + return (y-x)-s.diffhero <= -1 + } + + return false +} + // tripCh returns the character for this space, taking into account whether // or not the player is tripping (misc.c trip_ch). func (g *RogueGame) tripCh(y, x int, ch byte) byte { @@ -409,40 +484,22 @@ func (g *RogueGame) promptDirection() bool { } for { - gotit := true - - switch g.DirCh = g.readchar(); g.DirCh { - case 'h', 'H': - g.Delta = Coord{X: -1, Y: 0} - case 'j', 'J': - g.Delta = Coord{X: 0, Y: 1} - case 'k', 'K': - g.Delta = Coord{X: 0, Y: -1} - case 'l', 'L': - g.Delta = Coord{X: 1, Y: 0} - case 'y', 'Y': - g.Delta = Coord{X: -1, Y: -1} - case 'u', 'U': - g.Delta = Coord{X: 1, Y: -1} - case 'b', 'B': - g.Delta = Coord{X: -1, Y: 1} - case 'n', 'N': - g.Delta = Coord{X: 1, Y: 1} - case Escape: + g.DirCh = g.readchar() + if g.DirCh == Escape { g.LastDir = 0 g.resetLast() return false - default: - g.Msgs.Mpos = 0 - g.msg("%s", prompt) - - gotit = false } - if gotit { + if d, ok := deltaFor(g.DirCh); ok { + g.Delta = d + break } + + g.Msgs.Mpos = 0 + g.msg("%s", prompt) } g.DirCh = toLower(g.DirCh) @@ -451,14 +508,7 @@ func (g *RogueGame) promptDirection() bool { } 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.confuseDirection() } g.Msgs.Mpos = 0 @@ -466,6 +516,44 @@ func (g *RogueGame) promptDirection() bool { return true } +// confuseDirection randomizes the chosen direction for a confused hero +// (the ISHUH tail of misc.c get_dir). +func (g *RogueGame) confuseDirection() { + for { + g.Delta.Y = g.rnd(3) - 1 + + g.Delta.X = g.rnd(3) - 1 + if g.Delta.Y != 0 || g.Delta.X != 0 { + return + } + } +} + +// deltaFor maps a direction key to its movement delta; ok is false for +// keys that are not directions (the switch of misc.c get_dir). +func deltaFor(ch byte) (Coord, bool) { + switch ch { + case 'h', 'H': + return Coord{X: -1, Y: 0}, true + case 'j', 'J': + return Coord{X: 0, Y: 1}, true + case 'k', 'K': + return Coord{X: 0, Y: -1}, true + case 'l', 'L': + return Coord{X: 1, Y: 0}, true + case 'y', 'Y': + return Coord{X: -1, Y: -1}, true + case 'u', 'U': + return Coord{X: 1, Y: -1}, true + case 'b', 'B': + return Coord{X: -1, Y: 1}, true + case 'n', 'N': + return Coord{X: 1, Y: 1}, true + } + + return Coord{}, false +} + // callIt calls an object something after use (misc.c call_it). func (g *RogueGame) callIt(info *ObjInfo) { if info.Know { From aa57349c342927f5a5ede84c8432785f652b6ade Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:15:27 +0200 Subject: [PATCH 08/32] Decompose command.go; command keys become a handler table (step 7) The ordinary command keys move into gameData.commandHandlers (method expressions and small literals); dispatchKey keeps only re-dispatching prefixes (runCommand/fightCommand/repeatCommand/moveOnCommand) and the wizard fallthrough. command() splits into playTurn/turnUpkeep/ readCommand/executeCommand/countPrefix/ringTurnEffects; search gains searchSpot/searchFloor; help gains helpOne/helpAll/helpLines; call gains callTarget/callPrelude; wizardCommand splits in two plus wizardKit; uLevel and current flatten to early returns. command.go is complexity-clean. Behavior and RNG call order unchanged. --- game/command.go | 1114 ++++++++++++++++++++++++----------------------- game/tables.go | 139 ++++++ 2 files changed, 714 insertions(+), 539 deletions(-) diff --git a/game/command.go b/game/command.go index f5dbf1b..4e82a07 100644 --- a/game/command.go +++ b/game/command.go @@ -16,119 +16,7 @@ func (g *RogueGame) command() { g.DoFuses(Before) for ; ntimes > 0; ntimes-- { - g.Again = false - if g.HasHit { - g.endmsg() - g.HasHit = false - } - // these are illegal things for the player to be, so if any are - // set, someone's been poking in memory - if p.On(Slowed | Greedy | Invisible | Regenerates | Targeted) { - panic("player flags corrupted") - } - - g.look(true) - - if !g.Running { - g.DoorStop = false - } - - g.status() - g.LastScore = p.Purse - g.move(p.Pos.Y, p.Pos.X) - - if (!g.Running && g.Count == 0) || !g.Options.Jump { - g.refresh() // draw screen - } - - g.Take = 0 - g.After = true - // Read command or continue run - if g.Wizard { - g.NoScore = true - } - - var ch byte - - if g.NoCommand == 0 { - switch { - case g.Running || g.ToDeath: - ch = g.RunCh - case g.Count != 0: - ch = g.countCh - default: - ch = g.readchar() - - g.MoveOn = false - if g.Msgs.Mpos != 0 { // erase message if its there - g.msg("") - } - } - } else { - ch = '.' - } - - if g.NoCommand != 0 { - if g.NoCommand--; g.NoCommand == 0 { - p.Flags.Set(Awake) - g.msg("you can move again") - } - } else { - // check for prefixes - g.newCount = false - if isDigit(ch) { - g.Count = 0 - - g.newCount = true - for isDigit(ch) { - g.Count = 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 - switch ch { - case CTRL('B'), CTRL('H'), CTRL('J'), CTRL('K'), - CTRL('L'), CTRL('N'), CTRL('U'), CTRL('Y'), - '.', 'a', 'b', 'h', 'j', 'k', 'l', 'm', 'n', 'q', - 'r', 's', 't', 'u', 'y', 'z', 'B', 'C', 'H', 'I', - 'J', 'K', 'L', 'N', 'U', 'Y', - CTRL('D'), CTRL('A'): - default: - g.Count = 0 - } - } - // execute a command - if g.Count != 0 && !g.Running { - g.Count-- - } - - if ch != 'a' && ch != Escape && - !g.Running && g.Count == 0 && !g.ToDeath { - g.LLastComm = g.LastComm - g.LLastDir = g.LastDir - g.LLastPick = g.LastPick - g.LastComm = ch - g.LastDir = 0 - g.LastPick = nil - } - - g.dispatch(ch) - // turn off flags if no longer needed - if !g.Running { - g.DoorStop = false - } - } - // If he ran into something to take, let him pick it up. - if g.Take != 0 { - g.pickUp(g.Take) - } - - if !g.Running { - g.DoorStop = false - } + g.playTurn() if !g.After { ntimes++ @@ -138,287 +26,368 @@ func (g *RogueGame) command() { 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() - } + g.ringTurnEffects(Left) + g.ringTurnEffects(Right) +} - if p.IsRing(Right, RingSearching) { +// ringTurnEffects fires the per-turn ring powers on one hand: searching +// searches, teleportation teleports on 1 turn in 50 (the tail of +// command.c command). +func (g *RogueGame) ringTurnEffects(hand int) { + p := &g.Player + if p.IsRing(hand, RingSearching) { g.search() - } else if p.IsRing(Right, RingTeleportation) && g.rnd(50) == 0 { + } else if p.IsRing(hand, RingTeleportation) && g.rnd(50) == 0 { g.teleport() } } -// dispatch runs one command character (the big switch in command.c; the -// loop stands in for its `goto over` re-dispatch). -func (g *RogueGame) dispatch(ch byte) { +// playTurn runs one full player turn: upkeep, one command, and pickup +// (the body of the command.c command loop). +func (g *RogueGame) playTurn() { p := &g.Player + g.turnUpkeep() + + var ch byte + + if g.NoCommand == 0 { + ch = g.readCommand() + } else { + ch = '.' + } + + if g.NoCommand != 0 { + if g.NoCommand--; g.NoCommand == 0 { + p.Flags.Set(Awake) + g.msg("you can move again") + } + } else { + g.executeCommand(ch) + } + // If he ran into something to take, let him pick it up. + if g.Take != 0 { + g.pickUp(g.Take) + } + + if !g.Running { + g.DoorStop = false + } +} + +// turnUpkeep redraws and resets per-turn state before a command is read +// (the top of the command.c command loop). +func (g *RogueGame) turnUpkeep() { + p := &g.Player + + g.Again = false + if g.HasHit { + g.endmsg() + g.HasHit = false + } + // these are illegal things for the player to be, so if any are + // set, someone's been poking in memory + if p.On(Slowed | Greedy | Invisible | Regenerates | Targeted) { + panic("player flags corrupted") + } + + g.look(true) + + if !g.Running { + g.DoorStop = false + } + + g.status() + g.LastScore = p.Purse + g.move(p.Pos.Y, p.Pos.X) + + if (!g.Running && g.Count == 0) || !g.Options.Jump { + g.refresh() // draw screen + } + + g.Take = 0 + g.After = true + // Read command or continue run + if g.Wizard { + g.NoScore = true + } +} + +// readCommand picks the next command character: run/count repeats or +// fresh input (the read block of command.c command). +func (g *RogueGame) readCommand() byte { + switch { + case g.Running || g.ToDeath: + return g.RunCh + case g.Count != 0: + return g.countCh + default: + ch := g.readchar() + + g.MoveOn = false + if g.Msgs.Mpos != 0 { // erase message if its there + g.msg("") + } + + return ch + } +} + +// executeCommand runs one read command with the repeat-count prefix and +// the repeat-history bookkeeping (the execute block of command.c +// command). +func (g *RogueGame) executeCommand(ch byte) { + // check for prefixes + g.newCount = false + ch = g.countPrefix(ch) + // execute a command + if g.Count != 0 && !g.Running { + g.Count-- + } + + if ch != 'a' && ch != Escape && + !g.Running && g.Count == 0 && !g.ToDeath { + g.LLastComm = g.LastComm + g.LLastDir = g.LastDir + g.LLastPick = g.LastPick + g.LastComm = ch + g.LastDir = 0 + g.LastPick = nil + } + + g.dispatch(ch) + // turn off flags if no longer needed + if !g.Running { + g.DoorStop = false + } +} + +// countPrefix consumes a numeric repeat-count prefix and returns the +// command character that follows it (command.c). +func (g *RogueGame) countPrefix(ch byte) byte { + if !isDigit(ch) { + return ch + } + + g.Count = 0 + + g.newCount = true + for isDigit(ch) { + 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 + switch ch { + case CTRL('B'), CTRL('H'), CTRL('J'), CTRL('K'), + CTRL('L'), CTRL('N'), CTRL('U'), CTRL('Y'), + '.', 'a', 'b', 'h', 'j', 'k', 'l', 'm', 'n', 'q', + 'r', 's', 't', 'u', 'y', 'z', 'B', 'C', 'H', 'I', + 'J', 'K', 'L', 'N', 'U', 'Y', + CTRL('D'), CTRL('A'): + default: + g.Count = 0 + } + + return ch +} + +// dispatch runs one command character, re-running dispatchKey when it +// asks (the C `goto over` re-dispatch in command.c). +func (g *RogueGame) dispatch(ch byte) { for { - switch ch { - case ',': - var found *Object + newCh, again := g.dispatchKey(ch) + if !again { + return + } - for _, obj := range g.Level.Objects { - if obj.Pos.Y == p.Pos.Y && obj.Pos.X == p.Pos.X { - found = obj + ch = newCh + } +} - break - } - } +// dispatchKey runs one command character (the big switch of command.c). +// A true second result asks dispatch to run once more with the returned +// key. +func (g *RogueGame) dispatchKey(ch byte) (byte, bool) { + if h, ok := g.data.commandHandlers[ch]; ok { + h(g) - if found != nil { - if !g.levitCheck() { - g.pickUp(found.Kind.Glyph()) - } - } else { - if !g.Options.Terse { - g.addmsgf("there is ") - } + return 0, false + } - g.addmsgf("nothing here") + switch ch { + case CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'), + CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'): + return g.runCommand(ch) + case 'F', 'f': + return g.fightCommand(ch) + case 'a': + return g.repeatCommand() + case 'm': + return g.moveOnCommand() + default: + g.After = false + if g.Wizard { + g.wizardCommand(ch) + } else { + g.illcom(ch) + } + } - if !g.Options.Terse { - g.addmsgf(" to pick up") - } + return 0, false +} - g.endmsg() - } - case '!': - g.shell() - case 'h': - g.moveHero(0, -1) - case 'j': - g.moveHero(1, 0) - case 'k': - g.moveHero(-1, 0) - case 'l': - g.moveHero(0, 1) - case 'y': - g.moveHero(-1, -1) - case 'u': - g.moveHero(-1, 1) - case 'b': - g.moveHero(1, -1) - case 'n': - g.moveHero(1, 1) - case 'H': - g.startRun('h') - case 'J': - g.startRun('j') - case 'K': - g.startRun('k') - case 'L': - g.startRun('l') - case 'Y': - g.startRun('y') - case 'U': - g.startRun('u') - case 'B': - g.startRun('b') - case 'N': - g.startRun('n') - case CTRL('H'), CTRL('J'), CTRL('K'), CTRL('L'), - CTRL('Y'), CTRL('U'), CTRL('B'), CTRL('N'): - if !p.On(Blind) { - g.DoorStop = true - g.Firstmove = true - } +// runCommand handles the ctrl-direction run-until-adjacent prefix; the +// returned key re-dispatches as the matching run command (command.c). +func (g *RogueGame) runCommand(ch byte) (byte, bool) { + if !g.Player.On(Blind) { + g.DoorStop = true + g.Firstmove = true + } - if g.Count != 0 && !g.newCount { - ch = g.direction - } else { - ch += 'A' - CTRL('A') - g.direction = ch - } + if g.Count != 0 && !g.newCount { + ch = g.direction + } else { + ch += 'A' - CTRL('A') + g.direction = ch + } - continue // the C goto over: re-dispatch as the run command - case 'F', 'f': - if ch == 'F' { - g.Kamikaze = true - } + return ch, true // the C goto over: re-dispatch as the run command +} - if !g.promptDirection() { - g.After = false +// repeatCommand handles 'a': replay the last command by re-dispatching +// it (command.c). +func (g *RogueGame) repeatCommand() (byte, bool) { + if g.LastComm == 0 { + g.msg("you haven't typed a command yet") + g.After = false - break - } + return 0, false + } - g.Delta.Y += p.Pos.Y - g.Delta.X += p.Pos.X + g.Again = true - mp := g.Level.MonsterAt(g.Delta.Y, g.Delta.X) - if mp == nil || (!g.seeMonst(mp) && !p.On(SenseMonsters)) { - if !g.Options.Terse { - g.addmsgf("I see ") - } + return g.LastComm, true // the C goto over: replay the last command +} - g.msg("no monster there") - g.After = false - } else if g.diagOk(p.Pos, g.Delta) { - g.ToDeath = true - g.MaxHit = 0 +// moveOnCommand handles 'm': move without picking up; the direction key +// re-dispatches (command.c). +func (g *RogueGame) moveOnCommand() (byte, bool) { + g.MoveOn = true + if !g.promptDirection() { + g.After = false - mp.Flags.Set(Targeted) + return 0, false + } - g.RunCh = g.DirCh - ch = g.DirCh + g.countCh = g.DirCh - continue // the C goto over: fight by running at it - } - case 't': - if !g.promptDirection() { - g.After = false - } else { - g.missile(g.Delta.Y, g.Delta.X) - } - case 'a': - if g.LastComm == 0 { - g.msg("you haven't typed a command yet") - g.After = false - } else { - ch = g.LastComm - g.Again = true + return g.DirCh, true // the C goto over: move onto it without pickup +} - continue // the C goto over: replay the last command - } - case 'q': - g.quaff() - case 'Q': - g.After = false - g.QComm = true - g.quit(0) - g.QComm = false - case 'i': - g.After = false - g.inventory(p.Pack, 0) - case 'I': - g.After = false - g.pickyInven() - case 'd': - g.dropIt() - case 'r': - g.readScroll() - case 'e': - g.eat() - case 'w': - g.wield() - case 'W': - g.wear() - case 'T': - g.takeOff() - case 'P': - g.ringOn() - case 'R': - g.ringOff() - case 'o': - g.option() - g.After = false - case 'c': - g.call() - g.After = false - case '>': - g.After = false - g.dLevel() - case '<': - g.After = false - g.uLevel() - case '?': - g.After = false - g.help() - case '/': - g.After = false - g.identify() - case 's': - g.search() - case 'z': - if g.promptDirection() { - g.doZap() - } else { - g.After = false - } - case 'D': - g.After = false - g.discovered() - case CTRL('P'): - g.After = false - g.msg("%s", g.Msgs.Huh) - case CTRL('R'): - g.After = false - g.refresh() - case 'v': - g.After = false - g.msg("version %s. (mctesq was here)", Release) - case 'S': - g.After = false - g.saveGame() - case '.': - // rest command - case ' ': - g.After = false // "legal" illegal command - case '^': - g.After = false - if g.promptDirection() { - g.Delta.Y += p.Pos.Y - g.Delta.X += p.Pos.X +// pickupCommand handles ',': pick up whatever is here (command.c). +func (g *RogueGame) pickupCommand() { + p := &g.Player - fp := g.Level.FlagsAt(g.Delta.Y, g.Delta.X) - if !g.Options.Terse { - g.addmsgf("You have found ") - } + var found *Object - switch { - case g.Level.Char(g.Delta.Y, g.Delta.X) != Trap: - g.msg("no trap there") - case p.On(Hallucinating): - g.msg("%s", g.data.trName[g.rnd(NumTrapTypes)]) - default: - g.msg("%s", g.data.trName[*fp&FTrapMask]) - fp.Set(FSeen) - } - } - case Escape: // escape - g.DoorStop = false - g.Count = 0 - g.After = false - g.Again = false - case 'm': - g.MoveOn = true - if !g.promptDirection() { - g.After = false - } else { - ch = g.DirCh - g.countCh = g.DirCh + for _, obj := range g.Level.Objects { + if obj.Pos.Y == p.Pos.Y && obj.Pos.X == p.Pos.X { + found = obj - continue // the C goto over: move onto it without pickup - } - case ')': - g.current(p.CurWeapon, "wielding", "") - case ']': - g.current(p.CurArmor, "wearing", "") - case '=': - g.current(p.CurRing[Left], "wearing", - g.chooseTerse("(L)", "on left hand")) - g.current(p.CurRing[Right], "wearing", - g.chooseTerse("(R)", "on right hand")) - case '@': - g.StatMsg = true - g.status() - g.StatMsg = false - g.After = false - default: - g.After = false - if g.Wizard { - g.wizardCommand(ch) - } else { - g.illcom(ch) - } + break + } + } + + if found != nil { + if !g.levitCheck() { + g.pickUp(found.Kind.Glyph()) } return } + + if !g.Options.Terse { + g.addmsgf("there is ") + } + + g.addmsgf("nothing here") + + if !g.Options.Terse { + g.addmsgf(" to pick up") + } + + g.endmsg() +} + +// fightCommand handles the f/F fight prefix; a true again asks dispatch +// to rerun with the direction key (command.c). +func (g *RogueGame) fightCommand(ch byte) (byte, bool) { + p := &g.Player + if ch == 'F' { + g.Kamikaze = true + } + + if !g.promptDirection() { + g.After = false + + return 0, false + } + + 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.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 + + return g.DirCh, true // the C goto over: fight by running at it + } + + return 0, false +} + +// identifyTrapCommand handles '^': name the trap in a direction +// (command.c). +func (g *RogueGame) identifyTrapCommand() { + p := &g.Player + + g.After = false + if !g.promptDirection() { + return + } + + 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.addmsgf("You have found ") + } + + switch { + case g.Level.Char(g.Delta.Y, g.Delta.X) != Trap: + g.msg("no trap there") + case p.On(Hallucinating): + g.msg("%s", g.data.trName[g.rnd(NumTrapTypes)]) + default: + g.msg("%s", g.data.trName[*fp&FTrapMask]) + fp.Set(FSeen) + } } // wizardCommand handles the MASTER debug commands (command.c). @@ -444,6 +413,17 @@ func (g *RogueGame) wizardCommand(ch byte) { g.NewLevel() case CTRL('F'): g.showMap() + default: + g.wizardDebugCommand(ch) + } +} + +// wizardDebugCommand handles the rest of the MASTER debug commands +// (command.c). +func (g *RogueGame) wizardDebugCommand(ch byte) { + p := &g.Player + + switch ch { case CTRL('T'): g.teleport() case CTRL('E'): @@ -457,25 +437,7 @@ func (g *RogueGame) wizardCommand(ch byte) { item.Charges = 10000 } case CTRL('I'): - for range 9 { - g.raiseLevel() - } - // Give him a sword (+1,+1) - obj := newObject() - g.initWeapon(obj, WeaponTwoHandedSword) - obj.HPlus = 1 - obj.DPlus = 1 - g.addPack(obj, true) - p.CurWeapon = obj - // And his suit of armor - obj = newObject() - obj.Kind = KindArmor - obj.Which = int(ArmorPlateMail) - obj.ArmorClass = -5 - obj.Flags.Set(Known) - obj.Count = 1 - p.CurArmor = obj - g.addPack(obj, true) + g.wizardKit() case '*': g.prList() default: @@ -483,6 +445,32 @@ func (g *RogueGame) wizardCommand(ch byte) { } } +// wizardKit levels the wizard up and equips him (the ^I case of the +// command.c wizard switch). +func (g *RogueGame) wizardKit() { + p := &g.Player + + for range 9 { + g.raiseLevel() + } + // Give him a sword (+1,+1) + obj := newObject() + g.initWeapon(obj, WeaponTwoHandedSword) + obj.HPlus = 1 + obj.DPlus = 1 + g.addPack(obj, true) + p.CurWeapon = obj + // And his suit of armor + obj = newObject() + obj.Kind = KindArmor + obj.Which = int(ArmorPlateMail) + obj.ArmorClass = -5 + obj.Flags.Set(Known) + obj.Count = 1 + p.CurArmor = obj + g.addPack(obj, true) +} + // illcom reports an illegal command (command.c illcom). func (g *RogueGame) illcom(ch byte) { g.Msgs.SaveMsg = false @@ -514,59 +502,8 @@ func (g *RogueGame) search() { 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.addmsgf("you found ") - } - - if p.On(Hallucinating) { - g.msg("%s", g.data.trName[g.rnd(NumTrapTypes)]) - } else { - g.msg("%s", g.data.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 { + if g.searchSpot(y, x, probinc) { found = true - - fp.Set(FReal) - - g.Count = 0 - g.Running = false } } } @@ -576,6 +513,72 @@ func (g *RogueGame) search() { } } +// searchSpot tries to reveal one hidden thing next to the hero: a +// secret door, a hidden trap, or a secret passage (the loop body of +// command.c search). It reports whether something was found. +func (g *RogueGame) searchSpot(y, x, probinc int) bool { + fp := g.Level.FlagsAt(y, x) + if fp.Has(FReal) { + return false + } + + 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: + foundone = g.searchFloor(fp, y, x, probinc) + case ' ': + if g.rnd(3+probinc) != 0 { + break + } + + g.Level.SetChar(y, x, Passage) + + foundone = true + } + + if foundone { + fp.Set(FReal) + + g.Count = 0 + g.Running = false + } + + return foundone +} + +// searchFloor reveals a hidden trap underfoot and names it (the FLOOR +// arm of command.c search). +func (g *RogueGame) searchFloor(fp *PlaceFlags, y, x, probinc int) bool { + if g.rnd(2+probinc) != 0 { + return false + } + + g.Level.SetChar(y, x, Trap) + + if !g.Options.Terse { + g.addmsgf("you found ") + } + + if g.Player.On(Hallucinating) { + g.msg("%s", g.data.trName[g.rnd(NumTrapTypes)]) + } else { + g.msg("%s", g.data.trName[*fp&FTrapMask]) + fp.Set(FSeen) + } + + return true +} + // help gives single character help, or the whole mess if he wants it // (command.c help). func (g *RogueGame) help() { @@ -585,40 +588,36 @@ func (g *RogueGame) help() { // If it's not a *, print the right help string or an error if he // typed a funny character. if helpch != '*' { - g.move(0, 0) - - for _, strp := range g.data.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)) + g.helpOne(helpch) return } - // Here we print help for everything, then wait before we return to - // command mode. - numprint := 0 + + g.helpAll() +} + +// helpOne prints the help line for a single key, or an error for a +// funny character (command.c help). +func (g *RogueGame) helpOne(helpch byte) { + g.move(0, 0) for _, strp := range g.data.helpStr { - if strp.Print { - numprint++ + if strp.Ch == helpch { + g.Msgs.LowerMsg = true + g.msg("%s%s", unctrl(strp.Ch), strp.Desc) + g.Msgs.LowerMsg = false + + return } } - if numprint&1 == 1 { // round odd numbers up - numprint++ - } + g.msg("unknown character '%s'", unctrl(helpch)) +} - numprint /= 2 - if numprint > NumLines-1 { - numprint = NumLines - 1 - } +// helpAll prints help for everything in two columns, then waits before +// returning to command mode (command.c help). +func (g *RogueGame) helpAll() { + numprint := g.helpLines() hw := g.scr.Hw hw.Clear() @@ -655,6 +654,29 @@ func (g *RogueGame) help() { g.refresh() } +// helpLines counts how many rows the two-column help listing needs +// (command.c help). +func (g *RogueGame) helpLines() int { + numprint := 0 + + for _, strp := range g.data.helpStr { + if strp.Print { + numprint++ + } + } + + if numprint&1 == 1 { // round odd numbers up + numprint++ + } + + numprint /= 2 + if numprint > NumLines-1 { + numprint = NumLines - 1 + } + + return numprint +} + // identify tells the player what a certain thing is (command.c identify). func (g *RogueGame) identify() { g.msg("what do you want identified? ") @@ -706,21 +728,25 @@ func (g *RogueGame) uLevel() { return } - if g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X) == Stairs { - if g.HasAmulet { - g.Depth-- - if g.Depth == 0 { - g.totalWinner() - } - - g.NewLevel() - g.msg("you feel a wrenching sensation in your gut") - } else { - g.msg("your way is magically blocked") - } - } else { + if g.Level.Char(g.Player.Pos.Y, g.Player.Pos.X) != Stairs { g.msg("I see no way up") + + return } + + if !g.HasAmulet { + g.msg("your way is magically blocked") + + return + } + + g.Depth-- + if g.Depth == 0 { + g.totalWinner() + } + + g.NewLevel() + g.msg("you feel a wrenching sensation in your gut") } // levitCheck checks whether she's levitating, and if she is, prints an @@ -744,63 +770,14 @@ func (g *RogueGame) call() { return } - var ( - op *ObjInfo - elsewise string - know *bool - guess *string - ) - - it := &g.Items - - switch obj.Kind { - case KindRing: - op = &it.Rings[obj.Which] - elsewise = it.RingStones[obj.Which] - case KindPotion: - op = &it.Potions[obj.Which] - elsewise = it.PotColors[obj.Which] - case KindScroll: - op = &it.Scrolls[obj.Which] - elsewise = it.ScrNames[obj.Which] - case KindWand: - op = &it.Sticks[obj.Which] - 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 - fromGuess = true - } - } else { - fromGuess = elsewise != "" && elsewise == *guess - } - - if know != nil && *know { - g.msg("that has already been identified") - + op, elsewise, guess, ok := g.callTarget(obj) + if !ok { return } - if fromGuess { - if !g.Options.Terse { - g.addmsgf("Was ") - } - - g.msg("called \"%s\"", elsewise) + elsewise, guess, ok = g.callPrelude(op, elsewise, guess) + if !ok { + return } g.msg("%s", g.chooseTerse("call it: ", "what do you want to call it? ")) @@ -811,24 +788,67 @@ func (g *RogueGame) call() { } } +// callTarget picks what a call names: the lore record for identifiable +// kinds, the object's own label otherwise; ok is false for food (the +// switch of command.c call). +func (g *RogueGame) callTarget(obj *Object) (*ObjInfo, string, *string, bool) { + it := &g.Items + + switch obj.Kind { + case KindRing: + return &it.Rings[obj.Which], it.RingStones[obj.Which], nil, true + case KindPotion: + return &it.Potions[obj.Which], it.PotColors[obj.Which], nil, true + case KindScroll: + return &it.Scrolls[obj.Which], it.ScrNames[obj.Which], nil, true + case KindWand: + return &it.Sticks[obj.Which], it.WandMade[obj.Which], nil, true + case KindFood: + g.msg("you can't call that anything") + + return nil, "", nil, false + default: + return nil, obj.Label, &obj.Label, true + } +} + +// callPrelude resolves the item's current name, reporting a previous +// guess; ok is false when the item is already identified (command.c +// call). +func (g *RogueGame) callPrelude(op *ObjInfo, elsewise string, guess *string) (string, *string, bool) { + fromGuess := false + + if op != nil { + if op.Know { + g.msg("that has already been identified") + + return "", nil, false + } + + guess = &op.Guess + if *guess != "" { + elsewise = *guess + fromGuess = true + } + } else { + fromGuess = elsewise != "" && elsewise == *guess + } + + if fromGuess { + if !g.Options.Terse { + g.addmsgf("Was ") + } + + g.msg("called \"%s\"", elsewise) + } + + return elsewise, guess, true +} + // current prints the current weapon/armor (command.c current). func (g *RogueGame) current(cur *Object, how, where string) { g.After = false - if cur != nil { - if !g.Options.Terse { - g.addmsgf("you are %s (", how) - } - - g.InvDescribe = false - g.addmsgf("%c) %s", cur.PackCh, g.inventoryName(cur, true)) - - g.InvDescribe = true - if where != "" { - g.addmsgf(" %s", where) - } - - g.endmsg() - } else { + if cur == nil { if !g.Options.Terse { g.addmsgf("you are ") } @@ -840,7 +860,23 @@ func (g *RogueGame) current(cur *Object, how, where string) { } g.endmsg() + + return } + + if !g.Options.Terse { + g.addmsgf("you are %s (", how) + } + + g.InvDescribe = false + g.addmsgf("%c) %s", cur.PackCh, g.inventoryName(cur, true)) + + g.InvDescribe = true + if where != "" { + g.addmsgf(" %s", where) + } + + g.endmsg() } // shell lets them escape for a while (main.c shell). The terminal decides diff --git a/game/tables.go b/game/tables.go index 2d66273..05044f4 100644 --- a/game/tables.go +++ b/game/tables.go @@ -142,6 +142,11 @@ type gameData struct { // lands, indexed by monster letter - 'A': the cases of the fight.c // attack switch. A true return means the monster removed itself. hitHandlers [26]func(g *RogueGame, mp *Monster, mname string) bool + + // commandHandlers dispatches the ordinary command keys: the simple + // cases of the big command.c switch. Keys that re-dispatch (runs, + // fight, repeat, move-on) and wizard keys stay in dispatchKey. + commandHandlers map[byte]func(g *RogueGame) } // ripWall is the repeated blank wall line of the tombstone art. @@ -672,6 +677,140 @@ func newGameData() *gameData { 'V' - 'A': (*RogueGame).hitLifeDrainer, 'W' - 'A': (*RogueGame).hitLifeDrainer, }, + + commandHandlers: map[byte]func(*RogueGame){ + ',': (*RogueGame).pickupCommand, + '!': (*RogueGame).shell, + 'h': func(g *RogueGame) { g.moveHero(0, -1) }, + 'j': func(g *RogueGame) { g.moveHero(1, 0) }, + 'k': func(g *RogueGame) { g.moveHero(-1, 0) }, + 'l': func(g *RogueGame) { g.moveHero(0, 1) }, + 'y': func(g *RogueGame) { g.moveHero(-1, -1) }, + 'u': func(g *RogueGame) { g.moveHero(-1, 1) }, + 'b': func(g *RogueGame) { g.moveHero(1, -1) }, + 'n': func(g *RogueGame) { g.moveHero(1, 1) }, + 'H': func(g *RogueGame) { g.startRun('h') }, + 'J': func(g *RogueGame) { g.startRun('j') }, + 'K': func(g *RogueGame) { g.startRun('k') }, + 'L': func(g *RogueGame) { g.startRun('l') }, + 'Y': func(g *RogueGame) { g.startRun('y') }, + 'U': func(g *RogueGame) { g.startRun('u') }, + 'B': func(g *RogueGame) { g.startRun('b') }, + 'N': func(g *RogueGame) { g.startRun('n') }, + 't': func(g *RogueGame) { + if !g.promptDirection() { + g.After = false + } else { + g.missile(g.Delta.Y, g.Delta.X) + } + }, + 'q': (*RogueGame).quaff, + 'Q': func(g *RogueGame) { + g.After = false + g.QComm = true + g.quit(0) + g.QComm = false + }, + 'i': func(g *RogueGame) { + g.After = false + g.inventory(g.Player.Pack, 0) + }, + 'I': func(g *RogueGame) { + g.After = false + g.pickyInven() + }, + 'd': (*RogueGame).dropIt, + 'r': (*RogueGame).readScroll, + 'e': (*RogueGame).eat, + 'w': (*RogueGame).wield, + 'W': (*RogueGame).wear, + 'T': (*RogueGame).takeOff, + 'P': (*RogueGame).ringOn, + 'R': (*RogueGame).ringOff, + 'o': func(g *RogueGame) { + g.option() + g.After = false + }, + 'c': func(g *RogueGame) { + g.call() + g.After = false + }, + '>': func(g *RogueGame) { + g.After = false + g.dLevel() + }, + '<': func(g *RogueGame) { + g.After = false + g.uLevel() + }, + '?': func(g *RogueGame) { + g.After = false + g.help() + }, + '/': func(g *RogueGame) { + g.After = false + g.identify() + }, + 's': (*RogueGame).search, + 'z': func(g *RogueGame) { + if g.promptDirection() { + g.doZap() + } else { + g.After = false + } + }, + 'D': func(g *RogueGame) { + g.After = false + g.discovered() + }, + CTRL('P'): func(g *RogueGame) { + g.After = false + g.msg("%s", g.Msgs.Huh) + }, + CTRL('R'): func(g *RogueGame) { + g.After = false + g.refresh() + }, + 'v': func(g *RogueGame) { + g.After = false + g.msg("version %s. (mctesq was here)", Release) + }, + 'S': func(g *RogueGame) { + g.After = false + g.saveGame() + }, + '.': func(*RogueGame) { + // rest command + }, + ' ': func(g *RogueGame) { + g.After = false // "legal" illegal command + }, + '^': (*RogueGame).identifyTrapCommand, + Escape: func(g *RogueGame) { + g.DoorStop = false + g.Count = 0 + g.After = false + g.Again = false + }, + ')': func(g *RogueGame) { + g.current(g.Player.CurWeapon, "wielding", "") + }, + ']': func(g *RogueGame) { + g.current(g.Player.CurArmor, "wearing", "") + }, + '=': func(g *RogueGame) { + g.current(g.Player.CurRing[Left], "wearing", + g.chooseTerse("(L)", "on left hand")) + g.current(g.Player.CurRing[Right], "wearing", + g.chooseTerse("(R)", "on right hand")) + }, + '@': func(g *RogueGame) { + g.StatMsg = true + g.status() + g.StatMsg = false + g.After = false + }, + }, } } From fec79b939a3043854e4cb99b1880a8ee5d1fadc1 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:19:31 +0200 Subject: [PATCH 09/32] Decompose chase.go (refactor step 7) chase splits into chaseBestSpot/chaseTry/scareScrollAt with a chaseSearch state struct; chaseStep gains chaseRooms, chaseGoal, dragonBreath/dragonShoots, and chaseTakeObject; runners gains runnerTurn; findDest gains objectClaimed. chase.go is complexity-clean. Behavior and RNG call order unchanged. --- game/chase.go | 465 +++++++++++++++++++++++++++++--------------------- 1 file changed, 266 insertions(+), 199 deletions(-) diff --git a/game/chase.go b/game/chase.go index d686994..b9516cc 100644 --- a/game/chase.go +++ b/game/chase.go @@ -9,26 +9,7 @@ const dragonShot = 5 func (g *RogueGame) runners(int) { list := append([]*Monster(nil), g.Level.Monsters...) for _, tp := range list { - if !tp.On(Held) && tp.On(Awake) { - origPos := tp.Pos - - wastarget := tp.On(Targeted) - if removed := g.moveMonster(tp); removed { - continue - } - - if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 { - if removed := g.moveMonster(tp); removed { - continue - } - } - - if wastarget && origPos != tp.Pos { - tp.Flags.Clear(Targeted) - - g.ToDeath = false - } - } + g.runnerTurn(tp) } if g.HasHit { @@ -37,6 +18,33 @@ func (g *RogueGame) runners(int) { } } +// runnerTurn gives one monster its motion for the turn; flying monsters +// far from the hero move twice (the loop body of chase.c runners). +func (g *RogueGame) runnerTurn(tp *Monster) { + if tp.On(Held) || !tp.On(Awake) { + return + } + + origPos := tp.Pos + + wastarget := tp.On(Targeted) + if removed := g.moveMonster(tp); removed { + return + } + + if tp.On(Flying) && distCp(g.Player.Pos, tp.Pos) >= 3 { + if removed := g.moveMonster(tp); removed { + return + } + } + + if wastarget && origPos != tp.Pos { + tp.Flags.Clear(Targeted) + + g.ToDeath = false + } +} + // moveMonster executes a single turn of running for a monster (chase.c // move_monst). The result reports that the monster died or left the // level (the C -1 return). @@ -87,111 +95,33 @@ func (g *RogueGame) relocate(th *Monster, newLoc Coord) { } } -// chaseStep makes one thing chase another (chase.c do_chase). removed -// reports that the chaser died or left the level in the attempt (the C -// -1 return). -func (g *RogueGame) chaseStep(th *Monster) (removed bool) { - p := &g.Player +// chaseStep makes one thing chase another (chase.c do_chase). The +// result reports that the chaser died or left the level in the attempt +// (the C -1 return). +func (g *RogueGame) chaseStep(th *Monster) bool { stoprun := false // true means we are there - mindist := 32767 - rer := th.Room // find room of chaser - if th.On(Greedy) && rer.GoldVal == 0 { - th.Dest = &p.Pos // if gold has been taken, run after hero - } + rer, ree, door := g.chaseRooms(th) - var ree *Room // find room of chasee - if th.Dest == &p.Pos { - ree = p.Room - } else { - ree = g.roomIn(*th.Dest) - } - // 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 - - for { - // If the object of our desire is in a different room, and we are - // not in a corridor, run to the door nearest to our goal. - if rer != ree { - for i := range rer.Exits { - curdist := distCp(*th.Dest, rer.Exits[i]) - if curdist < mindist { - this = rer.Exits[i] - mindist = curdist - } - } - - if door { - rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum] - door = false - - continue // the C goto over: redo with the passage as room - } - } else { - this = *th.Dest - // For dragons check and see if (a) the hero is on a straight - // line from it, and (b) that it is within shooting distance, - // but outside of striking range. - if th.Type == 'D' && (th.Pos.Y == p.Pos.Y || th.Pos.X == p.Pos.X || - abs(th.Pos.Y-p.Pos.Y) == abs(th.Pos.X-p.Pos.X)) && - 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 false - } - } - - break + this, shot := g.chaseGoal(th, rer, ree, door, 32767) + if shot { + return false } // This now contains what we want to run to this time so we run to it. // If we hit it we either want to fight it or stop running - if !g.chase(th, this) { - if this == p.Pos { - return g.attack(th) - } else if this == *th.Dest { - for _, obj := range g.Level.Objects { - if th.Dest == &obj.Pos { - g.Level.RemoveObject(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 - } - } - } else { + if g.chase(th, this) { if th.Type == 'F' { return false } + } else { + switch this { + case g.Player.Pos: + return g.attack(th) + case *th.Dest: + g.chaseTakeObject(th) + + stoprun = th.Type != 'F' + } } g.relocate(th, g.chRet) @@ -203,14 +133,129 @@ func (g *RogueGame) chaseStep(th *Monster) (removed bool) { return false } +// chaseRooms finds the rooms of the chaser and its desire; doors do not +// count as inside rooms here (the setup of chase.c do_chase). +func (g *RogueGame) chaseRooms(th *Monster) (*Room, *Room, bool) { + p := &g.Player + + rer := th.Room // find room of chaser + 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 + } else { + ree = g.roomIn(*th.Dest) + } + + return rer, ree, g.Level.Char(th.Pos.Y, th.Pos.X) == Door +} + +// chaseGoal picks the spot the chaser runs toward this turn: the +// nearest exit toward its desire when it is in a different room, or the +// desire itself. shot means a dragon breathed flame instead of moving +// (the goal loop of chase.c do_chase). +func (g *RogueGame) chaseGoal(th *Monster, rer, ree *Room, door bool, mindist int) (Coord, bool) { + var this Coord + + for { + // If the object of our desire is in a different room, and we are + // not in a corridor, run to the door nearest to our goal. + if rer == ree { + this = *th.Dest + + return this, g.dragonBreath(th) + } + + for i := range rer.Exits { + curdist := distCp(*th.Dest, rer.Exits[i]) + if curdist < mindist { + this = rer.Exits[i] + mindist = curdist + } + } + + if !door { + return this, false + } + + rer = &g.Level.Passages[*g.Level.FlagsAt(th.Pos.Y, th.Pos.X)&FPassNum] + door = false + // the C goto over: redo with the passage as room + } +} + +// dragonBreath checks whether a dragon shoots flame at the hero instead +// of moving, and shoots it (the D block of chase.c do_chase). +func (g *RogueGame) dragonBreath(th *Monster) bool { + if th.Type != 'D' || !g.dragonShoots(th) { + return false + } + + p := &g.Player + + 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 true +} + +// dragonShoots decides whether the dragon takes the shot: the hero is +// on a straight line from it, within shooting distance but outside +// striking range, it is not cancelled, and the shot roll comes up +// (chase.c do_chase). +func (g *RogueGame) dragonShoots(th *Monster) bool { + p := &g.Player + if th.Pos.Y != p.Pos.Y && th.Pos.X != p.Pos.X && + abs(th.Pos.Y-p.Pos.Y) != abs(th.Pos.X-p.Pos.X) { + return false + } + + return distCp(th.Pos, p.Pos) <= BoltLength*BoltLength && + !th.On(Cancelled) && g.rnd(dragonShot) == 0 +} + +// chaseTakeObject has the monster pick up the object it was running to +// (the dest arm of chase.c do_chase). +func (g *RogueGame) chaseTakeObject(th *Monster) { + for _, obj := range g.Level.Objects { + if th.Dest == &obj.Pos { + g.Level.RemoveObject(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 + } + } +} + // chase finds the spot for the chaser to move closer to the chasee // (chase.c chase). Returns true if we want to keep on chasing later, false // if we reach the goal. The chosen spot lands in g.chRet. 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 @@ -226,77 +271,98 @@ func (g *RogueGame) chase(tp *Monster, ee Coord) bool { tp.Flags.Clear(Confused) } } else { - // Otherwise, find the empty spot next to the chaser that is - // closest to the chasee. This will eventually hold where we move - // to get closer. If we can't find an empty spot, we stay where we - // are. - curdist = distCp(er, ee) - g.chRet = er + curdist = g.chaseBestSpot(tp, ee) + } - ey := er.Y + 1 - if ey >= NumLines-1 { - ey = NumLines - 2 + return curdist != 0 && g.chRet != g.Player.Pos +} + +// chaseSearch is the scan state while chase looks for the step that +// gets a monster closest to its chasee. +type chaseSearch struct { + er Coord // where the chaser is + ee Coord // where it wants to go + curdist int + plcnt int +} + +// chaseBestSpot finds the empty spot next to the chaser that is closest +// to the chasee, leaving it in g.chRet; if there is none, the chaser +// stays where it is (the search half of chase.c chase). +func (g *RogueGame) chaseBestSpot(tp *Monster, ee Coord) int { + er := tp.Pos + s := chaseSearch{er: er, ee: ee, curdist: distCp(er, ee), plcnt: 1} + g.chRet = er + + ey := er.Y + 1 + if ey >= NumLines-1 { + ey = NumLines - 2 + } + + ex := er.X + 1 + if ex >= NumCols { + ex = NumCols - 1 + } + + for x := er.X - 1; x <= ex; x++ { + if x < 0 { + continue } - ex := er.X + 1 - if ex >= NumCols { - ex = NumCols - 1 - } - - for x := er.X - 1; x <= ex; x++ { - 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 - // scroll so we need to look it up to see what type - // 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 - } - } - // It can also be a Xeroc, which we shouldn't step on - if m := g.Level.MonsterAt(y, x); m != nil && m.Type == 'X' { - continue - } - // If we didn't find any scrolls at this place or it - // wasn't a scare scroll, then this place counts - thisdist := distance(y, x, ee.Y, ee.X) - if thisdist < curdist { - plcnt = 1 - g.chRet = tryp - curdist = thisdist - } else if thisdist == curdist { - if plcnt++; g.rnd(plcnt) == 0 { - g.chRet = tryp - curdist = thisdist - } - } - } - } + for y := er.Y - 1; y <= ey; y++ { + g.chaseTry(&s, y, x) } } - return curdist != 0 && g.chRet != p.Pos + return s.curdist +} + +// chaseTry scores one candidate square, reservoir-sampling among ties +// (the scan body of chase.c chase). +func (g *RogueGame) chaseTry(s *chaseSearch, y, x int) { + tryp := Coord{X: x, Y: y} + if !g.diagOk(s.er, tryp) { + return + } + + ch := g.Level.VisibleChar(y, x) + if !stepOk(ch) { + return + } + // If it is a scroll, it might be a scare monster scroll so we need + // to look it up to see what type it is. + if ch == Scroll && g.scareScrollAt(y, x) { + return + } + // It can also be a Xeroc, which we shouldn't step on + if m := g.Level.MonsterAt(y, x); m != nil && m.Type == 'X' { + return + } + // If we didn't find any scrolls at this place or it wasn't a scare + // scroll, then this place counts + thisdist := distance(y, x, s.ee.Y, s.ee.X) + if thisdist < s.curdist { + s.plcnt = 1 + g.chRet = tryp + s.curdist = thisdist + } else if thisdist == s.curdist { + if s.plcnt++; g.rnd(s.plcnt) == 0 { + g.chRet = tryp + s.curdist = thisdist + } + } +} + +// scareScrollAt reports whether the object lying at (y, x) is a scare +// monster scroll (chase.c chase). +func (g *RogueGame) scareScrollAt(y, x int) bool { + for _, obj := range g.Level.Objects { + if y == obj.Pos.Y && x == obj.Pos.X { + return obj.ScrollKind() == ScrollScareMonster + } + } + + return false } // setOldChar sets the oldch character for the monster (chase.c set_oldch). @@ -431,22 +497,23 @@ func (g *RogueGame) findDest(tp *Monster) *Coord { 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 - } + if g.roomIn(obj.Pos) == tp.Room && g.rnd(100) < prob && + !g.objectClaimed(obj) { + return &obj.Pos } } return &g.Player.Pos } + +// objectClaimed reports whether some monster already runs toward this +// object (chase.c find_dest). +func (g *RogueGame) objectClaimed(obj *Object) bool { + for _, other := range g.Level.Monsters { + if other.Dest == &obj.Pos { + return true + } + } + + return false +} From ea68df32f06d9eb729a13cc137cae130586ede3c Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:22:54 +0200 Subject: [PATCH 10/32] Decompose move.go; traps become a handler table (refactor step 7) The be_trapped switch becomes gameData.trapHandlers with one trap* method per trap kind (mystery messages split in two); moveHero splits into moveTarget/moveResolve/moveEnter/moveOnto/offMap; passageTurn gains per-axis passageTurnVertical/Horizontal. move.go is complexity-clean. Behavior and RNG call order unchanged. --- game/move.go | 488 +++++++++++++++++++++++++++++-------------------- game/tables.go | 15 ++ 2 files changed, 305 insertions(+), 198 deletions(-) diff --git a/game/move.go b/game/move.go index f59ace6..e1c15cf 100644 --- a/game/move.go +++ b/game/move.go @@ -38,108 +38,145 @@ func (g *RogueGame) moveHero(dy, dx int) { } for { - // 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 - 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) { - ch = Trap - g.Level.SetChar(nh.Y, nh.X, Trap) - g.Level.FlagsAt(nh.Y, nh.X).Set(FReal) - } - } else if p.On(Held) && ch != 'F' { - g.msg("you are being held") - - return - } + ch, fl, stop := g.moveTarget(nh) + if stop { + return } - if hitBound { - ch = ' ' // fall into the wall case below + turned, ndy, ndx := g.moveResolve(nh, ch, fl, dy, dx) + if !turned { + return } - switch ch { - case ' ', '|', '-': - if turn, ndy, ndx := g.passageTurn(dy, dx); turn { - dy, dx = ndy, ndx + // the C goto over: re-check the turned move + dy, dx = ndy, ndx - g.turnRefresh() + g.turnRefresh() - nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx} + nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx} + } +} - continue // the C goto over: re-check the turned move - } - - g.Running = false - g.After = false - case Door: - g.Running = false - if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPassage) { - g.enterRoom(nh) - } - - g.finishMove(nh, fl) - case Trap: - tr := g.springTrap(nh) - if tr == TrapDoor || tr == TrapTeleport { - return - } - - g.finishMove(nh, fl) - case Passage: - // when you're in a corridor, you don't know if you're in a maze - // room or not, and there ain't no way to find out if you're - // leaving a maze room, so it is necessary to always recalculate - // proom. - p.Room = g.roomIn(p.Pos) - g.finishMove(nh, fl) - case Floor: - if !fl.Has(FReal) { - g.springTrap(p.Pos) - } - - g.finishMove(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) - } else { - if ch != Stairs { - g.Take = ch - } - - g.finishMove(nh, fl) - } +// moveResolve acts on the square the hero stepped at: a wall may turn a +// passage runner (reported with the new deltas); anything else completes +// or refuses the move (the switch of move.c do_move). +func (g *RogueGame) moveResolve(nh Coord, ch byte, fl PlaceFlags, dy, dx int) (bool, int, int) { + switch ch { + case ' ', '|', '-': + if turn, ndy, ndx := g.passageTurn(dy, dx); turn { + return true, ndy, ndx } + g.Running = false + g.After = false + default: + g.moveEnter(nh, fl, ch) + } + + return false, 0, 0 +} + +// moveEnter completes a step onto a walkable square: doors, traps, +// passages, floor, and things (the entry arms of the move.c do_move +// switch). +func (g *RogueGame) moveEnter(nh Coord, fl PlaceFlags, ch byte) { + p := &g.Player + + switch ch { + case Door: + g.Running = false + if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPassage) { + g.enterRoom(nh) + } + case Trap: + tr := g.springTrap(nh) + if tr == TrapDoor || tr == TrapTeleport { + return + } + case Passage: + // when you're in a corridor, you don't know if you're in a maze + // room or not, and there ain't no way to find out if you're + // leaving a maze room, so it is necessary to always recalculate + // proom. + p.Room = g.roomIn(p.Pos) + case Floor: + if !fl.Has(FReal) { + g.springTrap(p.Pos) + } + default: + g.moveOnto(nh, fl, ch) + return } + + g.finishMove(nh, fl) +} + +// offMap reports coordinates outside the walkable map (move.c do_move). +func offMap(nh Coord) bool { + return nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1 +} + +// moveTarget inspects the square the hero is stepping onto: bounds and +// diagonal legality, hidden traps underfoot, and being held. stop means +// the move is refused (the checks of move.c do_move). +func (g *RogueGame) moveTarget(nh Coord) (byte, PlaceFlags, bool) { + p := &g.Player + + // Check if he tried to move off the screen or make an illegal + // diagonal move, and stop him if he did. + if offMap(nh) { + return ' ', 0, false // fall into the wall case + } + + if !g.diagOk(p.Pos, nh) { + g.After = false + g.Running = false + + return 0, 0, true + } + + 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) { + ch = Trap + g.Level.SetChar(nh.Y, nh.X, Trap) + g.Level.FlagsAt(nh.Y, nh.X).Set(FReal) + } + } else if p.On(Held) && ch != 'F' { + g.msg("you are being held") + + return 0, 0, true + } + + return ch, fl, false +} + +// moveOnto handles stepping at a monster or onto an item (the default +// arm of the move.c do_move switch). +func (g *RogueGame) moveOnto(nh Coord, fl PlaceFlags, ch byte) { + p := &g.Player + 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) + } else { + if ch != Stairs { + g.Take = ch + } + + g.finishMove(nh, fl) + } } // passageTurn checks whether a runner in a gone-room passage should turn @@ -152,44 +189,66 @@ func (g *RogueGame) passageTurn(dy, dx int) (bool, int, int) { return false, dy, dx } - 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 { - g.RunCh = 'k' - dy = -1 - } else { - g.RunCh = 'j' - dy = 1 - } - - return true, dy, 0 + if turn, ndy := g.passageTurnVertical(); turn { + return true, ndy, 0 } 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 { - g.RunCh = 'h' - dx = -1 - } else { - g.RunCh = 'l' - dx = 1 - } - - return true, 0, dx + if turn, ndx := g.passageTurnHorizontal(); turn { + return true, 0, ndx } } return false, dy, dx } +// passageTurnVertical decides whether a horizontal runner turns up or +// down at a corner (move.c do_move). +func (g *RogueGame) passageTurnVertical() (bool, int) { + p := &g.Player + + 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 { + return false, 0 + } + + if b1 { + g.RunCh = 'k' + + return true, -1 + } + + g.RunCh = 'j' + + return true, 1 +} + +// passageTurnHorizontal decides whether a vertical runner turns left or +// right at a corner (move.c do_move). +func (g *RogueGame) passageTurnHorizontal() (bool, int) { + p := &g.Player + + 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 { + return false, 0 + } + + if b1 { + g.RunCh = 'h' + + return true, -1 + } + + g.RunCh = 'l' + + return true, 1 +} + // finishMove is the move_stuff label in do_move: complete the step. func (g *RogueGame) finishMove(nh Coord, fl PlaceFlags) { p := &g.Player @@ -255,85 +314,8 @@ func (g *RogueGame) springTrap(tc Coord) TrapKind { tr := TrapKind(pp.Flags & FTrapMask) pp.Flags.Set(FSeen) - switch tr { - case TrapDoor: - g.Depth++ - g.NewLevel() - g.msg("you fell into a trap!") - case TrapBear: - g.NoMove += g.spread(3) // BEARTIME - g.msg("you are caught in a bear trap") - case TrapMystery: - switch g.rnd(11) { - case 0: - g.msg("you are suddenly in a parallel dimension") - case 1: - g.msg("the light in here suddenly seems %s", g.data.rainbow[g.rnd(len(g.data.rainbow))]) - case 2: - g.msg("you feel a sting in the side of your neck") - case 3: - g.msg("multi-colored lines swirl around you, then fade") - case 4: - g.msg("a %s light flashes in your eyes", g.data.rainbow[g.rnd(len(g.data.rainbow))]) - case 5: - g.msg("a spike shoots past your ear!") - case 6: - g.msg("%s sparks dance across your armor", g.data.rainbow[g.rnd(len(g.data.rainbow))]) - case 7: - g.msg("you suddenly feel very thirsty") - case 8: - g.msg("you feel time speed up suddenly") - case 9: - g.msg("time now seems to be going slower") - case 10: - g.msg("you pack turns %s!", g.data.rainbow[g.rnd(len(g.data.rainbow))]) - } - 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: - if g.swing(p.Stats.Lvl-1, p.Stats.ArmorClass, 1) { - p.Stats.HP -= g.roll(1, 6) - if p.Stats.HP <= 0 { - g.msg("an arrow killed you") - g.death('a') - } else { - g.msg("oh no! An arrow shot you") - } - } else { - arrow := newObject() - g.initWeapon(arrow, WeaponArrow) - arrow.Count = 1 - arrow.Pos = p.Pos - g.fall(arrow, false) - g.msg("an arrow shoots past you") - } - case TrapTeleport: - // since the hero's leaving, look() won't put a TRAP down for us, - // so we have to do it ourself - g.teleport() - g.mvaddch(tc.Y, tc.X, Trap) - case TrapDart: - if !g.swing(p.Stats.Lvl+1, p.Stats.ArmorClass, 1) { - g.msg("a small dart whizzes by your ear and vanishes") - } else { - p.Stats.HP -= g.roll(1, 4) - if p.Stats.HP <= 0 { - g.msg("a poisoned dart killed you") - g.death('d') - } - - if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) { - g.changeStrength(-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) + if h := g.data.trapHandlers[tr]; h != nil { + h(g, tc) } g.flushType() @@ -341,6 +323,116 @@ func (g *RogueGame) springTrap(tc Coord) TrapKind { return tr } +// The per-trap effect handlers, dispatched through +// gameData.trapHandlers. Each is one case of the C be_trapped switch. + +func (g *RogueGame) trapFall(Coord) { + g.Depth++ + g.NewLevel() + g.msg("you fell into a trap!") +} + +func (g *RogueGame) trapBear(Coord) { + g.NoMove += g.spread(3) // BEARTIME + g.msg("you are caught in a bear trap") +} + +func (g *RogueGame) trapMystery(Coord) { + which := g.rnd(11) + switch which { + case 0: + g.msg("you are suddenly in a parallel dimension") + case 1: + g.msg("the light in here suddenly seems %s", g.data.rainbow[g.rnd(len(g.data.rainbow))]) + case 2: + g.msg("you feel a sting in the side of your neck") + case 3: + g.msg("multi-colored lines swirl around you, then fade") + case 4: + g.msg("a %s light flashes in your eyes", g.data.rainbow[g.rnd(len(g.data.rainbow))]) + case 5: + g.msg("a spike shoots past your ear!") + default: + g.trapMysteryMore(which) + } +} + +// trapMysteryMore holds the back half of the mystery-trap messages. +func (g *RogueGame) trapMysteryMore(which int) { + switch which { + case 6: + g.msg("%s sparks dance across your armor", g.data.rainbow[g.rnd(len(g.data.rainbow))]) + case 7: + g.msg("you suddenly feel very thirsty") + case 8: + g.msg("you feel time speed up suddenly") + case 9: + g.msg("time now seems to be going slower") + case 10: + g.msg("you pack turns %s!", g.data.rainbow[g.rnd(len(g.data.rainbow))]) + } +} + +func (g *RogueGame) trapSleep(Coord) { + g.NoCommand += g.spread(5) // SLEEPTIME + + g.Player.Flags.Clear(Awake) + g.msg("a strange white mist envelops you and you fall asleep") +} + +func (g *RogueGame) trapArrow(Coord) { + p := &g.Player + if g.swing(p.Stats.Lvl-1, p.Stats.ArmorClass, 1) { + p.Stats.HP -= g.roll(1, 6) + if p.Stats.HP <= 0 { + g.msg("an arrow killed you") + g.death('a') + } else { + g.msg("oh no! An arrow shot you") + } + } else { + arrow := newObject() + g.initWeapon(arrow, WeaponArrow) + arrow.Count = 1 + arrow.Pos = p.Pos + g.fall(arrow, false) + g.msg("an arrow shoots past you") + } +} + +func (g *RogueGame) trapTeleport(tc Coord) { + // since the hero's leaving, look() won't put a TRAP down for us, + // so we have to do it ourself + g.teleport() + g.mvaddch(tc.Y, tc.X, Trap) +} + +func (g *RogueGame) trapDart(Coord) { + p := &g.Player + if !g.swing(p.Stats.Lvl+1, p.Stats.ArmorClass, 1) { + g.msg("a small dart whizzes by your ear and vanishes") + + return + } + + p.Stats.HP -= g.roll(1, 4) + if p.Stats.HP <= 0 { + g.msg("a poisoned dart killed you") + g.death('d') + } + + if !p.IsWearing(RingSustainStrength) && !g.save(VsPoison) { + g.changeStrength(-1) + } + + g.msg("a small dart just hit you in the shoulder") +} + +func (g *RogueGame) trapRust(Coord) { + g.msg("a gush of water hits you on the head") + g.rustArmor(g.Player.CurArmor) +} + // randomStep moves in a random direction if the monster/person is // confused (move.c rndmove). func (g *RogueGame) randomStep(who *Creature) Coord { diff --git a/game/tables.go b/game/tables.go index 05044f4..7455d7e 100644 --- a/game/tables.go +++ b/game/tables.go @@ -147,6 +147,10 @@ type gameData struct { // cases of the big command.c switch. Keys that re-dispatch (runs, // fight, repeat, move-on) and wizard keys stay in dispatchKey. commandHandlers map[byte]func(g *RogueGame) + + // trapHandlers dispatches each sprung trap to its effect method: + // the cases of the move.c be_trapped switch. + trapHandlers [NumTrapTypes]func(g *RogueGame, tc Coord) } // ripWall is the repeated blank wall line of the tombstone art. @@ -811,6 +815,17 @@ func newGameData() *gameData { g.After = false }, }, + + trapHandlers: [NumTrapTypes]func(*RogueGame, Coord){ + TrapDoor: (*RogueGame).trapFall, + TrapArrow: (*RogueGame).trapArrow, + TrapSleep: (*RogueGame).trapSleep, + TrapBear: (*RogueGame).trapBear, + TrapTeleport: (*RogueGame).trapTeleport, + TrapDart: (*RogueGame).trapDart, + TrapRust: (*RogueGame).trapRust, + TrapMystery: (*RogueGame).trapMystery, + }, } } From cc2efb86e89f205619d6678c0cb7f38525a3e0be Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:25:46 +0200 Subject: [PATCH 11/32] Decompose passages.go (refactor step 7) digPassages gains pickNeighbor; connectRooms splits into connOrient/connPlanDown/connPlanRight/connEnd/digCorridor around a corridorPlan struct; addPass gains addPassSpot; the shared door/ secret-door predicate becomes hiddenExit. passages.go is complexity-clean. Behavior and RNG call order unchanged. --- game/passages.go | 417 ++++++++++++++++++++++++++--------------------- 1 file changed, 229 insertions(+), 188 deletions(-) diff --git a/game/passages.go b/game/passages.go index a49682f..b9cd56e 100644 --- a/game/passages.go +++ b/game/passages.go @@ -15,204 +15,234 @@ func (g *RogueGame) digPassages() { r1 := g.rnd(MaxRooms) ingraph[r1] = true - for { + for roomcount < MaxRooms { // find a room to connect with - j := 0 - r2 := -1 - - for i := range MaxRooms { - if g.data.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 + r2 := g.pickNeighbor(r1, func(i int) bool { return !ingraph[i] }) + if r2 < 0 { + // if no adjacent rooms are outside the graph, pick a new + // room to look from for { r1 = g.rnd(MaxRooms) if ingraph[r1] { break } } - } else { - // otherwise, connect new room to the graph, and draw a tunnel - // to it - ingraph[r2] = true //nolint:gosec // G602: rnd(MaxRooms) bounded - g.connectRooms(r1, r2) - isconn[r1][r2] = true - isconn[r2][r1] = true //nolint:gosec // G602: rnd(MaxRooms) bounded - roomcount++ - } - if roomcount >= MaxRooms { - break + continue } + // otherwise, connect new room to the graph, and draw a tunnel + // to it + ingraph[r2] = true + g.connectRooms(r1, r2) + isconn[r1][r2] = true + isconn[r2][r1] = true + roomcount++ } // attempt to add passages to the graph a random number of times so that // there isn't always just one unique passage through it. for roomcount = g.rnd(5); roomcount > 0; roomcount-- { r1 = g.rnd(MaxRooms) // a random room to look from - // find an adjacent room not already connected - j := 0 - r2 := -1 - - for i := range MaxRooms { - if g.data.rdesConn[r1][i] && !isconn[r1][i] { - if j++; g.rnd(j) == 0 { - r2 = i - } - } - } - // if there is one, connect it and look for the next added passage - if j != 0 { + // find an adjacent room not already connected; if there is one, + // connect it and look for the next added passage + r2 := g.pickNeighbor(r1, func(i int) bool { return !isconn[r1][i] }) + if r2 >= 0 { g.connectRooms(r1, r2) isconn[r1][r2] = true - isconn[r2][r1] = true //nolint:gosec // G602: rnd(MaxRooms) bounded + isconn[r2][r1] = true } } g.numberPassages() } +// pickNeighbor reservoir-picks an adjacent room for which ok holds, or +// -1 when there is none (passages.c do_passages). +func (g *RogueGame) pickNeighbor(r1 int, ok func(int) bool) int { + j := 0 + r2 := -1 + + for i := range MaxRooms { + if g.data.rdesConn[r1][i] && ok(i) { + if j++; g.rnd(j) == 0 { + r2 = i + } + } + } + + return r2 +} + +// corridorPlan is the movement setup connectRooms computes before it +// digs (the local variables of passages.c conn). +type corridorPlan struct { + rpf, rpt *Room // the rooms being joined + del Coord // direction of move + turnDelta Coord // direction to turn + spos, epos Coord // start and end of move + distance, turnDistance int // how far to move and to turn +} + // connectRooms draws a corridor from a room in a certain direction // (passages.c conn). func (g *RogueGame) connectRooms(r1, r2 int) { - var ( - rm int - direc byte - ) - - if r1 < r2 { - rm = r1 - if r1+1 == r2 { - direc = 'r' - } else { - direc = 'd' - } - } else { - rm = r2 - if r2+1 == r1 { - direc = 'r' - } else { - direc = 'd' - } - } - - rpf := &g.Level.Rooms[rm] - - // Set up the movement variables, in two cases: first drawing one down. - var ( - rpt *Room - del, turnDelta, spos, epos Coord - distance, turnDistance int - ) + rm, direc := connOrient(r1, r2) + var plan corridorPlan 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) { - 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) { - break - } - } - } - - distance = abs(spos.Y-epos.Y) - 1 // distance to move - - 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 - rpt = &g.Level.Rooms[rmt] - 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) { - 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) { - 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) + plan = g.connPlanDown(rm) + } else { + plan = g.connPlanRight(rm) } - turnSpot := g.rnd(distance-1) + 1 // where turn starts + turnSpot := g.rnd(plan.distance-1) + 1 // where turn starts // Draw in the doors on either side of the passage or just put #'s if // the rooms are gone. - if !rpf.Flags.Has(Gone) { - g.door(rpf, spos) - } else { - g.putPassage(spos) + g.connEnd(plan.rpf, plan.spos) + g.connEnd(plan.rpt, plan.epos) + + g.digCorridor(plan, turnSpot) +} + +// connOrient picks the upper-left room of the pair and the digging +// direction: right for horizontal neighbors, down otherwise (passages.c +// conn). +func connOrient(r1, r2 int) (int, byte) { + rm := min(r1, r2) + if abs(r1-r2) == 1 { + return rm, 'r' + } + + return rm, 'd' +} + +// connPlanDown sets up the movement variables for a corridor drawn +// downward (passages.c conn). +func (g *RogueGame) connPlanDown(rm int) corridorPlan { + rpf := &g.Level.Rooms[rm] + rpt := &g.Level.Rooms[rm+3] // room pointer of dest + + plan := corridorPlan{ + rpf: rpf, + rpt: rpt, + 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 { + plan.spos.X = rpf.Pos.X + g.rnd(rpf.Max.X-2) + 1 + + plan.spos.Y = rpf.Pos.Y + rpf.Max.Y - 1 + if !rpf.Flags.Has(Maze) || + g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) { + break + } + } } if !rpt.Flags.Has(Gone) { - g.door(rpt, epos) - } else { - g.putPassage(epos) + for { + plan.epos.X = rpt.Pos.X + g.rnd(rpt.Max.X-2) + 1 + if !rpt.Flags.Has(Maze) || + g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) { + break + } + } } - // Get ready to move... - curr := spos + + plan.distance = abs(plan.spos.Y-plan.epos.Y) - 1 // distance to move + + plan.turnDelta.Y = 0 // direction to turn + if plan.spos.X < plan.epos.X { + plan.turnDelta.X = 1 + } else { + plan.turnDelta.X = -1 + } + + plan.turnDistance = abs(plan.spos.X - plan.epos.X) // how far to turn + + return plan +} + +// connPlanRight sets up the movement variables for a corridor drawn to +// the right (passages.c conn). +func (g *RogueGame) connPlanRight(rm int) corridorPlan { + rpf := &g.Level.Rooms[rm] + rpt := &g.Level.Rooms[rm+1] + + plan := corridorPlan{ + rpf: rpf, + rpt: rpt, + del: Coord{X: 1, Y: 0}, + spos: rpf.Pos, + epos: rpt.Pos, + } + + if !rpf.Flags.Has(Gone) { + for { + plan.spos.X = rpf.Pos.X + rpf.Max.X - 1 + + plan.spos.Y = rpf.Pos.Y + g.rnd(rpf.Max.Y-2) + 1 + if !rpf.Flags.Has(Maze) || + g.Level.FlagsAt(plan.spos.Y, plan.spos.X).Has(FPassage) { + break + } + } + } + + if !rpt.Flags.Has(Gone) { + for { + plan.epos.Y = rpt.Pos.Y + g.rnd(rpt.Max.Y-2) + 1 + if !rpt.Flags.Has(Maze) || + g.Level.FlagsAt(plan.epos.Y, plan.epos.X).Has(FPassage) { + break + } + } + } + + plan.distance = abs(plan.spos.X-plan.epos.X) - 1 + if plan.spos.Y < plan.epos.Y { + plan.turnDelta.Y = 1 + } else { + plan.turnDelta.Y = -1 + } + + plan.turnDelta.X = 0 + plan.turnDistance = abs(plan.spos.Y - plan.epos.Y) + + return plan +} + +// connEnd draws a corridor end: a door on a real room, a passage square +// on a gone one (passages.c conn). +func (g *RogueGame) connEnd(rp *Room, pos Coord) { + if !rp.Flags.Has(Gone) { + g.door(rp, pos) + } else { + g.putPassage(pos) + } +} + +// digCorridor digs from spos to epos, turning at turnSpot (the digging +// loop of passages.c conn). +func (g *RogueGame) digCorridor(plan corridorPlan, turnSpot int) { + curr := plan.spos + distance := plan.distance + turnDistance := plan.turnDistance + for distance > 0 { // Move to new position - curr.X += del.X - curr.Y += del.Y + curr.X += plan.del.X + curr.Y += plan.del.Y // Check if we are at the turn place, if so do the turn if distance == turnSpot { for ; turnDistance > 0; turnDistance-- { g.putPassage(curr) - curr.X += turnDelta.X - curr.Y += turnDelta.Y + curr.X += plan.turnDelta.X + curr.Y += plan.turnDelta.Y } } // Continue digging along @@ -221,10 +251,10 @@ func (g *RogueGame) connectRooms(r1, r2 int) { distance-- } - curr.X += del.X + curr.X += plan.del.X - curr.Y += del.Y - if curr != epos { + curr.Y += plan.del.Y + if curr != plan.epos { g.msg("warning, connectivity problem on this level") } } @@ -270,38 +300,50 @@ func (g *RogueGame) door(rm *Room, cp Coord) { func (g *RogueGame) addPass() { for y := 1; y < NumLines-1; y++ { 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 == '-')) { - ch := pp.Ch - if pp.Flags.Has(FPassage) { - ch = Passage - } - - pp.Flags.Set(FSeen) - g.move(y, x) - - switch { - case pp.Monst != nil: - pp.Monst.OldCh = pp.Ch - case pp.Flags.Has(FReal): - g.addch(ch) - default: - g.standout() - - if pp.Flags.Has(FPassage) { - g.addch(Passage) - } else { - g.addch(Door) - } - - g.standend() - } - } + g.addPassSpot(g.Level.At(y, x), y, x) } } } +// addPassSpot shows one passage or door square for the wizard (the loop +// body of passages.c add_pass). +func (g *RogueGame) addPassSpot(pp *Place, y, x int) { + if !pp.Flags.Has(FPassage) && !hiddenExit(pp.Flags, pp.Ch) { + return + } + + ch := pp.Ch + if pp.Flags.Has(FPassage) { + ch = Passage + } + + pp.Flags.Set(FSeen) + g.move(y, x) + + switch { + case pp.Monst != nil: + pp.Monst.OldCh = pp.Ch + case pp.Flags.Has(FReal): + g.addch(ch) + default: + g.standout() + + if pp.Flags.Has(FPassage) { + g.addch(Passage) + } else { + g.addch(Door) + } + + g.standend() + } +} + +// hiddenExit reports a door, or a secret door still drawn as a wall +// (passages.c add_pass / numpass). +func hiddenExit(fp PlaceFlags, ch byte) bool { + return ch == Door || (!fp.Has(FReal) && (ch == '|' || ch == '-')) +} + // numberPassages assigns a number to each passageway (passages.c passnum). func (g *RogueGame) numberPassages() { g.pnum = 0 @@ -338,8 +380,7 @@ func (g *RogueGame) numberPassage(y, x int) { } // check to see if it is a door or secret door, i.e., a new exit, or a // numerable type of place - if ch := g.Level.Char(y, x); ch == Door || - (!fp.Has(FReal) && (ch == '|' || ch == '-')) { + if hiddenExit(*fp, g.Level.Char(y, x)) { rp := &g.Level.Passages[g.pnum] rp.Exits = append(rp.Exits, Coord{Y: y, X: x}) } else if !fp.Has(FPassage) { From 5849dddcf0564d0fa8ab8f8f324a320a1c283a18 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:27:56 +0200 Subject: [PATCH 12/32] Decompose options.go (refactor step 7) ParseOpts splits into parseOptName/parseOptValue/parseInvType; getStr gains endsInput/getStrErase/getStrEdit. options.go is complexity-clean. Behavior unchanged. --- game/options.go | 247 ++++++++++++++++++++++++++---------------------- 1 file changed, 132 insertions(+), 115 deletions(-) diff --git a/game/options.go b/game/options.go index 15fea9c..7c3d594 100644 --- a/game/options.go +++ b/game/options.go @@ -205,55 +205,11 @@ func (g *RogueGame) getStr(opt *string, win *Window) int { ) for { c = g.readchar() - if c == '\n' || c == '\r' || c == Escape { + if endsInput(c) || (len(buf) == 0 && c == '-' && !onStd) { 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 != ' ') { - continue // C beeps here - } - - buf = append(buf, c) - win.AddStr(unctrl(c)) - win.Clrtoeol() - g.scr.RefreshWin(win) + buf = g.getStrEdit(win, buf, c, oy, ox) } if len(buf) > 0 { // only change option if something has been typed @@ -277,6 +233,52 @@ func (g *RogueGame) getStr(opt *string, win *Window) int { } } +// endsInput reports the keys that finish line input (options.c get_str). +func endsInput(c byte) bool { + return c == '\n' || c == '\r' || c == Escape +} + +// getStrErase deletes the last character of the buffer (options.c +// get_str). +func getStrErase(win *Window, buf []byte, oy, ox int) []byte { + if len(buf) > 0 { + buf = buf[:len(buf)-1] + win.Move(oy, ox+len(displayStr(buf))) + } + + win.Clrtoeol() + + return buf +} + +// getStrEdit applies one key to the line editor's buffer: erase, kill, +// home expansion, or a typed character (options.c get_str). +func (g *RogueGame) getStrEdit(win *Window, buf []byte, c byte, oy, ox int) []byte { + switch { + case c == 8 || c == 0x7f: + buf = getStrErase(win, buf, oy, ox) + case c == CTRL('U'): // kill character + buf = buf[:0] + + win.Move(oy, ox) + win.Clrtoeol() + case len(buf) == 0 && c == '~': + buf = append(buf, g.Home...) + win.AddStr(g.Home) + win.Clrtoeol() + case len(buf) >= MaxInp || (!isPrint(c) && c != ' '): + return buf // C beeps here + default: + buf = append(buf, c) + win.AddStr(unctrl(c)) + win.Clrtoeol() + } + + g.scr.RefreshWin(win) + + return buf +} + // displayStr renders a buffer the way the input echo did. func displayStr(buf []byte) string { var sb strings.Builder @@ -337,75 +339,7 @@ func (g *RogueGame) ParseOpts(str string) { 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 { - // Skip to start of string value - 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:] - } - } - // Skip to end of string value - end := strings.IndexByte(val, ',') - 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 g.data.invTName { - if strings.HasPrefix(tn, w) { - *op.intP = ti - - break - } - } - } else { - *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 + rest := g.parseOptName(optlist, str[:i], str[i:]) // skip to start of next option name for rest != "" && !isAlpha(rest[0]) { rest = rest[1:] @@ -415,6 +349,89 @@ func (g *RogueGame) ParseOpts(str string) { } } +// parseOptName applies one named option, returning the unconsumed +// remainder: "name" turns a boolean on, "noname" turns it off, and +// string options consume a value (the option scan of options.c +// parse_opts). +func (g *RogueGame) parseOptName(optlist []optDesc, name, rest string) string { + for oi := range optlist { + op := &optlist[oi] + + isBoolOpt := op.kind == optBool || op.kind == optSeeFloor + if strings.HasPrefix(op.name, name) && name != "" { + if isBoolOpt { + *op.boolP = true + + return rest + } + + return g.parseOptValue(op, rest) + } + + if isBoolOpt && strings.HasPrefix(name, "no") && + strings.HasPrefix(op.name, name[2:]) { + *op.boolP = false + + return rest + } + } + + return rest +} + +// parseOptValue consumes an option's "=value" from rest, storing it, +// and returns the remainder (the string arm of options.c parse_opts). +func (g *RogueGame) parseOptValue(op *optDesc, rest string) string { + // Skip to start of string value + 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:] + } + } + // Skip to end of string value + end := strings.IndexByte(val, ',') + if end < 0 { + end = len(val) + } + + word := val[:end] + + if op.kind == optInvT { + g.parseInvType(op, word) + } else { + *op.strP = prefix + strucpy(word) + } + + return val[end:] +} + +// parseInvType matches an inventory-style name by prefix (options.c +// parse_opts). +func (g *RogueGame) parseInvType(op *optDesc, word string) { + // check for type of inventory + if word != "" { + word = string(toUpper(word[0])) + word[1:] + } + + for ti, tn := range g.data.invTName { + if strings.HasPrefix(tn, word) { + *op.intP = ti + + break + } + } +} + // strucpy copies a string keeping only printable characters, capped at // MAXINP (options.c strucpy). func strucpy(s string) string { From c6dae3cf3d1e5970fc009493cde4f8cdd763109a Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:28:30 +0200 Subject: [PATCH 13/32] Finish getStr split (getStrResult) --- game/options.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/game/options.go b/game/options.go index 7c3d594..95fe9a2 100644 --- a/game/options.go +++ b/game/options.go @@ -223,6 +223,12 @@ func (g *RogueGame) getStr(opt *string, win *Window) int { g.Msgs.Mpos += len(buf) } + return getStrResult(c) +} + +// getStrResult maps the terminating key to the C return code (options.c +// get_str). +func getStrResult(c byte) int { switch c { case '-': return Minus From 0274460e62f5e9666c7a9a835fde32454c3846f5 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:31:23 +0200 Subject: [PATCH 14/32] Decompose pack.go (refactor step 7) addPack splits into pickupScareScroll and packInsert with packScanKind/packScanWhich/packMatch/packMatchGroup for the C linked-list walk; promptPackItem gains repeatLastItem and promptItemPurpose; inventory's empty-handed messages flatten via chooseTerse. pack.go is complexity-clean. Behavior unchanged. --- game/pack.go | 319 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 195 insertions(+), 124 deletions(-) diff --git a/game/pack.go b/game/pack.go index faeac49..234b853 100644 --- a/game/pack.go +++ b/game/pack.go @@ -17,105 +17,13 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { } // Check for and deal with scare monster scrolls - if obj.Kind == KindScroll && obj.ScrollKind() == ScrollScareMonster && obj.Flags.Has(WasFound) { - g.Level.RemoveObject(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") - + if g.pickupScareScroll(obj) { return } - if len(p.Pack) == 0 { - p.Pack = append(p.Pack, obj) - obj.PackCh = p.nextPackChar() - p.Inpack++ - } else { - // Walk the pack looking for the insertion point, keeping items of - // one type together and merging stackable/grouped items — a direct - // translation of the C linked-list walk. lp is the index to insert - // 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 - for p.Pack[i].Kind == obj.Kind && p.Pack[i].Which != obj.Which { - lp = i - if i+1 >= len(p.Pack) { - break - } - - i++ - } - - op := p.Pack[i] - if op.Kind == obj.Kind && op.Which == obj.Which { - switch { - case op.Kind.MergesInPack(): - if !g.packRoom(fromFloor, obj) { - return - } - - op.Count++ - obj = op - lp = -1 - merged = true - case obj.Group != 0: - lp = i - for p.Pack[i].Kind == obj.Kind && - p.Pack[i].Which == obj.Which && - p.Pack[i].Group != obj.Group { - lp = i - 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 - } - default: - lp = i - } - } - - break - } - - if !merged && lp != -1 { - if !g.packRoom(fromFloor, obj) { - return - } - - obj.PackCh = p.nextPackChar() - p.Pack = append(p.Pack[:lp+1], - append([]*Object{obj}, p.Pack[lp+1:]...)...) - } + obj, ok := g.packInsert(obj, fromFloor) + if !ok { + return } obj.Flags.Set(WasFound) @@ -141,6 +49,163 @@ func (g *RogueGame) addPack(obj *Object, silent bool) { } } +// pickupScareScroll crumbles a found scare monster scroll when it is +// picked up again; it reports whether it did (pack.c add_pack). +func (g *RogueGame) pickupScareScroll(obj *Object) bool { + if obj.Kind != KindScroll || obj.ScrollKind() != ScrollScareMonster || + !obj.Flags.Has(WasFound) { + return false + } + + p := &g.Player + + g.Level.RemoveObject(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 true +} + +// packInsert places the object in the pack, keeping items of one type +// together and merging stackable and grouped items — a translation of +// the C linked-list walk in pack.c add_pack. It returns the pack entry +// the object ended up as; ok is false when the pack has no room. +func (g *RogueGame) packInsert(obj *Object, fromFloor bool) (*Object, bool) { + p := &g.Player + if len(p.Pack) == 0 { + p.Pack = append(p.Pack, obj) + obj.PackCh = p.nextPackChar() + p.Inpack++ + + return obj, true + } + + merged := false + + // lp is the index to insert after; -1 after a merge means no + // insertion. + i, lp := packScanKind(p.Pack, obj.Kind) + if i < len(p.Pack) { + i, lp = packScanWhich(p.Pack, obj, i, lp) + + if op := p.Pack[i]; op.Kind == obj.Kind && op.Which == obj.Which { + var ok bool + + obj, lp, merged, ok = g.packMatch(obj, op, i, lp, fromFloor) + if !ok { + return nil, false + } + } + } + + if !merged && lp != -1 { + if !g.packRoom(fromFloor, obj) { + return nil, false + } + + obj.PackCh = p.nextPackChar() + p.Pack = append(p.Pack[:lp+1], + append([]*Object{obj}, p.Pack[lp+1:]...)...) + } + + return obj, true +} + +// packScanKind scans to the first pack entry of this kind, returning +// its index (or the pack length) and the entry to insert after (the +// outer scan of pack.c add_pack). +func packScanKind(pack []*Object, kind ObjectKind) (int, int) { + lp := -1 + + i := 0 + for ; i < len(pack); i++ { + if pack[i].Kind == kind { + break + } + + lp = i + } + + return i, lp +} + +// packScanWhich scans within the kind group for the matching subtype +// (the inner scan of pack.c add_pack). +func packScanWhich(pack []*Object, obj *Object, i, lp int) (int, int) { + for pack[i].Kind == obj.Kind && pack[i].Which != obj.Which { + lp = i + if i+1 >= len(pack) { + break + } + + i++ + } + + return i, lp +} + +// packMatch merges the object with a matching pack entry when possible: +// stackables merge counts, grouped missiles rejoin their bundle, and +// anything else marks the insertion point (the matched-subtype switch +// of pack.c add_pack). It returns the resulting entry, the insert-after +// index, whether a merge happened, and ok false when the pack is full. +func (g *RogueGame) packMatch(obj, op *Object, i, lp int, fromFloor bool) (*Object, int, bool, bool) { + switch { + case op.Kind.MergesInPack(): + if !g.packRoom(fromFloor, obj) { + return nil, 0, false, false + } + + op.Count++ + + return op, -1, true, true + case obj.Group != 0: + return g.packMatchGroup(obj, i, fromFloor) + default: + return obj, i, false, true + } +} + +// packMatchGroup rejoins a grouped missile bundle with its group entry +// (the o_group arm of pack.c add_pack). +func (g *RogueGame) packMatchGroup(obj *Object, i int, fromFloor bool) (*Object, int, bool, bool) { + p := &g.Player + + lp := i + for p.Pack[i].Kind == obj.Kind && + p.Pack[i].Which == obj.Which && + p.Pack[i].Group != obj.Group { + lp = i + 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 nil, 0, false, false + } + + return op, -1, true, true + } + + return obj, lp, false, true +} + // packRoom sees if there's room in the pack; if not, prints an appropriate // message (pack.c pack_room). func (g *RogueGame) packRoom(fromFloor bool, obj *Object) bool { @@ -219,18 +284,11 @@ func (g *RogueGame) inventory(list []*Object, kind ObjectKind) bool { } if g.NObjs == 0 { - if g.Options.Terse { - if kind == KindNone { - g.msg("empty handed") - } else { - g.msg("nothing appropriate") - } + if kind == KindNone { + g.msg("%s", g.chooseTerse("empty handed", "you are empty handed")) } else { - if kind == KindNone { - g.msg("you are empty handed") - } else { - g.msg("you don't have anything appropriate") - } + g.msg("%s", g.chooseTerse("nothing appropriate", + "you don't have anything appropriate")) } return false @@ -345,27 +403,12 @@ func (g *RogueGame) promptPackItem(purpose string, kind ObjectKind) (obj *Object } if g.Again { - if g.LastPick != nil { - return g.LastPick, true - } - - g.msg("you ran out") - - return nil, false + return g.repeatLastItem() } for { - if !g.Options.Terse { - g.addmsgf("which object do you want to ") - } + g.promptItemPurpose(purpose) - g.addmsgf("%s", purpose) - - if g.Options.Terse { - g.addmsgf(" what") - } - - g.msg("? (* for list): ") ch := g.readchar() g.Msgs.Mpos = 0 // Give the poor player a chance to abort the command @@ -399,6 +442,34 @@ func (g *RogueGame) promptPackItem(purpose string, kind ObjectKind) (obj *Object } } +// repeatLastItem replays the previous selection for the repeat command +// (pack.c get_item). +func (g *RogueGame) repeatLastItem() (*Object, bool) { + if g.LastPick != nil { + return g.LastPick, true + } + + g.msg("you ran out") + + return nil, false +} + +// promptItemPurpose prints the "which object do you want to ...?" +// prompt (pack.c get_item). +func (g *RogueGame) promptItemPurpose(purpose string) { + if !g.Options.Terse { + g.addmsgf("which object do you want to ") + } + + g.addmsgf("%s", purpose) + + if g.Options.Terse { + g.addmsgf(" what") + } + + g.msg("? (* for list): ") +} + // money adds or subtracts gold from the pack (pack.c money). func (g *RogueGame) money(value int) { p := &g.Player From 0b798c9c8200f598ab33c07f0c561207a003f663 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 7 Jul 2026 03:31:53 +0200 Subject: [PATCH 15/32] Tidy pack.go split: drop unused lp param and named returns --- game/pack.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/game/pack.go b/game/pack.go index 234b853..cb86121 100644 --- a/game/pack.go +++ b/game/pack.go @@ -98,7 +98,7 @@ func (g *RogueGame) packInsert(obj *Object, fromFloor bool) (*Object, bool) { if op := p.Pack[i]; op.Kind == obj.Kind && op.Which == obj.Which { var ok bool - obj, lp, merged, ok = g.packMatch(obj, op, i, lp, fromFloor) + obj, lp, merged, ok = g.packMatch(obj, op, i, fromFloor) if !ok { return nil, false } @@ -156,7 +156,7 @@ func packScanWhich(pack []*Object, obj *Object, i, lp int) (int, int) { // anything else marks the insertion point (the matched-subtype switch // of pack.c add_pack). It returns the resulting entry, the insert-after // index, whether a merge happened, and ok false when the pack is full. -func (g *RogueGame) packMatch(obj, op *Object, i, lp int, fromFloor bool) (*Object, int, bool, bool) { +func (g *RogueGame) packMatch(obj, op *Object, i int, fromFloor bool) (*Object, int, bool, bool) { switch { case op.Kind.MergesInPack(): if !g.packRoom(fromFloor, obj) { @@ -393,8 +393,9 @@ func (g *RogueGame) pickyInven() { } // promptPackItem picks something out of a pack for a purpose (pack.c -// get_item); ok reports whether the player chose an item. -func (g *RogueGame) promptPackItem(purpose string, kind ObjectKind) (obj *Object, ok bool) { +// get_item); the second result reports whether the player chose an +// item. +func (g *RogueGame) promptPackItem(purpose string, kind ObjectKind) (*Object, bool) { p := &g.Player if len(p.Pack) == 0 { g.msg("you aren't carrying anything") From e1f065e783130d1cabe71f1dc5b44aae9d5c57b8 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:50:44 +0700 Subject: [PATCH 16/32] Decompose things.go (refactor step 7) inventoryName splits into nameScroll/nameFood/nameWeapon/nameArmor/ describeWorn/fixNameCase; newThing gains newFoodThing/newWeaponThing/ newArmorThing/newRingThing; dropCheck gains dropRing; addLine splits into addLineSlow/addLinePaged/addLineOverlay. things.go is complexity-clean. Behavior and RNG call order unchanged. --- game/things.go | 484 +++++++++++++++++++++++++++++-------------------- 1 file changed, 289 insertions(+), 195 deletions(-) diff --git a/game/things.go b/game/things.go index d7a9d78..147cebe 100644 --- a/game/things.go +++ b/game/things.go @@ -23,107 +23,147 @@ func (g *RogueGame) inventoryName(obj *Object, drop bool) string { case KindWand: g.nameit(&pb, obj, it.WandType[which], it.WandMade[which], &it.Sticks[which], chargeStr) case KindScroll: - if obj.Count == 1 { - pb.WriteString("A scroll ") - } else { - fmt.Fprintf(&pb, "%d scrolls ", obj.Count) - } - - op := &it.Scrolls[which] - - switch { - case op.Know: - fmt.Fprintf(&pb, "of %s", op.Name) - case op.Guess != "": - fmt.Fprintf(&pb, "called %s", op.Guess) - default: - fmt.Fprintf(&pb, "titled '%s'", it.ScrNames[which]) - } + g.nameScroll(&pb, obj) case KindFood: - if which == 1 { - if obj.Count == 1 { - fmt.Fprintf(&pb, "A%s %s", vowelstr(g.Fruit), g.Fruit) - } else { - fmt.Fprintf(&pb, "%d %ss", obj.Count, g.Fruit) - } - } else { - if obj.Count == 1 { - pb.WriteString("Some food") - } else { - fmt.Fprintf(&pb, "%d rations of food", obj.Count) - } - } + g.nameFood(&pb, obj) 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) - } + g.nameWeapon(&pb, obj) case KindArmor: - sp := it.Armors[which].Name - if obj.Flags.Has(Known) { - fmt.Fprintf(&pb, "%s %s [", num(g.data.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) - } + g.nameArmor(&pb, obj) case KindAmulet: pb.WriteString("The Amulet of Yendor") case KindGold: fmt.Fprintf(&pb, "%d Gold pieces", obj.GoldValue) } - out := pb.String() + return fixNameCase(g.describeWorn(obj, pb.String()), drop) +} - if g.InvDescribe { - p := &g.Player - if obj == p.CurArmor { - out += " (being worn)" - } - - if obj == p.CurWeapon { - out += " (weapon in hand)" - } - - switch obj { - case p.CurRing[Left]: - out += " (on left hand)" - case p.CurRing[Right]: - out += " (on right hand)" - } +// nameScroll writes a scroll's inventory name (things.c inv_name). +func (g *RogueGame) nameScroll(pb *strings.Builder, obj *Object) { + if obj.Count == 1 { + pb.WriteString("A scroll ") + } else { + fmt.Fprintf(pb, "%d scrolls ", obj.Count) } - if out != "" { - if drop && isUpper(out[0]) { - out = string(toLower(out[0])) + out[1:] - } else if !drop && isLower(out[0]) { - out = string(toUpper(out[0])) + out[1:] + op := &g.Items.Scrolls[obj.Which] + + switch { + case op.Know: + fmt.Fprintf(pb, "of %s", op.Name) + case op.Guess != "": + fmt.Fprintf(pb, "called %s", op.Guess) + default: + fmt.Fprintf(pb, "titled '%s'", g.Items.ScrNames[obj.Which]) + } +} + +// nameFood writes a food item's inventory name; which 1 is the fruit +// (things.c inv_name). +func (g *RogueGame) nameFood(pb *strings.Builder, obj *Object) { + if obj.Which == 1 { + if obj.Count == 1 { + fmt.Fprintf(pb, "A%s %s", vowelstr(g.Fruit), g.Fruit) + } else { + fmt.Fprintf(pb, "%d %ss", obj.Count, g.Fruit) } + + return + } + + if obj.Count == 1 { + pb.WriteString("Some food") + } else { + fmt.Fprintf(pb, "%d rations of food", obj.Count) + } +} + +// nameWeapon writes a weapon's inventory name (things.c inv_name). +func (g *RogueGame) nameWeapon(pb *strings.Builder, obj *Object) { + sp := g.Items.Weapons[obj.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) + } +} + +// nameArmor writes an armor's inventory name (things.c inv_name). +func (g *RogueGame) nameArmor(pb *strings.Builder, obj *Object) { + sp := g.Items.Armors[obj.Which].Name + if obj.Flags.Has(Known) { + fmt.Fprintf(pb, "%s %s [", + num(g.data.aClass[obj.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) + } +} + +// describeWorn appends the equipped-status notes to an inventory name +// (things.c inv_name). +func (g *RogueGame) describeWorn(obj *Object, out string) string { + if !g.InvDescribe { + return out + } + + p := &g.Player + if obj == p.CurArmor { + out += " (being worn)" + } + + if obj == p.CurWeapon { + out += " (weapon in hand)" + } + + switch obj { + case p.CurRing[Left]: + out += " (on left hand)" + case p.CurRing[Right]: + out += " (on right hand)" + } + + return out +} + +// fixNameCase upper- or lowercases the leading letter to suit the +// sentence it will land in (things.c inv_name). +func fixNameCase(out string, drop bool) string { + if out == "" { + return out + } + + if drop && isUpper(out[0]) { + return string(toLower(out[0])) + out[1:] + } + + if !drop && isLower(out[0]) { + return string(toUpper(out[0])) + out[1:] } return out @@ -192,25 +232,33 @@ func (g *RogueGame) dropCheck(obj *Object) bool { p.CurArmor = nil default: - hand := Right - if obj == p.CurRing[Left] { - hand = Left - } - - p.CurRing[hand] = nil - - switch obj.RingKind() { - case RingAddStrength: - g.changeStrength(-obj.Bonus) - case RingSeeInvisible: - g.unsee(0) - g.Extinguish(DUnsee) - } + g.dropRing(obj) } return true } +// dropRing takes a worn ring off with its side effects (things.c +// dropcheck). +func (g *RogueGame) dropRing(obj *Object) { + p := &g.Player + + hand := Right + if obj == p.CurRing[Left] { + hand = Left + } + + p.CurRing[hand] = nil + + switch obj.RingKind() { + case RingAddStrength: + g.changeStrength(-obj.Bonus) + case RingSeeInvisible: + g.unsee(0) + g.Extinguish(DUnsee) + } +} + // newThing returns a new random thing for the dungeon (things.c new_thing). func (g *RogueGame) newThing() *Object { cur := newObject() @@ -236,47 +284,13 @@ func (g *RogueGame) newThing() *Object { cur.Kind = KindScroll cur.Which = pickOne(g, g.Items.Scrolls[:]) case 2: - cur.Kind = KindFood - - g.Player.NoFood = 0 - if g.rnd(10) != 0 { - cur.Which = 0 - } else { - cur.Which = 1 - } + g.newFoodThing(cur) 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 - } else if r < 15 { - cur.HPlus += g.rnd(3) + 1 - } + g.newWeaponThing(cur) case 4: - cur.Kind = KindArmor - cur.Which = pickOne(g, g.Items.Armors[:]) - - cur.ArmorClass = g.data.aClass[cur.Which] - if r := g.rnd(100); r < 20 { - cur.Flags.Set(Cursed) - cur.ArmorClass += g.rnd(3) + 1 - } else if r < 28 { - cur.ArmorClass -= g.rnd(3) + 1 - } + g.newArmorThing(cur) case 5: - cur.Kind = KindRing - - cur.Which = pickOne(g, g.Items.Rings[:]) - switch cur.RingKind() { - case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage: - if cur.Bonus = g.rnd(3); cur.Bonus == 0 { - cur.Bonus = -1 - cur.Flags.Set(Cursed) - } - case RingAggravateMonsters, RingTeleportation: - cur.Flags.Set(Cursed) - } + g.newRingThing(cur) case 6: cur.Kind = KindWand cur.Which = pickOne(g, g.Items.Sticks[:]) @@ -286,6 +300,63 @@ func (g *RogueGame) newThing() *Object { return cur } +// newFoodThing rolls food, one time in ten the fruit (things.c +// new_thing). +func (g *RogueGame) newFoodThing(cur *Object) { + cur.Kind = KindFood + + g.Player.NoFood = 0 + if g.rnd(10) != 0 { + cur.Which = 0 + } else { + cur.Which = 1 + } +} + +// newWeaponThing rolls a weapon, sometimes cursed or blessed (things.c +// new_thing). +func (g *RogueGame) newWeaponThing(cur *Object) { + 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 + } else if r < 15 { + cur.HPlus += g.rnd(3) + 1 + } +} + +// newArmorThing rolls armor, sometimes cursed or blessed (things.c +// new_thing). +func (g *RogueGame) newArmorThing(cur *Object) { + cur.Kind = KindArmor + cur.Which = pickOne(g, g.Items.Armors[:]) + + cur.ArmorClass = g.data.aClass[cur.Which] + if r := g.rnd(100); r < 20 { + cur.Flags.Set(Cursed) + cur.ArmorClass += g.rnd(3) + 1 + } else if r < 28 { + cur.ArmorClass -= g.rnd(3) + 1 + } +} + +// newRingThing rolls a ring, cursing the bad ones (things.c new_thing). +func (g *RogueGame) newRingThing(cur *Object) { + cur.Kind = KindRing + + cur.Which = pickOne(g, g.Items.Rings[:]) + switch cur.RingKind() { + case RingAddStrength, RingProtection, RingDexterity, RingIncreaseDamage: + if cur.Bonus = g.rnd(3); cur.Bonus == 0 { + cur.Bonus = -1 + cur.Flags.Set(Cursed) + } + case RingAggravateMonsters, RingTeleportation: + cur.Flags.Set(Cursed) + } +} + // pickOne picks an item out of a list of possible objects using their // cumulative probabilities (things.c pick_one). func pickOne(g *RogueGame, info []ObjInfo) int { @@ -423,7 +494,6 @@ const flushSentinel = "\x00" func (g *RogueGame) addLine(format string, a ...any) int { pg := &g.invPage - prompt := "--Press space to continue--" isFlush := format == flushSentinel var line string @@ -440,69 +510,93 @@ func (g *RogueGame) addLine(format string, a ...any) int { } if g.Options.InvType == InvSlow { - if !isFlush && line != "" { - if g.msg("%s", line) == Escape { - return Escape - } - } + return g.addLineSlow(line, isFlush) + } - pg.lineCnt++ - } else { - if !pg.init { - pg.maxlen = len(prompt) - pg.init = true - } + g.addLinePaged(line, isFlush) - 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() + return ^Escape +} - 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(' ') - g.scr.Std.CopyFrom(saved) - g.refresh() - } else { - g.scr.Hw.MvAddStr(NumLines-1, 0, prompt) - g.scr.RefreshWin(g.scr.Hw) - g.waitFor(' ') - g.scr.Hw.Clear() - g.refresh() - } - - pg.newpage = true - pg.lineCnt = 0 - pg.maxlen = len(prompt) - } - - 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 +// addLineSlow shows one discovery line as a message (the slow-inventory +// arm of things.c add_line). +func (g *RogueGame) addLineSlow(line string, isFlush bool) int { + if !isFlush && line != "" { + if g.msg("%s", line) == Escape { + return Escape } } + g.invPage.lineCnt++ + return ^Escape } +// addLinePaged accumulates discovery lines into the paged window, +// prompting between full pages (the windowed arm of things.c add_line). +func (g *RogueGame) addLinePaged(line string, isFlush bool) { + pg := &g.invPage + prompt := "--Press space to continue--" + + if !pg.init { + pg.maxlen = len(prompt) + pg.init = true + } + + if pg.lineCnt >= NumLines-1 || isFlush { + if g.Options.InvType == InvOver && isFlush && !pg.newpage { + g.addLineOverlay(prompt) + } else { + g.scr.Hw.MvAddStr(NumLines-1, 0, prompt) + g.scr.RefreshWin(g.scr.Hw) + g.waitFor(' ') + g.scr.Hw.Clear() + g.refresh() + } + + pg.newpage = true + pg.lineCnt = 0 + pg.maxlen = len(prompt) + } + + 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 + } +} + +// addLineOverlay draws the accumulated list in a box at the top right +// of the screen, prompts, and restores what was beneath (things.c +// add_line). +func (g *RogueGame) addLineOverlay(prompt string) { + pg := &g.invPage + + 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(' ') + g.scr.Std.CopyFrom(saved) + g.refresh() +} + // flushLine is add_line(NULL): force out the accumulated page. func (g *RogueGame) flushLine() int { return g.addLine(flushSentinel) } From 389db14bbf4f1c5d35f0b9c952142d2b42f3ef88 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:51:06 +0700 Subject: [PATCH 17/32] Finish addLine split (addLinePageBreak) --- game/things.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/game/things.go b/game/things.go index 147cebe..e87ddff 100644 --- a/game/things.go +++ b/game/things.go @@ -544,19 +544,7 @@ func (g *RogueGame) addLinePaged(line string, isFlush bool) { } if pg.lineCnt >= NumLines-1 || isFlush { - if g.Options.InvType == InvOver && isFlush && !pg.newpage { - g.addLineOverlay(prompt) - } else { - g.scr.Hw.MvAddStr(NumLines-1, 0, prompt) - g.scr.RefreshWin(g.scr.Hw) - g.waitFor(' ') - g.scr.Hw.Clear() - g.refresh() - } - - pg.newpage = true - pg.lineCnt = 0 - pg.maxlen = len(prompt) + g.addLinePageBreak(prompt, isFlush) } if !isFlush && (pg.lineCnt != 0 || line != "") { @@ -571,6 +559,25 @@ func (g *RogueGame) addLinePaged(line string, isFlush bool) { } } +// addLinePageBreak prompts at a full page and starts a fresh one +// (things.c add_line). +func (g *RogueGame) addLinePageBreak(prompt string, isFlush bool) { + pg := &g.invPage + if g.Options.InvType == InvOver && isFlush && !pg.newpage { + g.addLineOverlay(prompt) + } else { + g.scr.Hw.MvAddStr(NumLines-1, 0, prompt) + g.scr.RefreshWin(g.scr.Hw) + g.waitFor(' ') + g.scr.Hw.Clear() + g.refresh() + } + + pg.newpage = true + pg.lineCnt = 0 + pg.maxlen = len(prompt) +} + // addLineOverlay draws the accumulated list in a box at the top right // of the screen, prompts, and restores what was beneath (things.c // add_line). From 43b4fbe7462761774daca2b54c87e97573fae92b Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:53:02 +0700 Subject: [PATCH 18/32] Decompose save.go (refactor step 7) saveGame splits into askDefaultSave/saveFileName/saveCheckOverwrite/ askOverwrite around a saveAnswer tri-state; snapshot gains destRefFor; applySnapshot gains applyMonsters/applyDests. save.go is complexity-clean. Behavior unchanged. --- game/save.go | 266 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 163 insertions(+), 103 deletions(-) diff --git a/game/save.go b/game/save.go index 3a648ce..e69b065 100644 --- a/game/save.go +++ b/game/save.go @@ -275,43 +275,44 @@ func (g *RogueGame) snapshot() *SaveState { } st.Monsters = append(st.Monsters, sc) - - ref := destRef{} - - switch { - case m.Dest == nil: - case m.Dest == &p.Pos: - ref = destRef{Kind: 1} - default: - for mi, om := range g.Level.Monsters { - if m.Dest == &om.Pos { - ref = destRef{Kind: 2, Idx: mi} - } - } - - if ref.Kind == 0 { - for _, oo := range g.Level.Objects { - if m.Dest == &oo.Pos { - ref = destRef{Kind: 3, Idx: objAt[oo]} - } - } - } - - if ref.Kind == 0 { - for ri := range g.Level.Rooms { - if m.Dest == &g.Level.Rooms[ri].Gold { - ref = destRef{Kind: 4, Idx: ri} - } - } - } - } - - st.Dests = append(st.Dests, ref) + st.Dests = append(st.Dests, g.destRefFor(m, objAt)) } return st } +// destRefFor encodes a monster's chase target as a (kind, index) +// reference: the hero, another monster, a level object, or room gold +// (state.c rs_write_thing). +func (g *RogueGame) destRefFor(m *Monster, objAt map[*Object]int) destRef { + switch { + case m.Dest == nil: + return destRef{} + case m.Dest == &g.Player.Pos: + return destRef{Kind: 1} + } + + for mi, om := range g.Level.Monsters { + if m.Dest == &om.Pos { + return destRef{Kind: 2, Idx: mi} + } + } + + for _, oo := range g.Level.Objects { + if m.Dest == &oo.Pos { + return destRef{Kind: 3, Idx: objAt[oo]} + } + } + + for ri := range g.Level.Rooms { + if m.Dest == &g.Level.Rooms[ri].Gold { + return destRef{Kind: 4, Idx: ri} + } + } + + return destRef{} +} + // applySnapshot rebuilds live game state from a snapshot. func (g *RogueGame) applySnapshot(st *SaveState) { p := &g.Player @@ -412,7 +413,15 @@ func (g *RogueGame) applySnapshot(st *SaveState) { p.MaxStats = sp.MaxStats p.VfHit = sp.VfHit - // monsters, their map index, and their chase targets + g.applyMonsters(st) + g.applyDests(st) + + g.scr.Std.SetContents(st.Screen) +} + +// applyMonsters rebuilds the monster list and its map index from a +// snapshot (the monster half of applySnapshot). +func (g *RogueGame) applyMonsters(st *SaveState) { g.Level.Monsters = nil for i := range st.Monsters { sc := &st.Monsters[i] @@ -430,13 +439,17 @@ func (g *RogueGame) applySnapshot(st *SaveState) { g.Level.Monsters = append(g.Level.Monsters, m) g.Level.SetMonsterAt(m.Pos.Y, m.Pos.X, m) } +} +// applyDests re-aims the monsters' chase targets from their (kind, +// index) references (the fixup half of applySnapshot). +func (g *RogueGame) applyDests(st *SaveState) { for i, ref := range st.Dests { m := g.Level.Monsters[i] switch ref.Kind { case 1: - m.Dest = &p.Pos + m.Dest = &g.Player.Pos case 2: m.Dest = &g.Level.Monsters[ref.Idx].Pos case 3: @@ -445,13 +458,20 @@ func (g *RogueGame) applySnapshot(st *SaveState) { m.Dest = &g.Level.Rooms[ref.Idx].Gold } } - - g.scr.Std.SetContents(st.Screen) } +// saveAnswer is a yes/no/escape prompt result in the save-game flow. +type saveAnswer int + +// The saveGame prompt outcomes. +const ( + saveYes saveAnswer = iota + saveNo + saveAbort +) + // saveGame implements the "save game" command (save.c save_game). The -// labeled prompt loop and the useDefault flag stand in for the C -// goto over/gotfile flow. +// labeled prompt loop stands in for the C goto over/gotfile flow. func (g *RogueGame) saveGame() { g.Msgs.Mpos = 0 @@ -460,84 +480,34 @@ prompt: 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") + a := g.askDefaultSave() + if a == saveAbort { + return } - if c == 'y' || c == 'Y' { - g.addstr("Yes\n") - g.refresh() - - useDefault = true - } + useDefault = a == saveYes } for { - var buf string - if useDefault { - buf = g.FileName - useDefault = false - } else { - g.Msgs.Mpos = 0 - g.msg("file name: ") - - if g.getStr(&buf, g.scr.Std) == Quit { - g.msg("") - - return - } - - g.Msgs.Mpos = 0 + buf, ok := g.saveFileName(useDefault) + if !ok { + return } - // test to see if the file exists - _, 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("") + useDefault = false - return - } + a := g.saveCheckOverwrite(buf) + if a == saveAbort { + return + } - if c == 'y' || c == 'Y' { - break - } - - if c == 'n' || c == 'N' { - continue prompt // the C goto over: start again - } - - g.msg("Please answer Y or N") - } - - g.msg("file name: %s", buf) - _ = os.Remove(g.FileName) // best effort, as in C (md_unlink) + if a == saveNo { + continue prompt // the C goto over: start again } g.FileName = buf - err := g.saveFile(g.FileName) - if err != nil { + if err := g.saveFile(g.FileName); err != nil { g.msg("%s", err.Error()) continue @@ -550,6 +520,96 @@ prompt: g.myExit() } +// askDefaultSave asks whether to save to the current file name (save.c +// save_game). +func (g *RogueGame) askDefaultSave() saveAnswer { + for { + g.msg("save file (%s)? ", g.FileName) + c := g.readchar() + + g.Msgs.Mpos = 0 + + switch c { + case Escape: + g.msg("") + + return saveAbort + case 'y', 'Y': + g.addstr("Yes\n") + g.refresh() + + return saveYes + case 'n', 'N': + return saveNo + } + + g.msg("please answer Y or N") + } +} + +// saveFileName picks the save path: the default, or a prompted one; ok +// is false when the player quit the prompt (save.c save_game). +func (g *RogueGame) saveFileName(useDefault bool) (string, bool) { + if useDefault { + return g.FileName, true + } + + g.Msgs.Mpos = 0 + g.msg("file name: ") + + buf := "" + if g.getStr(&buf, g.scr.Std) == Quit { + g.msg("") + + return "", false + } + + g.Msgs.Mpos = 0 + + return buf, true +} + +// saveCheckOverwrite guards an existing file: saveNo restarts the whole +// prompt, saveAbort quits (save.c save_game). +func (g *RogueGame) saveCheckOverwrite(buf string) saveAnswer { + // test to see if the file exists + if _, statErr := os.Stat(buf); statErr != nil { + return saveYes + } + + answer := g.askOverwrite() + if answer != saveYes { + return answer + } + + g.msg("file name: %s", buf) + _ = os.Remove(g.FileName) // best effort, as in C (md_unlink) + + return saveYes +} + +// askOverwrite asks whether to overwrite the existing file (save.c +// save_game). +func (g *RogueGame) askOverwrite() saveAnswer { + for { + g.msg("File exists. Do you wish to overwrite it?") + g.Msgs.Mpos = 0 + + switch g.readchar() { + case Escape: + g.msg("") + + return saveAbort + case 'y', 'Y': + return saveYes + case 'n', 'N': + return saveNo + } + + g.msg("Please answer Y or N") + } +} + // 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 { From 80484bcd31bbd37f0211da7b50d95d5fea74ee9c Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:54:41 +0700 Subject: [PATCH 19/32] Finish save.go split: header/player halves, drop inline err --- game/save.go | 141 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 83 insertions(+), 58 deletions(-) diff --git a/game/save.go b/game/save.go index e69b065..038bd43 100644 --- a/game/save.go +++ b/game/save.go @@ -177,8 +177,48 @@ func (g *RogueGame) packIdx(obj *Object) int { // snapshot captures the complete game state. func (g *RogueGame) snapshot() *SaveState { - p := &g.Player - st := &SaveState{ + st := g.snapshotHeader() + + // the map, sans monster pointers (rebuilt on load) + st.Places = make([]savedPlace, len(g.Level.Places)) + for i := range g.Level.Places { + st.Places[i] = savedPlace{ + Ch: g.Level.Places[i].Ch, + Flags: g.Level.Places[i].Flags, + } + } + + // level objects by value; remember their pointers for dest encoding + objAt := make(map[*Object]int, len(g.Level.Objects)) + for i, o := range g.Level.Objects { + st.Objects = append(st.Objects, *o) + objAt[o] = i + } + + st.Player = g.snapshotPlayer() + + // monsters, with chase targets as (kind, index) references + for _, m := range g.Level.Monsters { + sc := savedCreature{ + Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise, + OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats, + RoomIdx: g.roomIdx(m.Room), + } + for _, o := range m.Pack { + sc.Pack = append(sc.Pack, *o) + } + + st.Monsters = append(st.Monsters, sc) + st.Dests = append(st.Dests, g.destRefFor(m, objAt)) + } + + return st +} + +// snapshotHeader captures the scalar game state (the field list of +// state.c rs_save_file). +func (g *RogueGame) snapshotHeader() *SaveState { + return &SaveState{ Version: saveFormatVersion, Seed: g.Rng.Seed, Dnum: g.Dnum, @@ -226,25 +266,14 @@ func (g *RogueGame) snapshot() *SaveState { AllScore: g.AllScore, Screen: g.scr.Std.Contents(), } +} - // the map, sans monster pointers (rebuilt on load) - st.Places = make([]savedPlace, len(g.Level.Places)) - for i := range g.Level.Places { - st.Places[i] = savedPlace{ - Ch: g.Level.Places[i].Ch, - Flags: g.Level.Places[i].Flags, - } - } +// snapshotPlayer captures the player, equipment as pack indices (the +// player half of snapshot). +func (g *RogueGame) snapshotPlayer() savedPlayer { + p := &g.Player - // level objects by value; remember their pointers for dest encoding - objAt := make(map[*Object]int, len(g.Level.Objects)) - for i, o := range g.Level.Objects { - st.Objects = append(st.Objects, *o) - objAt[o] = i - } - - // the player - st.Player = savedPlayer{ + sp := savedPlayer{ Body: savedCreature{ Pos: p.Pos, Turn: p.Turn, Type: p.Type, Disguise: p.Disguise, OldCh: p.OldCh, Flags: p.Flags, Stats: p.Stats, @@ -260,25 +289,10 @@ func (g *RogueGame) snapshot() *SaveState { MaxStats: p.MaxStats, VfHit: p.VfHit, } for _, o := range p.Pack { - st.Player.Body.Pack = append(st.Player.Body.Pack, *o) + sp.Body.Pack = append(sp.Body.Pack, *o) } - // monsters, with chase targets as (kind, index) references - for _, m := range g.Level.Monsters { - sc := savedCreature{ - Pos: m.Pos, Turn: m.Turn, Type: m.Type, Disguise: m.Disguise, - OldCh: m.OldCh, Flags: m.Flags, Stats: m.Stats, - RoomIdx: g.roomIdx(m.Room), - } - for _, o := range m.Pack { - sc.Pack = append(sc.Pack, *o) - } - - st.Monsters = append(st.Monsters, sc) - st.Dests = append(st.Dests, g.destRefFor(m, objAt)) - } - - return st + return sp } // destRefFor encodes a monster's chase target as a (kind, index) @@ -315,7 +329,32 @@ func (g *RogueGame) destRefFor(m *Monster, objAt map[*Object]int) destRef { // applySnapshot rebuilds live game state from a snapshot. func (g *RogueGame) applySnapshot(st *SaveState) { - p := &g.Player + g.applyHeader(st) + + for i := range g.Level.Places { + g.Level.Places[i] = Place{ + Ch: st.Places[i].Ch, + Flags: st.Places[i].Flags, + } + } + + // level objects + g.Level.Objects = nil + for i := range st.Objects { + o := st.Objects[i] + g.Level.Objects = append(g.Level.Objects, &o) + } + + g.applyPlayer(st) + g.applyMonsters(st) + g.applyDests(st) + + g.scr.Std.SetContents(st.Screen) +} + +// applyHeader restores the scalar game state (the field list of +// applySnapshot). +func (g *RogueGame) applyHeader(st *SaveState) { g.Rng.Seed = st.Seed g.Dnum = st.Dnum g.Whoami = st.Whoami @@ -360,22 +399,12 @@ func (g *RogueGame) applySnapshot(st *SaveState) { g.LastScore = st.LastScore g.AllScore = st.AllScore g.Playing = true +} - for i := range g.Level.Places { - g.Level.Places[i] = Place{ - Ch: st.Places[i].Ch, - Flags: st.Places[i].Flags, - } - } - - // level objects - g.Level.Objects = nil - for i := range st.Objects { - o := st.Objects[i] - g.Level.Objects = append(g.Level.Objects, &o) - } - - // the player +// applyPlayer restores the player, resolving equipment pack indices +// (the player half of applySnapshot). +func (g *RogueGame) applyPlayer(st *SaveState) { + p := &g.Player sp := &st.Player p.Pos = sp.Body.Pos p.Turn = sp.Body.Turn @@ -412,11 +441,6 @@ func (g *RogueGame) applySnapshot(st *SaveState) { p.NoFood = sp.NoFood p.MaxStats = sp.MaxStats p.VfHit = sp.VfHit - - g.applyMonsters(st) - g.applyDests(st) - - g.scr.Std.SetContents(st.Screen) } // applyMonsters rebuilds the monster list and its map index from a @@ -507,7 +531,8 @@ prompt: 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 From 9083967ed3e76b5497928f6ed44864ddf73d9dff Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:55:04 +0700 Subject: [PATCH 20/32] Split applyHeader turn-state half; drop inline stat err --- game/save.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/game/save.go b/game/save.go index 038bd43..625b07e 100644 --- a/game/save.go +++ b/game/save.go @@ -369,6 +369,12 @@ func (g *RogueGame) applyHeader(st *SaveState) { g.Level.Passages = st.Passages g.Level.Stairs = st.Stairs g.Level.TrapCount = st.TrapCount + g.applyTurnState(st) +} + +// applyTurnState restores the in-turn command state (the second half of +// applyHeader). +func (g *RogueGame) applyTurnState(st *SaveState) { g.After = st.After g.Again = st.Again g.NoScore = st.NoScoreF @@ -598,7 +604,8 @@ func (g *RogueGame) saveFileName(useDefault bool) (string, bool) { // prompt, saveAbort quits (save.c save_game). func (g *RogueGame) saveCheckOverwrite(buf string) saveAnswer { // test to see if the file exists - if _, statErr := os.Stat(buf); statErr != nil { + _, statErr := os.Stat(buf) + if statErr != nil { return saveYes } From bac9e361bc715fc04e3e14b2e9a9472de69d2781 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:57:22 +0700 Subject: [PATCH 21/32] Decompose daemons.go; daemon dispatch becomes a table (step 7) runDaemon's switch becomes gameData.daemonHandlers (the C d_func function pointers restored as method expressions); stomach splits into stomachFaint/stomachDigest; visuals gains visualMonsters. daemons.go is complexity-clean. Behavior and RNG call order unchanged. --- game/daemons.go | 145 +++++++++++++++++++++++------------------------- game/tables.go | 22 ++++++++ 2 files changed, 92 insertions(+), 75 deletions(-) diff --git a/game/daemons.go b/game/daemons.go index e78307c..4909706 100644 --- a/game/daemons.go +++ b/game/daemons.go @@ -5,38 +5,14 @@ package game // runDaemon invokes the callback named by id (the call through d_func in C). func (g *RogueGame) runDaemon(id DaemonID, arg int) { - switch id { - case DRollwand: - g.rollwand(arg) - case DDoctor: - g.doctor(arg) - case DStomach: - g.stomach(arg) - case DRunners: - g.runners(arg) - case DSwander: - g.swander(arg) - case DNohaste: - g.nohaste(arg) - case DUnconfuse: - g.unconfuse(arg) - case DUnsee: - g.unsee(arg) - case DSight: - g.sight(arg) - case DVisuals: - g.visuals(arg) - case DComeDown: - g.comeDown(arg) - case DLand: - g.land(arg) - case DTurnSee: - g.turnSee(arg != 0) - default: - // Callbacks are added to this switch as their subsystems are - // ported; reaching one that isn't here is a porting bug. + h := g.data.daemonHandlers[id] + if h == nil { + // Handlers are added to the table as their subsystems are + // ported; reaching one that isn't there is a porting bug. panic("daemon not yet ported") } + + h(g, arg) } // doctor is the healing daemon that restores hit points after rest @@ -141,50 +117,9 @@ func (g *RogueGame) stomach(int) { origHungry := p.HungryState if p.FoodLeft <= 0 { - if p.FoodLeft--; p.FoodLeft < -StarveTime { - g.death('s') - } - // the hero is fainting - if g.NoCommand != 0 || g.rnd(5) != 0 { - return - } - - g.NoCommand += g.rnd(8) + 4 - p.HungryState = 3 - - if !g.Options.Terse { - 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")) + g.stomachFaint() } 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 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 { - g.msg("%s", g.chooseStr("you are getting the munchies", - "you are starting to get hungry")) - } - } + g.stomachDigest() } if p.HungryState != origHungry { @@ -196,6 +131,61 @@ func (g *RogueGame) stomach(int) { } } +// stomachFaint starves and possibly faints an empty-stomached hero (the +// no-food arm of daemons.c stomach). +func (g *RogueGame) stomachFaint() { + p := &g.Player + if p.FoodLeft--; p.FoodLeft < -StarveTime { + g.death('s') + } + // the hero is fainting + if g.NoCommand != 0 || g.rnd(5) != 0 { + return + } + + g.NoCommand += g.rnd(8) + 4 + p.HungryState = 3 + + if !g.Options.Terse { + 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")) +} + +// stomachDigest burns food and reports growing hunger (the fed arm of +// daemons.c stomach). +func (g *RogueGame) stomachDigest() { + p := &g.Player + 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 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 { + g.msg("%s", g.chooseStr("you are getting the munchies", + "you are starting to get hungry")) + } + } +} + // comeDown takes the hero down off her acid trip (daemons.c come_down). func (g *RogueGame) comeDown(int) { p := &g.Player @@ -242,7 +232,6 @@ func (g *RogueGame) comeDown(int) { // visuals changes the characters for the player while hallucinating // (daemons.c visuals). func (g *RogueGame) visuals(int) { - p := &g.Player if !g.After || (g.Running && g.Options.Jump) { return } @@ -259,7 +248,13 @@ func (g *RogueGame) visuals(int) { } // change the monsters - seemonst := p.On(SenseMonsters) + g.visualMonsters() +} + +// visualMonsters redraws the monsters through the hallucination (the +// monster loop of daemons.c visuals). +func (g *RogueGame) visualMonsters() { + seemonst := g.Player.On(SenseMonsters) for _, tp := range g.Level.Monsters { g.move(tp.Pos.Y, tp.Pos.X) diff --git a/game/tables.go b/game/tables.go index 7455d7e..94934e3 100644 --- a/game/tables.go +++ b/game/tables.go @@ -151,6 +151,10 @@ type gameData struct { // trapHandlers dispatches each sprung trap to its effect method: // the cases of the move.c be_trapped switch. trapHandlers [NumTrapTypes]func(g *RogueGame, tc Coord) + + // daemonHandlers dispatches DaemonIDs to their callbacks: the C + // d_func function pointers (daemons.c / daemon.c). + daemonHandlers [DTurnSee + 1]func(g *RogueGame, arg int) } // ripWall is the repeated blank wall line of the tombstone art. @@ -826,6 +830,24 @@ func newGameData() *gameData { TrapRust: (*RogueGame).trapRust, TrapMystery: (*RogueGame).trapMystery, }, + + daemonHandlers: [DTurnSee + 1]func(*RogueGame, int){ + DRollwand: (*RogueGame).rollwand, + DDoctor: (*RogueGame).doctor, + DStomach: (*RogueGame).stomach, + DRunners: (*RogueGame).runners, + DSwander: (*RogueGame).swander, + DNohaste: (*RogueGame).nohaste, + DUnconfuse: (*RogueGame).unconfuse, + DUnsee: (*RogueGame).unsee, + DSight: (*RogueGame).sight, + DVisuals: (*RogueGame).visuals, + DComeDown: (*RogueGame).comeDown, + DLand: (*RogueGame).land, + DTurnSee: func(g *RogueGame, arg int) { + g.turnSee(arg != 0) + }, + }, } } From 432ea4f019e0cb3d1423eca909402cdf6d820b58 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:59:24 +0700 Subject: [PATCH 22/32] Decompose rooms.go (refactor step 7) digRooms splits into digRoom/placeGoneRoom/placeMazeRoom/ placeNormalRoom/roomGold/roomMonster; dig gains digPick/digWallGap; findFloorImpl gains floorChar; enterRoom and leaveRoom gain per-cell helpers. rooms.go is complexity-clean. Behavior and RNG call order unchanged. --- game/rooms.go | 410 +++++++++++++++++++++++++++++--------------------- 1 file changed, 238 insertions(+), 172 deletions(-) diff --git a/game/rooms.go b/game/rooms.go index 7dc9e36..6dcc78a 100644 --- a/game/rooms.go +++ b/game/rooms.go @@ -38,87 +38,119 @@ func (g *RogueGame) digRooms() { } // dig and populate all the rooms on the level for i := range g.Level.Rooms { - 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} + g.digRoom(i, bsze) + } +} - if rp.Flags.Has(Gone) { - // Place a gone room. Make certain that there is a blank line - // for passage drawing. - for { - 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 +// digRoom digs and populates one room (the loop body of rooms.c +// do_rooms). +func (g *RogueGame) digRoom(i int, bsze Coord) { + 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} - rp.Max.Y = -NumLines - if rp.Pos.Y > 0 && rp.Pos.Y < NumLines-1 { - break - } - } + if rp.Flags.Has(Gone) { + g.placeGoneRoom(rp, top, bsze) - continue + return + } + // 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 } - // set room type - if g.rnd(10) < g.Depth-1 { - rp.Flags.Set(Dark) // dark room + } + // Find a place and size for a random room + if rp.Flags.Has(Maze) { + placeMazeRoom(rp, top, bsze) + } else { + g.placeNormalRoom(rp, top, bsze) + } - if g.rnd(15) == 0 { - rp.Flags = Maze // maze room - } + g.drawRoom(rp) + g.roomGold(rp) + g.roomMonster(rp) +} + +// placeGoneRoom places a gone room, making certain that there is a +// blank line for passage drawing (rooms.c do_rooms). +func (g *RogueGame) placeGoneRoom(rp *Room, top, bsze Coord) { + for { + 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 { + return } - // 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 - } +// placeMazeRoom sizes a maze room to fill its box (rooms.c do_rooms). +func placeMazeRoom(rp *Room, top, bsze Coord) { + rp.Max.X = bsze.X - 1 - if rp.Pos.Y = top.Y; rp.Pos.Y == 0 { - rp.Pos.Y++ - rp.Max.Y-- - } - } else { - for { - 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.Max.Y = bsze.Y - 1 + if rp.Pos.X = top.X; rp.Pos.X == 1 { + rp.Pos.X = 0 + } - rp.Pos.Y = top.Y + g.rnd(bsze.Y-rp.Max.Y) - if rp.Pos.Y != 0 { - break - } - } + if rp.Pos.Y = top.Y; rp.Pos.Y == 0 { + rp.Pos.Y++ + rp.Max.Y-- + } +} + +// placeNormalRoom rolls a place and size for an ordinary room (rooms.c +// do_rooms). +func (g *RogueGame) placeNormalRoom(rp *Room, top, bsze Coord) { + for { + 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 { + return } + } +} - g.drawRoom(rp) - // Put the gold in - if g.rnd(2) == 0 && (!g.HasAmulet || g.Depth >= g.MaxDepth) { - gold := newObject() - rp.GoldVal = g.goldCalc() - gold.GoldValue = rp.GoldVal - rp.Gold, _ = g.findFloorIn(rp, 0, false) - gold.Pos = rp.Gold - g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold) +// roomGold maybe puts a gold pile in the room (rooms.c do_rooms). +func (g *RogueGame) roomGold(rp *Room) { + if g.rnd(2) != 0 || (g.HasAmulet && g.Depth < g.MaxDepth) { + return + } - gold.Flags = Stackable - gold.Group = goldGrp - gold.Kind = KindGold - g.Level.AddObject(gold) - } - // Put the monster in - prob := 25 - if rp.GoldVal > 0 { - prob = 80 - } + gold := newObject() + rp.GoldVal = g.goldCalc() + gold.GoldValue = rp.GoldVal + rp.Gold, _ = g.findFloorIn(rp, 0, false) + gold.Pos = rp.Gold + g.Level.SetChar(rp.Gold.Y, rp.Gold.X, Gold) - if g.rnd(100) < prob { - tp := &Monster{} - mp, _ := g.findFloorIn(rp, 0, true) - g.newMonster(tp, g.randMonster(false), mp) - g.givePack(tp) - } + gold.Flags = Stackable + gold.Group = goldGrp + gold.Kind = KindGold + g.Level.AddObject(gold) +} + +// roomMonster maybe puts a monster in the room; gold attracts them +// (rooms.c do_rooms). +func (g *RogueGame) roomMonster(rp *Room) { + prob := 25 + if rp.GoldVal > 0 { + prob = 80 + } + + if g.rnd(100) < prob { + tp := &Monster{} + mp, _ := g.findFloorIn(rp, 0, true) + g.newMonster(tp, g.randMonster(false), mp) + g.givePack(tp) } } @@ -182,63 +214,76 @@ func (g *RogueGame) digMaze(rp *Room) { // dig digs out from around where we are now, if possible (rooms.c dig). func (g *RogueGame) dig(y, x int) { m := &g.maze - del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}} 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 { + nexty, nextx, ok := g.digPick(y, x) + if !ok { return } g.accountMaze(y, x, nexty, nextx) g.accountMaze(nexty, nextx, y, x) - var pos Coord - if nexty == y { - pos.Y = y + m.starty - if nextx-x < 0 { - pos.X = nextx + m.startx + 1 - } else { - pos.X = nextx + m.startx - 1 - } - } else { - pos.X = x + m.startx - if nexty-y < 0 { - pos.Y = nexty + m.starty + 1 - } else { - pos.Y = nexty + m.starty - 1 - } - } - - g.putPassage(pos) - pos.Y = nexty + m.starty - pos.X = nextx + m.startx - g.putPassage(pos) + g.putPassage(digWallGap(m, y, x, nexty, nextx)) + g.putPassage(Coord{Y: nexty + m.starty, X: nextx + m.startx}) g.dig(nexty, nextx) } } +// digPick reservoir-picks the next unvisited maze cell; ok is false +// when the digger is boxed in (the candidate scan of rooms.c dig). +func (g *RogueGame) digPick(y, x int) (int, int, bool) { + m := &g.maze + del := [4]Coord{{X: 2, Y: 0}, {X: -2, Y: 0}, {X: 0, Y: 2}, {X: 0, Y: -2}} + + 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 + } + } + + return nexty, nextx, cnt != 0 +} + +// digWallGap picks the wall square to knock out between two maze cells +// (rooms.c dig). +func digWallGap(m *mazeState, y, x, nexty, nextx int) Coord { + var pos Coord + if nexty == y { + pos.Y = y + m.starty + if nextx-x < 0 { + pos.X = nextx + m.startx + 1 + } else { + pos.X = nextx + m.startx - 1 + } + } else { + pos.X = x + m.startx + if nexty-y < 0 { + pos.Y = nexty + m.starty + 1 + } else { + pos.Y = nexty + m.starty - 1 + } + } + + return pos +} + // accountMaze accounts for maze exits (rooms.c accnt_maze). func (g *RogueGame) accountMaze(y, x, ny, nx int) { sp := &g.maze.maze[y][x] @@ -278,13 +323,20 @@ func (g *RogueGame) findFloorIn(rp *Room, limit int, monst bool) (Coord, bool) { return g.findFloorImpl(rp, limit, monst, false) } +// floorChar is what an empty spot looks like in a room: passage in a +// maze, floor otherwise (rooms.c find_floor). +func floorChar(rp *Room) byte { + if rp.Flags.Has(Maze) { + return Passage + } + + return Floor +} + func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Coord, bool) { var compchar byte if !pickroom { - compchar = Floor - if rp.Flags.Has(Maze) { - compchar = Passage - } + compchar = floorChar(rp) } cnt := limit @@ -297,11 +349,7 @@ func (g *RogueGame) findFloorImpl(rp *Room, limit int, monst, pickroom bool) (Co if pickroom { rp = &g.Level.Rooms[g.randomRoom()] - - compchar = Floor - if rp.Flags.Has(Maze) { - compchar = Passage - } + compchar = floorChar(rp) } cp := g.randomPos(rp) @@ -330,34 +378,44 @@ func (g *RogueGame) enterRoom(cp Coord) { 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 { - g.addch(ch) - } else { - g.move(y, x+1) - } - } else { - tp.OldCh = ch - if !g.seeMonst(tp) { - if p.On(SenseMonsters) { - g.standout() - g.addch(tp.Disguise) - g.standend() - } else { - g.addch(ch) - } - } else { - g.addch(tp.Disguise) - } - } + g.enterRoomCell(y, x) } } } } +// enterRoomCell draws one square of a room being lit on entry (the loop +// body of rooms.c enter_room). +func (g *RogueGame) enterRoomCell(y, x int) { + tp := g.Level.MonsterAt(y, x) + + ch := g.Level.Char(y, x) + if tp == nil { + if g.inch() != ch { + g.addch(ch) + } else { + g.move(y, x+1) + } + + return + } + + tp.OldCh = ch + if g.seeMonst(tp) { + g.addch(tp.Disguise) + + return + } + + if g.Player.On(SenseMonsters) { + g.standout() + g.addch(tp.Disguise) + g.standend() + } else { + g.addch(ch) + } +} + // leaveRoom is the code for when we exit a room (rooms.c leave_room). func (g *RogueGame) leaveRoom(cp Coord) { p := &g.Player @@ -381,35 +439,43 @@ func (g *RogueGame) leaveRoom(cp Coord) { p.Room = &g.Level.Passages[*g.Level.FlagsAt(cp.Y, cp.X)&FPassNum] 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 == ' ' { - g.addch(' ') - } - default: - // to check for monster, we have to strip out the standout - // bit (our Window returns the bare character already) - if isUpper(ch) { - if p.On(SenseMonsters) { - g.standout() - g.addch(ch) - g.standend() - - break - } - - pp := g.Level.At(y, x) - if pp.Ch == Door { - g.addch(Door) - } else { - g.addch(floor) - } - } - } + g.leaveRoomCell(floor, y, x) } } g.doorOpen(rp) } + +// leaveRoomCell hides one square of a room being left (the loop body of +// rooms.c leave_room). +func (g *RogueGame) leaveRoomCell(floor byte, y, x int) { + g.move(y, x) + + switch ch := g.inch(); ch { + case Floor: + if floor == ' ' { + g.addch(' ') + } + default: + // to check for monster, we have to strip out the standout + // bit (our Window returns the bare character already) + if !isUpper(ch) { + return + } + + if g.Player.On(SenseMonsters) { + g.standout() + g.addch(ch) + g.standend() + + return + } + + pp := g.Level.At(y, x) + if pp.Ch == Door { + g.addch(Door) + } else { + g.addch(floor) + } + } +} From 3e1c30c787b330796a01ea68513410d9f774af8b Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 21:59:44 +0700 Subject: [PATCH 23/32] Flatten digWallGap --- game/rooms.go | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/game/rooms.go b/game/rooms.go index 6dcc78a..1c096af 100644 --- a/game/rooms.go +++ b/game/rooms.go @@ -264,21 +264,18 @@ func (g *RogueGame) digPick(y, x int) (int, int, bool) { // digWallGap picks the wall square to knock out between two maze cells // (rooms.c dig). func digWallGap(m *mazeState, y, x, nexty, nextx int) Coord { - var pos Coord if nexty == y { - pos.Y = y + m.starty + pos := Coord{Y: y + m.starty, X: nextx + m.startx - 1} if nextx-x < 0 { pos.X = nextx + m.startx + 1 - } else { - pos.X = nextx + m.startx - 1 - } - } else { - pos.X = x + m.startx - if nexty-y < 0 { - pos.Y = nexty + m.starty + 1 - } else { - pos.Y = nexty + m.starty - 1 } + + return pos + } + + pos := Coord{X: x + m.startx, Y: nexty + m.starty - 1} + if nexty-y < 0 { + pos.Y = nexty + m.starty + 1 } return pos From 8895db530d0308722b7364173769c1bfc1382054 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:01:03 +0700 Subject: [PATCH 24/32] Decompose score and wakeMonster (refactor step 7) score splits into scoreInsert/scoreLines/showScores; wakeMonster gains meanWakes/medusaCatches/medusaGaze predicates and effect. Behavior and RNG call order unchanged. --- game/monsters.go | 86 ++++++++++++++++++--------- game/score.go | 151 +++++++++++++++++++++++++++-------------------- 2 files changed, 144 insertions(+), 93 deletions(-) diff --git a/game/monsters.go b/game/monsters.go index 06c81fa..d371450 100644 --- a/game/monsters.go +++ b/game/monsters.go @@ -124,40 +124,14 @@ func (g *RogueGame) wakeMonster(y, x int) *Monster { 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) && - !p.IsWearing(RingStealth) && !p.On(Levitating) { + if g.meanWakes(tp) { 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.addmsgf("%s", mname) - - if mname != "it" { - g.addmsgf("'") - } - - g.msg("s gaze has confused you") - } - } + if g.medusaCatches(tp) { + g.medusaGaze(tp, y, x) } // Let greedy ones guard gold if tp.On(Greedy) && !tp.On(Awake) { @@ -173,6 +147,60 @@ func (g *RogueGame) wakeMonster(y, x int) *Monster { return tp } +// meanWakes decides whether a sleeping mean monster starts the chase +// (monsters.c wake_monster). The waking roll happens for any sleeping +// monster, as in C. +func (g *RogueGame) meanWakes(tp *Monster) bool { + p := &g.Player + + return !tp.On(Awake) && g.rnd(3) != 0 && tp.On(Mean) && !tp.On(Held) && + !p.IsWearing(RingStealth) && !p.On(Levitating) +} + +// medusaCatches reports an uncovered, awake medusa the hero can see +// (monsters.c wake_monster). +func (g *RogueGame) medusaCatches(tp *Monster) bool { + p := &g.Player + + return tp.Type == 'M' && !p.On(Blind) && !p.On(Hallucinating) && + !tp.On(Found) && !tp.On(Cancelled) && tp.On(Awake) +} + +// medusaGaze confuses the hero when the medusa's gaze lands (the M +// block of monsters.c wake_monster). +func (g *RogueGame) medusaGaze(tp *Monster, y, x int) { + p := &g.Player + + rp := p.Room + if (rp == nil || rp.Flags.Has(Dark)) && + distance(y, x, p.Pos.Y, p.Pos.X) >= LampDist { + return + } + + tp.Flags.Set(Found) + + if g.save(VsMagic) { + return + } + + 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.addmsgf("%s", mname) + + if mname != "it" { + g.addmsgf("'") + } + + g.msg("s gaze has confused you") +} + // givePack gives a pack to a monster if it deserves one (monsters.c // give_pack). func (g *RogueGame) givePack(tp *Monster) { diff --git a/game/score.go b/game/score.go index 8d07364..6f715a0 100644 --- a/game/score.go +++ b/game/score.go @@ -107,61 +107,81 @@ 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() + ins = g.scoreInsert(topTen, amount, flags, monst) + } - scp := len(topTen) - for i := range topTen { - if amount > topTen[i].Score { - scp = i + lines, highlight := g.scoreLines(topTen, ins) + g.showScores(lines, highlight) - break - } else if !g.AllScore && flags != 2 && - topTen[i].UID == uid && topTen[i].Flags != 2 { - // only one score per nowin uid - scp = len(topTen) + // Update the list file + if ins >= 0 { + g.wrScore(topTen) + } +} - break - } - } +// scoreInsert slots the new score into the top ten, honoring the +// one-score-per-losing-uid rule; -1 means it did not place (the +// insertion half of rip.c score). +func (g *RogueGame) scoreInsert(topTen []ScoreEnt, amount, flags int, monst byte) int { + uid := os.Getuid() - 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 + scp := len(topTen) + for i := range topTen { + if amount > topTen[i].Score { + scp = i - break - } - } - } + break + } else if !g.AllScore && flags != 2 && + topTen[i].UID == uid && topTen[i].Flags != 2 { + // only one score per nowin uid + scp = len(topTen) - 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, - Flags: flags, - Monster: monst, - Name: g.Whoami, - Level: lvl, - Time: time.Now().Unix(), - } - ins = scp + break } } - // Build the list display + if scp >= len(topTen) { + return -1 + } + + 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, + Flags: flags, + Monster: monst, + Name: g.Whoami, + Level: lvl, + Time: time.Now().Unix(), + } + + return scp +} + +// scoreLines formats the scoreboard, noting which display line holds +// the freshly inserted score (the display half of rip.c score). +func (g *RogueGame) scoreLines(topTen []ScoreEnt, ins int) ([]string, int) { label := "Rogueists" if g.AllScore { label = "Scores" @@ -194,32 +214,35 @@ func (g *RogueGame) score(amount, flags int, monst byte) { lines = append(lines, line) } - if g.scr != nil && g.scr.term != nil { - g.clear() + return lines, highlight +} - for i, line := range lines { - if i == highlight { - g.standout() - } - - g.mvaddstr(i, 0, line) - - if i == highlight { - g.standend() - } - } - - g.refresh() - } else { +// showScores prints the scoreboard on the screen when there is one, +// else to standard output (rip.c score). +func (g *RogueGame) showScores(lines []string, highlight int) { + if g.scr == nil || g.scr.term == nil { for _, line := range lines { _, _ = fmt.Fprintln(os.Stdout, line) // CLI output } + + return } - // Update the list file - if ins >= 0 { - g.wrScore(topTen) + g.clear() + + for i, line := range lines { + if i == highlight { + g.standout() + } + + g.mvaddstr(i, 0, line) + + if i == highlight { + g.standend() + } } + + g.refresh() } // ShowScores implements the -s command line option: print the scoreboard From ff7ee95395b634782e0823925f3969ea92204200 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:02:02 +0700 Subject: [PATCH 25/32] Finish score/wakeMonster tidy (scoreSlot; drop unused return) --- game/monsters.go | 4 +--- game/score.go | 35 ++++++++++++++++++++--------------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/game/monsters.go b/game/monsters.go index d371450..95b59e2 100644 --- a/game/monsters.go +++ b/game/monsters.go @@ -116,7 +116,7 @@ func (g *RogueGame) wanderer() { // wakeMonster is what to do when the hero steps next to a monster // (monsters.c wake_monster). -func (g *RogueGame) wakeMonster(y, x int) *Monster { +func (g *RogueGame) wakeMonster(y, x int) { p := &g.Player tp := g.Level.MonsterAt(y, x) @@ -143,8 +143,6 @@ func (g *RogueGame) wakeMonster(y, x int) *Monster { tp.Dest = &p.Pos } } - - return tp } // meanWakes decides whether a sleeping mean monster starts the chase diff --git a/game/score.go b/game/score.go index 6f715a0..599cada 100644 --- a/game/score.go +++ b/game/score.go @@ -126,21 +126,7 @@ func (g *RogueGame) score(amount, flags int, monst byte) { func (g *RogueGame) scoreInsert(topTen []ScoreEnt, amount, flags int, monst byte) int { 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 - } - } - + scp := g.scoreSlot(topTen, amount, flags, uid) if scp >= len(topTen) { return -1 } @@ -179,6 +165,25 @@ func (g *RogueGame) scoreInsert(topTen []ScoreEnt, amount, flags int, monst byte return scp } +// scoreSlot finds where the new score lands: len(topTen) when it does +// not place, or when this uid already holds a losing score (the scan of +// rip.c score). +func (g *RogueGame) scoreSlot(topTen []ScoreEnt, amount, flags, uid int) int { + for i := range topTen { + if amount > topTen[i].Score { + return i + } + + if !g.AllScore && flags != 2 && + topTen[i].UID == uid && topTen[i].Flags != 2 { + // only one score per nowin uid + return len(topTen) + } + } + + return len(topTen) +} + // scoreLines formats the scoreboard, noting which display line holds // the freshly inserted score (the display half of rip.c score). func (g *RogueGame) scoreLines(topTen []ScoreEnt, ins int) ([]string, int) { From 444bc30f2c0b7d945d8792ad77828490e6e6a484 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:03:31 +0700 Subject: [PATCH 26/32] Decompose ringOn and totalWinner (refactor step 7) ringOn gains pickRingHand and chooseTerse messages; totalWinner's appraisal switch becomes objectWorth with loreWorth/ringWorth/ wandWorth. Behavior unchanged. --- game/rings.go | 47 +++++++------- game/rip.go | 167 ++++++++++++++++++++++++++++---------------------- 2 files changed, 119 insertions(+), 95 deletions(-) diff --git a/game/rings.go b/game/rings.go index c62a00f..1e7bc2a 100644 --- a/game/rings.go +++ b/game/rings.go @@ -14,11 +14,8 @@ func (g *RogueGame) ringOn() { } 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") - } + g.msg("%s", g.chooseTerse("not a ring", + "it would be difficult to wrap that around a finger")) return } @@ -28,24 +25,8 @@ func (g *RogueGame) ringOn() { return } - var ring int - - switch { - case p.CurRing[Left] == nil && p.CurRing[Right] == nil: - if ring = g.gethand(); ring < 0 { - return - } - case p.CurRing[Left] == nil: - ring = Left - case p.CurRing[Right] == nil: - ring = Right - default: - if !g.Options.Terse { - g.msg("you already have a ring on each hand") - } else { - g.msg("wearing two") - } - + ring := g.pickRingHand() + if ring < 0 { return } @@ -68,6 +49,26 @@ func (g *RogueGame) ringOn() { g.msg("%s (%c)", g.inventoryName(obj, true), obj.PackCh) } +// pickRingHand chooses the hand for a new ring, asking when both are +// free; negative aborts (rings.c ring_on). +func (g *RogueGame) pickRingHand() int { + p := &g.Player + + switch { + case p.CurRing[Left] == nil && p.CurRing[Right] == nil: + return g.gethand() + case p.CurRing[Left] == nil: + return Left + case p.CurRing[Right] == nil: + return Right + default: + g.msg("%s", g.chooseTerse("wearing two", + "you already have a ring on each hand")) + + return -1 + } +} + // ringOff takes off a ring (rings.c ring_off). func (g *RogueGame) ringOff() { p := &g.Player diff --git a/game/rip.go b/game/rip.go index 5e504cb..8910efa 100644 --- a/game/rip.go +++ b/game/rip.go @@ -115,78 +115,7 @@ func (g *RogueGame) totalWinner() { line := 1 for _, obj := range p.Pack { - worth := 0 - it := &g.Items - - switch obj.Kind { - case KindFood: - worth = 2 * obj.Count - case KindWeapon: - worth = it.Weapons[obj.Which].Worth - worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count - obj.Flags.Set(Known) - case KindArmor: - worth = it.Armors[obj.Which].Worth - worth += (9 - obj.ArmorClass) * 100 - worth += 10 * (g.data.aClass[obj.Which] - obj.ArmorClass) - 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 { - worth += obj.Bonus * 100 - } else { - 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 - } + worth := g.objectWorth(obj) g.scr.Std.MvPrintwf(line, 0, "%c) %5d %s", obj.PackCh, worth, g.inventoryName(obj, false)) @@ -200,6 +129,100 @@ func (g *RogueGame) totalWinner() { g.myExit() } +// objectWorth appraises one pack item on the way out, marking it known +// (the switch of rip.c total_winner). +func (g *RogueGame) objectWorth(obj *Object) int { + it := &g.Items + worth := 0 + + switch obj.Kind { + case KindFood: + worth = 2 * obj.Count + case KindWeapon: + worth = it.Weapons[obj.Which].Worth + worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count + obj.Flags.Set(Known) + case KindArmor: + worth = it.Armors[obj.Which].Worth + worth += (9 - obj.ArmorClass) * 100 + worth += 10 * (g.data.aClass[obj.Which] - obj.ArmorClass) + obj.Flags.Set(Known) + case KindScroll: + worth = loreWorth(&it.Scrolls[obj.Which], obj.Count) + case KindPotion: + worth = loreWorth(&it.Potions[obj.Which], obj.Count) + case KindRing: + worth = g.ringWorth(obj) + case KindWand: + worth = g.wandWorth(obj) + case KindAmulet: + worth = 1000 + } + + if worth < 0 { + worth = 0 + } + + return worth +} + +// loreWorth appraises a scroll or potion, halved when unidentified, and +// identifies it (rip.c total_winner). +func loreWorth(op *ObjInfo, count int) int { + worth := op.Worth * count + if !op.Know { + worth /= 2 + } + + op.Know = true + + return worth +} + +// ringWorth appraises a ring: bonus rings gain by their bonus, cursed +// ones are junk (rip.c total_winner). +func (g *RogueGame) ringWorth(obj *Object) int { + op := &g.Items.Rings[obj.Which] + worth := op.Worth + + if obj.RingKind() == RingAddStrength || obj.RingKind() == RingIncreaseDamage || + obj.RingKind() == RingProtection || obj.RingKind() == RingDexterity { + if obj.Bonus > 0 { + worth += obj.Bonus * 100 + } else { + worth = 10 + } + } + + if !obj.Flags.Has(Known) { + worth /= 2 + } + + obj.Flags.Set(Known) + + op.Know = true + + return worth +} + +// wandWorth appraises a wand or staff by its charges (rip.c +// total_winner). +func (g *RogueGame) wandWorth(obj *Object) int { + op := &g.Items.Sticks[obj.Which] + worth := op.Worth + + worth += 20 * obj.Charges + if !obj.Flags.Has(Known) { + worth /= 2 + } + + obj.Flags.Set(Known) + + op.Know = true + + return worth +} + // killname converts a code to a monster name (rip.c killname). func (g *RogueGame) killname(monst byte, doart bool) string { var ( From 71713d68b7fe7c3eb30dad3fe6b29d5a68bd627d Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:05:06 +0700 Subject: [PATCH 27/32] Decompose io.go End and status (refactor step 7) End's --More-- handling moves into promptMore; status's redraw-skip check moves into statusUnchanged. io.go is complexity-clean. Behavior unchanged. --- game/io.go | 71 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/game/io.go b/game/io.go index eb487dc..6d73d77 100644 --- a/game/io.go +++ b/game/io.go @@ -58,29 +58,8 @@ func (m *MessageLine) End() int { m.Huh = m.buf.String() } - if m.Mpos != 0 { - m.look(false) - m.scr.Std.MvAddStr(0, m.Mpos, "--More--") - m.scr.Refresh() - - if !m.MsgEsc { - m.waitForSpace() - } else { - for { - ch := m.readChar() - if ch == ' ' { - break - } - - if ch == Escape { - m.buf.Reset() - m.Mpos = 0 - m.newpos = 0 - - return Escape - } - } - } + if m.Mpos != 0 && m.promptMore() == Escape { + return Escape } // All messages should start with uppercase, except ones that start // with a pack addressing character @@ -101,6 +80,36 @@ func (m *MessageLine) End() int { return ^Escape } +// promptMore shows the --More-- prompt and waits for the reader to +// acknowledge; Escape means the player bailed out (the Mpos block of +// io.c endmsg). +func (m *MessageLine) promptMore() int { + m.look(false) + m.scr.Std.MvAddStr(0, m.Mpos, "--More--") + m.scr.Refresh() + + if !m.MsgEsc { + m.waitForSpace() + + return ^Escape + } + + for { + ch := m.readChar() + if ch == ' ' { + return ^Escape + } + + if ch == Escape { + m.buf.Reset() + m.Mpos = 0 + m.newpos = 0 + + return Escape + } + } +} + // attach wires the message line to its display and input; NewGame and // Restore call it once the screen and game exist. func (m *MessageLine) attach(scr *Screen, look func(bool), readChar func() byte) { @@ -193,9 +202,7 @@ func (g *RogueGame) status() { 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 { + if g.statusUnchanged(temp) { return } @@ -238,6 +245,18 @@ func (g *RogueGame) status() { g.move(oy, ox) } +// statusUnchanged reports whether the status line still shows current +// values, so it need not be redrawn (the shadow-variable check of io.c +// status). temp is the effective armor class. +func (g *RogueGame) statusUnchanged(temp int) bool { + s := &g.statusCache + p := &g.Player + + return 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 +} + // waitFor sits around until the guy types the right key (io.c wait_for). func (g *RogueGame) waitFor(ch byte) { if ch == '\n' { From 730d91d160f0ca997221c7bd31823e65f53c23d3 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:06:48 +0700 Subject: [PATCH 28/32] Decompose ObjectKind.String and doMotion (refactor step 7) String's tail moves into stringRest; doMotion's erase step moves into eraseFlight and the inverted loop drops a nesting level. Behavior unchanged. --- game/object.go | 29 +++++++++++++++++++---------- game/weapons.go | 42 +++++++++++++++++++++++------------------- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/game/object.go b/game/object.go index 5991503..b62f689 100644 --- a/game/object.go +++ b/game/object.go @@ -77,17 +77,9 @@ func (k ObjectKind) String() string { return "suit of armor" case KindRing: return ringName - case KindWand: - return "wand or staff" - case KindAmulet: - return "amulet" - case KindGold: - return goldName - case KindRingOrStick: - return "ring, wand or staff" + default: + return k.stringRest() } - - return "bizarre thing" } // objectKindForGlyph is the reverse of Glyph: what category of item does a @@ -123,6 +115,23 @@ func (k ObjectKind) MergesInPack() bool { return k == KindPotion || k == KindScroll || k == KindFood } +// stringRest names the remaining kinds, including the ring-or-stick +// prompt pseudo-kind (the tail of the C type_name switch). +func (k ObjectKind) stringRest() string { + switch k { + case KindWand: + return "wand or staff" + case KindAmulet: + return "amulet" + case KindGold: + return goldName + case KindRingOrStick: + return "ring, wand or staff" + } + + return "bizarre thing" +} + // Object is the _o arm of the C THING union: anything that can lie on the // floor or ride in a pack. type Object struct { diff --git a/game/weapons.go b/game/weapons.go index de56d1d..c555240 100644 --- a/game/weapons.go +++ b/game/weapons.go @@ -35,34 +35,38 @@ func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) { // Come fly with us ... obj.Pos = p.Pos for { - // Erase the old one - if obj.Pos != p.Pos && g.canSee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse { - ch := g.Level.Char(obj.Pos.Y, obj.Pos.X) - if ch == Floor && !g.showFloor() { - ch = ' ' - } - - g.mvaddch(obj.Pos.Y, obj.Pos.X, ch) - } + g.eraseFlight(obj, p.Pos) // 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. - if g.canSee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse { - g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph()) - g.refresh() - } - - continue + if !stepOk(ch) || ch == Door { + break + } + // It hasn't hit anything yet, so display it if it's alright. + if g.canSee(obj.Pos.Y, obj.Pos.X) && !g.Options.Terse { + g.mvaddch(obj.Pos.Y, obj.Pos.X, obj.Kind.Glyph()) + g.refresh() } - - break } } +// eraseFlight erases a flying object from its current square, unless it +// still sits on the hero (the erase step of weapons.c do_motion). +func (g *RogueGame) eraseFlight(obj *Object, heroPos Coord) { + if obj.Pos == heroPos || !g.canSee(obj.Pos.Y, obj.Pos.X) || g.Options.Terse { + return + } + + ch := g.Level.Char(obj.Pos.Y, obj.Pos.X) + if ch == Floor && !g.showFloor() { + ch = ' ' + } + + g.mvaddch(obj.Pos.Y, obj.Pos.X, ch) +} + // fall drops an item someplace around here (weapons.c fall). func (g *RogueGame) fall(obj *Object, pr bool) { if fpos, ok := g.fallpos(obj.Pos); ok { From b68836dde0bce1b52ae93784d33e6b43d9c49750 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:07:31 +0700 Subject: [PATCH 29/32] Decompose wizard.go createObj and whatis (refactor step 7) createObj gains createWeaponArmor/createRing; whatis gains whatisPick for its prompt loop. wizard.go is complexity-clean. Behavior unchanged. --- game/wizard.go | 161 +++++++++++++++++++++++++++---------------------- 1 file changed, 90 insertions(+), 71 deletions(-) diff --git a/game/wizard.go b/game/wizard.go index d954421..e7cd5dc 100644 --- a/game/wizard.go +++ b/game/wizard.go @@ -27,50 +27,9 @@ func (g *RogueGame) createObj() { 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 - } - } else { - obj.ArmorClass = g.data.aClass[obj.Which] - if bless == '-' { - obj.ArmorClass += g.rnd(3) + 1 - } - - if bless == '+' { - obj.ArmorClass -= g.rnd(3) + 1 - } - } + g.createWeaponArmor(obj) 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 - } else { - obj.Bonus = g.rnd(2) + 1 - } - case RingAggravateMonsters, RingTeleportation: - obj.Flags.Set(Cursed) - } + g.createRing(obj) case KindWand: g.fixStick(obj) case KindGold: @@ -85,6 +44,61 @@ func (g *RogueGame) createObj() { g.addPack(obj, false) } +// createWeaponArmor sets up a wizard-created weapon or armor with an +// optional blessing (the weapon/armor arm of wizard.c create_obj). +func (g *RogueGame) createWeaponArmor(obj *Object) { + 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 + } + + return + } + + obj.ArmorClass = g.data.aClass[obj.Which] + if bless == '-' { + obj.ArmorClass += g.rnd(3) + 1 + } + + if bless == '+' { + obj.ArmorClass -= g.rnd(3) + 1 + } +} + +// createRing sets up a wizard-created ring, prompting for a bonus on +// the bonus rings (the ring arm of wizard.c create_obj). +func (g *RogueGame) createRing(obj *Object) { + 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 + } else { + obj.Bonus = g.rnd(2) + 1 + } + case RingAggravateMonsters, RingTeleportation: + obj.Flags.Set(Cursed) + } +} + // showMap prints out the whole map for the wizard (wizard.c show_map). func (g *RogueGame) showMap() { hw := g.scr.Hw @@ -117,34 +131,8 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) { return } - var obj *Object - for { - obj, _ = g.promptPackItem("identify", kind) - - if !insist { - break - } - - if g.NObjs == 0 { - return - } - - if obj == nil { - g.msg("you must identify something") - - continue - } - - if !matchesFilter(kind, obj) { - g.msg("you must identify a %s", kind) - - continue - } - - break - } - - if obj == nil { + obj, ok := g.whatisPick(insist, kind) + if !ok { return } @@ -164,6 +152,37 @@ func (g *RogueGame) whatis(insist bool, kind ObjectKind) { g.msg("%s", g.inventoryName(obj, false)) } +// whatisPick prompts for the item to identify, re-asking until a +// matching one is chosen when insist is set; ok is false when the +// player gives up (the prompt loop of wizard.c whatis). +func (g *RogueGame) whatisPick(insist bool, kind ObjectKind) (*Object, bool) { + for { + obj, _ := g.promptPackItem("identify", kind) + + if !insist { + return obj, obj != nil + } + + if g.NObjs == 0 { + return nil, false + } + + if obj == nil { + g.msg("you must identify something") + + continue + } + + if !matchesFilter(kind, obj) { + g.msg("you must identify a %s", kind) + + continue + } + + return obj, true + } +} + // setKnow sets things up when we really know what a thing is (wizard.c // set_know). func setKnow(obj *Object, info []ObjInfo) { From 5c14a829aa4c0f25181b5ce0de8cfcaa26957fdd Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:08:59 +0700 Subject: [PATCH 30/32] Decompose tcell ReadChar key translation (refactor step 7) ReadChar's key switch moves into translateKey with namedKey split into motionKey/editingKey halves. term is complexity-clean. Behavior unchanged. --- term/tcell.go | 114 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 37 deletions(-) diff --git a/term/tcell.go b/term/tcell.go index 8d68857..d87a705 100644 --- a/term/tcell.go +++ b/term/tcell.go @@ -89,48 +89,88 @@ func (t *Tcell) ReadChar() byte { t.Render(t.last) } case *tcell.EventKey: - switch ev.Key() { - case tcell.KeyUp: - return 'k' - case tcell.KeyDown: - return 'j' - case tcell.KeyLeft: - return 'h' - case tcell.KeyRight: - return 'l' - case tcell.KeyHome: - return 'y' - case tcell.KeyPgUp: - return 'u' - case tcell.KeyEnd: - return 'b' - case tcell.KeyPgDn: - return 'n' - case tcell.KeyEnter: - return '\n' - case tcell.KeyEscape: - return game.Escape - case tcell.KeyBackspace, tcell.KeyBackspace2: - return 8 - case tcell.KeyDelete: - return 0x7f - case tcell.KeyTab: - return '\t' - case tcell.KeyCtrlC: - return 3 - default: - if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ { - return byte(ev.Key()) //nolint:gosec // G115: 1..26 fits - } - - if r := ev.Rune(); r > 0 && r < 0x80 { - return byte(r) - } + if b, ok := translateKey(ev); ok { + return b } } } } +// translateKey converts a key event to a game input byte; ok is false +// for keys the C game does not understand. +func translateKey(ev *tcell.EventKey) (byte, bool) { + if b, ok := namedKey(ev.Key()); ok { + return b, true + } + + if ev.Key() >= tcell.KeyCtrlA && ev.Key() <= tcell.KeyCtrlZ { + return byte(ev.Key()), true //nolint:gosec // G115: 1..26 fits + } + + if r := ev.Rune(); r > 0 && r < 0x80 { + return byte(r), true + } + + return 0, false +} + +// namedKey translates tcell's navigation and editing keys to the single +// bytes the C game reads (arrows become hjkl, etc.); ok is false for +// keys handled elsewhere. +func namedKey(k tcell.Key) (byte, bool) { + if b, ok := motionKey(k); ok { + return b, true + } + + return editingKey(k) +} + +// motionKey translates the arrow and paging keys to Rogue's movement +// letters (tcell.go ReadChar). +func motionKey(k tcell.Key) (byte, bool) { + switch k { + case tcell.KeyUp: + return 'k', true + case tcell.KeyDown: + return 'j', true + case tcell.KeyLeft: + return 'h', true + case tcell.KeyRight: + return 'l', true + case tcell.KeyHome: + return 'y', true + case tcell.KeyPgUp: + return 'u', true + case tcell.KeyEnd: + return 'b', true + case tcell.KeyPgDn: + return 'n', true + } + + return 0, false +} + +// editingKey translates the editing and control keys to their C0 codes +// (tcell.go ReadChar). +func editingKey(k tcell.Key) (byte, bool) { + switch k { + case tcell.KeyEnter: + return '\n', true + case tcell.KeyEscape: + return game.Escape, true + case tcell.KeyBackspace, tcell.KeyBackspace2: + return 8, true + case tcell.KeyDelete: + return 0x7f, true + case tcell.KeyTab: + return '\t', true + case tcell.KeyCtrlC: + return 3, true + } + + return 0, false +} + // ShellEscape suspends the screen and runs the user's shell (main.c // shell + md_shellescape). func (t *Tcell) ShellEscape() { From 5b7e258195d6b0cbd65fb5b9afd091dc9b7f18c8 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:11:10 +0700 Subject: [PATCH 31/32] Decompose test functions to clear complexity findings (step 7) TestSaveRestoreRoundTrip, TestNewGameRandomizesAppearances, and TestNewLevelInvariants split their assertion blocks into t.Helper() sub-checks. The lint run is now completely clean (0 issues). --- game/newlevel_test.go | 151 +++++++++++++++++++++++++----------------- game/save_test.go | 52 ++++++++++----- game/tables_test.go | 36 ++++++++-- 3 files changed, 157 insertions(+), 82 deletions(-) diff --git a/game/newlevel_test.go b/game/newlevel_test.go index 0d5122e..45f47e1 100644 --- a/game/newlevel_test.go +++ b/game/newlevel_test.go @@ -38,75 +38,106 @@ func TestNewLevelInvariants(t *testing.T) { for _, seed := range []int32{1, 12345, 2026, 99999} { g := genLevel(t, seed) - // The staircase is somewhere real. - st := g.Level.Stairs - if g.Level.Char(st.Y, st.X) != Stairs { - t.Errorf("seed %d: no staircase at recorded stairs position", seed) + checkHeroPlacement(t, g, seed) + checkRoomsDrawn(t, g, seed) + checkMonstersPlaced(t, g, seed) + checkObjectsPlaced(t, g, seed) + checkStartingKit(t, g, seed) + } +} + +// checkHeroPlacement verifies the staircase and hero landed on valid, +// unoccupied cells. +func checkHeroPlacement(t *testing.T, g *RogueGame, seed int32) { + t.Helper() + + // The staircase is somewhere real. + st := g.Level.Stairs + if g.Level.Char(st.Y, st.X) != Stairs { + t.Errorf("seed %d: no staircase at recorded stairs position", seed) + } + + // The hero stands on a walkable, monster-free cell. + hp := g.Player.Pos + if !stepOk(g.Level.Char(hp.Y, hp.X)) { + 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) + } +} + +// checkRoomsDrawn verifies rooms and floor/passages appear on the map. +func checkRoomsDrawn(t *testing.T, g *RogueGame, seed int32) { + t.Helper() + + m := renderMap(g) + 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) + } +} + +// checkMonstersPlaced verifies every monster is indexed on the map and +// placed in a room. +func checkMonstersPlaced(t *testing.T, g *RogueGame, seed int32) { + t.Helper() + + for _, mon := range g.Level.Monsters { + if g.Level.MonsterAt(mon.Pos.Y, mon.Pos.X) != mon { + t.Errorf("seed %d: monster %c not indexed at its position", + seed, mon.Type) } - // The hero stands on a walkable, monster-free cell. - hp := g.Player.Pos - if !stepOk(g.Level.Char(hp.Y, hp.X)) { - t.Errorf("seed %d: hero on unwalkable cell %q", seed, - g.Level.Char(hp.Y, hp.X)) + if mon.Room == nil { + t.Errorf("seed %d: monster %c has no room", seed, mon.Type) } + } +} - if g.Level.MonsterAt(hp.Y, hp.X) != nil { - t.Errorf("seed %d: hero standing on a monster", seed) +// checkObjectsPlaced verifies every level object sits on a cell +// displaying its type (items can share cells only with monsters standing +// on them). +func checkObjectsPlaced(t *testing.T, g *RogueGame, seed int32) { + t.Helper() + + for _, obj := range g.Level.Objects { + ch := g.Level.Char(obj.Pos.Y, obj.Pos.X) + if ch != obj.Kind.Glyph() && + g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil { + t.Errorf("seed %d: object %v at (%d,%d) but map shows %q", + seed, obj.Kind, obj.Pos.Y, obj.Pos.X, ch) } + } +} - if g.Player.Room == nil { - t.Errorf("seed %d: hero not in any room", seed) - } +// checkStartingKit verifies the player has her starting kit: food, armor, +// mace, bow, arrows. +func checkStartingKit(t *testing.T, g *RogueGame, seed int32) { + t.Helper() - // Some rooms exist and are drawn. - m := renderMap(g) - if !strings.Contains(m, "|") || !strings.Contains(m, "-") { - t.Errorf("seed %d: no room walls drawn", seed) - } + if len(g.Player.Pack) != 5 { + t.Errorf("seed %d: starting pack has %d items, want 5", + seed, len(g.Player.Pack)) + } - if !strings.Contains(m, ".") && !strings.Contains(m, "#") { - t.Errorf("seed %d: no floor or passages drawn", seed) - } + if g.Player.CurWeapon == nil || + g.Player.CurWeapon.WeaponKind() != WeaponMace { + t.Errorf("seed %d: not wielding the starting mace", seed) + } - // Every monster is indexed on the map and placed in a room. - for _, mon := range g.Level.Monsters { - if g.Level.MonsterAt(mon.Pos.Y, mon.Pos.X) != mon { - 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) - } - } - - // Every level object sits on a cell displaying its type (items can - // share cells only with monsters standing on them). - for _, obj := range g.Level.Objects { - ch := g.Level.Char(obj.Pos.Y, obj.Pos.X) - if ch != obj.Kind.Glyph() && - g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil { - t.Errorf("seed %d: object %v at (%d,%d) but map shows %q", - seed, obj.Kind, obj.Pos.Y, obj.Pos.X, ch) - } - } - - // The player has her starting kit: food, armor, mace, bow, arrows. - if len(g.Player.Pack) != 5 { - 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) - } + if g.Player.CurArmor == nil || + g.Player.CurArmor.ArmorKind() != ArmorRingMail { + t.Errorf("seed %d: not wearing the starting ring mail", seed) } } diff --git a/game/save_test.go b/game/save_test.go index 4bbe291..34bad67 100644 --- a/game/save_test.go +++ b/game/save_test.go @@ -39,6 +39,15 @@ func TestSaveRestoreRoundTrip(t *testing.T) { t.Error("save file not deleted on restore (C anti-restart rule)") } + checkRestoredState(t, g, h) + checkRestoredMonsters(t, g, h) + checkEquipmentAliasing(t, g, h) +} + +// checkRestoredState verifies the scalar state survived the round trip. +func checkRestoredState(t *testing.T, g, h *RogueGame) { + t.Helper() + if h.Player.Purse != 123 || h.Player.FoodLeft != 777 { t.Errorf("player state lost: purse=%d food=%d", h.Player.Purse, h.Player.FoodLeft) @@ -67,28 +76,41 @@ func TestSaveRestoreRoundTrip(t *testing.T) { if renderMap(h) != renderMap(g) { t.Error("restored level map differs") } +} + +// checkRestoredMonsters verifies the monster list and its pointer fixups +// survived the round trip. +func checkRestoredMonsters(t *testing.T, g, h *RogueGame) { + t.Helper() 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") - } + if len(g.Level.Monsters) == 0 { + return } - // Equipment aliasing: the wielded mace must be the same *Object as the - // one in the pack. + + 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") + } +} + +// checkEquipmentAliasing verifies the wielded mace is the same *Object as +// the one in the pack. +func checkEquipmentAliasing(t *testing.T, g, h *RogueGame) { + t.Helper() + st := g.snapshot() t.Logf("snapshot indices: weapon=%d armor=%d rings=%v packlen=%d", st.Player.CurWeapon, st.Player.CurArmor, st.Player.CurRing, diff --git a/game/tables_test.go b/game/tables_test.go index 5226ac4..313bc3f 100644 --- a/game/tables_test.go +++ b/game/tables_test.go @@ -48,6 +48,22 @@ func TestInitProbsCumulative(t *testing.T) { func TestNewGameRandomizesAppearances(t *testing.T) { g := NewGame(Config{Seed: 12345}) + + checkPotionColors(t, g) + checkScrollNames(t, g) + checkWandMaterials(t, g) + + // Determinism: same seed, same appearances. + h := NewGame(Config{Seed: 12345}) + if h.Items != g.Items { + t.Error("two games with the same seed produced different item lore") + } +} + +// checkPotionColors verifies every potion has a distinct color. +func checkPotionColors(t *testing.T, g *RogueGame) { + t.Helper() + seen := map[string]bool{} for i, c := range g.Items.PotColors { @@ -61,6 +77,12 @@ func TestNewGameRandomizesAppearances(t *testing.T) { seen[c] = true } +} + +// checkScrollNames verifies every scroll has a name within the C buffer +// limit. +func checkScrollNames(t *testing.T, g *RogueGame) { + t.Helper() for i, n := range g.Items.ScrNames { if n == "" { @@ -71,9 +93,15 @@ func TestNewGameRandomizesAppearances(t *testing.T) { t.Errorf("scroll name %q longer than C buffer allows", n) } } +} + +// checkWandMaterials verifies every stick has a wand/staff type and a +// material. +func checkWandMaterials(t *testing.T, g *RogueGame) { + t.Helper() for i := range g.Items.WandType { - if g.Items.WandType[i] != "wand" && g.Items.WandType[i] != "staff" { + if g.Items.WandType[i] != wandName && g.Items.WandType[i] != staffName { t.Errorf("stick %d has type %q", i, g.Items.WandType[i]) } @@ -81,12 +109,6 @@ func TestNewGameRandomizesAppearances(t *testing.T) { t.Errorf("stick %d has no material", i) } } - - // Determinism: same seed, same appearances. - h := NewGame(Config{Seed: 12345}) - if h.Items != g.Items { - t.Error("two games with the same seed produced different item lore") - } } func TestMonsterTable(t *testing.T) { From ad098f9d99cd22c7fb5bccd6d3c30769054a0236 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 22 Jul 2026 22:18:11 +0700 Subject: [PATCH 32/32] Rotate TODO to step 8; note complexity linters clean (step 7 done) Refactor step 7 is complete: the whole golangci-lint run is 0 issues. MEMORY.md updated to reflect the complexity linters are enabled and clean. --- MEMORY.md | 13 +++++++------ TODO.md | 40 +++++++++++++++++++++++++--------------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 0069e1c..ece0780 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -21,12 +21,13 @@ 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); testpackage, -exhaustive, and mnd (2026-07-07). Complexity linters (cyclop, gocognit, -nestif) stay enabled and red until refactor step 7 fixes the findings, -per sneak 2026-07-07. 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. +exhaustive, and mnd (2026-07-07). The complexity linters (cyclop, +gocognit, nestif) are enabled and clean as of refactor step 7 +(2026-07-07): the whole golangci-lint run is 0 issues, so keep it that +way — decompose new hot spots rather than reaching for a nolint. +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 diff --git a/TODO.md b/TODO.md index 8a09b43..77ae6c7 100644 --- a/TODO.md +++ b/TODO.md @@ -29,13 +29,27 @@ Refactor ground rules: # Next Step -Refactor step 7: effects dispatch — the giant quaff/readScroll/doZap -switches become per-kind handler tables of small named methods, -keeping effect order and RNG call sequence identical. This step also -clears the outstanding cyclop/gocognit/nestif lint findings. +Refactor step 8: constructor and style pass per the house styleguide — +game.New(game.Params{...}) replacing NewGame(Config); replace the +gameEnd panic unwind with error-based turn results where feasible; +77-column wrap sweep. # Completed Steps +- 2026-07-07 Refactor step 7 (refactor/effects-dispatch): effects + dispatch tables plus a full decomposition sweep — the quaff / + readScroll / doZap switches, the attack monster-power switch, the + be_trapped switch, the daemon d_func switch, and the command-key + switch all became handler tables on gameData (quaffHandlers, + readHandlers, zapHandlers, hitHandlers, trapHandlers, daemonHandlers, + commandHandlers), one small named method per case. Every remaining + cyclop/gocognit/nestif hot spot was split into named helpers across + fight, misc (look), command, chase, move, passages, options, pack, + things, save, daemons, rooms, score, monsters, rings, rip, io, + object, weapons, wizard, and term/tcell, plus three test functions. + Effect order and RNG call sequence preserved throughout; the whole + golangci-lint run is now 0 issues. + - 2026-07-07 Refactor step 6 (refactor/god-object-extraction): MessageLine (was MsgLine) owns the msg/addmsg/endmsg machinery, wired to its screen/look/input needs via attach(); RogueGame keeps @@ -128,27 +142,23 @@ clears the outstanding cyclop/gocognit/nestif lint findings. # Future Steps -1. Refactor step 8: constructor and style pass per the house - styleguide — game.New(game.Params{...}) replacing NewGame(Config); - replace the gameEnd panic unwind with error-based turn results where - feasible; 77-column wrap sweep. -2. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the +1. Docs refresh: update ARCHITECTURE.md Part 2 and README.md for the post-refactor names; add the C name → Go name rename table. -3. Playtest hardening pass: play several full games with the tcell +2. Playtest hardening pass: play several full games with the tcell binary and extend run_test.go to script a deeper multi-level playthrough (descend past level 5, use potions, scrolls, zapping, save/restore). Fix any panics, message mismatches, or divergences from the C behavior that this uncovers, with regression tests. -4. Verify the seed-compatibility claim against the C reference on +3. Verify the seed-compatibility claim against the C reference on c-master: same seed, same dungeon, same item tables, for several seeds. -5. Broaden unit test coverage where playtesting finds thin spots +4. Broaden unit test coverage where playtesting finds thin spots (rings, sticks, wizard commands). -6. Tag a release once a full game (Amulet retrieval and score entry) +5. Tag a release once a full game (Amulet retrieval and score entry) completes without defects. -7. Full-terminal-size support (deferred by explicit decision +6. Full-terminal-size support (deferred by explicit decision 2026-07-06): per-game dungeon dimensions instead of the 80x24 constants; open design questions are resize policy, gameplay tuning at larger sizes, and a --classic 80x24 mode. -8. Note: this repo is exempt from the standard policy scaffold. Do not +7. Note: this repo is exempt from the standard policy scaffold. Do not add Makefile, Dockerfile, or REPO_POLICIES.md.