createObj stored the raw 0-f nibble as Object.Which with no bounds check, so wizard mode -> C -> / -> f made a wand numbered 15 against a 14-entry table and panicked in fixStick. Input outside 0-f overshoots much further rather than going negative: readchar returns a byte, so the int(ch-'a') + 10 branch is byte arithmetic and wraps, giving 234 for 'A' and 202 for '!', and panicked the same way. C's create_obj() was equally unchecked, but its consumers were either switches (defined for any value) or static-array reads past the end (undefined, and survivable in practice). Since one game is now one process, the Go panic kills the game outright and leaves the terminal in raw mode. Reject at the two boundaries a bad Which can enter through. createObj now refuses an out-of-range choice with a message drawn from C's own type_name() vocabulary and adds nothing to the pack, a deliberate divergence recorded in a comment because C had no defined behavior here to be faithful to. Restore refuses a snapshot describing such an object (ErrSaveCorrupt) rather than loading a game that would explode later. A decoded snapshot is also the only source of a genuinely negative Which, Which being a plain int off the wire, so it is what the Which >= 0 arm of hasValidWhich defends against. Behind those, whichLimit/hasValidWhich back defensive guards at every dispatch the issue names: the quaffHandler/readHandler/zapHandler accessors return no handler instead of indexing (for wands that is exactly what non-MASTER C did, matching no case and still running o_charges--), the callIt lore lookups, identifyType, armorClass for the a_class[] reads, initWeapon against the missing init_dam[] row for WeaponFlame, fixStick's ws_type[] read, and inventoryName and objectWorth, hoisted so one check each covers the whole family of per-kind name and appraisal tables. identifyType's bound is defensive rather than live: readHandlers registers readIdentify only for the identify scrolls, all of which sit inside the shorter idType table. No in-range input changes behavior and no guard consumes a random number: the rejection precedes every rnd() call. TestSeedCompatItemTables stays green untouched. New game/wizard_test.go covers the exact reproducer, a rejection sweep over every indexed kind including the wrapped values from input outside 0-f, an acceptance sweep proving valid choices still build the right item, one no-panic test per guarded family, the fixStick crash site, the corrupt-save rejection over both the wrapped values and a negative Which, and a check that whichLimit still agrees with the table sizes. Each guard was confirmed load-bearing by reverting it and watching the test fail. TODO.md records the step; Next Step is deliberately left alone, since this arrived out of band via an issue.
543 lines
12 KiB
Go
543 lines
12 KiB
Go
//nolint:mnd // C-faithful literals; names hurt C-greppability (approved 2026-07-07)
|
|
package game
|
|
|
|
import "fmt"
|
|
|
|
// sticks.c — zap wands and staffs.
|
|
|
|
// The two ws_type strings a stick can be made as.
|
|
const (
|
|
wandName = "wand"
|
|
staffName = "staff"
|
|
)
|
|
|
|
// doZap performs a zap with a wand (sticks.c do_zap).
|
|
func (g *RogueGame) doZap() {
|
|
obj, ok := g.promptPackItem("zap with", KindWand)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if obj.Kind != KindWand {
|
|
g.After = false
|
|
g.msg("you can't zap with that!")
|
|
|
|
return
|
|
}
|
|
|
|
if obj.Charges == 0 {
|
|
g.msg("nothing happens")
|
|
|
|
return
|
|
}
|
|
|
|
// A malformed Which yields no handler, which is exactly what C did in a
|
|
// non-MASTER build: its do_zap switch matched no case, fell out, and
|
|
// still ran o_charges--. (The MASTER-only "what a bizarre schtick!"
|
|
// message for that arm is issue #13, not this bounds fix.)
|
|
if h := g.data.zapHandler(obj); h != nil {
|
|
if !h(g, obj) {
|
|
return // the zap aborted; no charge is used
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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
|
|
|
|
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
|
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
|
|
// First count how many things we need to spread the hit points among
|
|
var corp *Room
|
|
if g.Level.Char(p.Pos.Y, p.Pos.X) == Door {
|
|
corp = &g.Level.Passages[*g.Level.FlagsAt(p.Pos.Y, p.Pos.X)&FPassNum]
|
|
}
|
|
|
|
inpass := p.Room.Flags.Has(Gone)
|
|
|
|
var drainee []*Monster
|
|
|
|
for _, mp := range g.Level.Monsters {
|
|
if g.drainReaches(mp, corp, inpass) {
|
|
drainee = append(drainee, mp)
|
|
}
|
|
}
|
|
|
|
cnt := len(drainee)
|
|
if cnt == 0 {
|
|
g.msg("you have a tingling feeling")
|
|
|
|
return
|
|
}
|
|
|
|
p.Stats.HP /= 2
|
|
cnt = p.Stats.HP / cnt
|
|
// Now zot all of the monsters
|
|
for _, mp := range drainee {
|
|
if mp.Stats.HP -= cnt; mp.Stats.HP <= 0 {
|
|
g.killed(mp, g.seeMonst(mp))
|
|
} else {
|
|
g.runTo(mp.Pos)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
p := &g.Player
|
|
fromHero := start == p.Pos
|
|
|
|
bolt := newObject()
|
|
bolt.Kind = KindWeapon
|
|
bolt.Which = int(WeaponFlame)
|
|
bolt.HurlDmg = dice("6x6")
|
|
bolt.HPlus = 100
|
|
bolt.DPlus = 0
|
|
g.Items.Weapons[WeaponFlame].Name = name
|
|
|
|
dirch := boltDirChar(*dir)
|
|
pos := start
|
|
hitHero := !fromHero
|
|
used := false
|
|
changed := false
|
|
|
|
var spotpos []Coord
|
|
for len(spotpos) < BoltLength && !used {
|
|
pos.Y += dir.Y
|
|
pos.X += dir.X
|
|
spotpos = append(spotpos, pos)
|
|
|
|
ch := g.Level.VisibleChar(pos.Y, pos.X)
|
|
if boltBounces(ch, p.Pos, pos) {
|
|
if !changed {
|
|
hitHero = !hitHero
|
|
}
|
|
|
|
changed = false
|
|
dir.Y = -dir.Y
|
|
dir.X = -dir.X
|
|
spotpos = spotpos[:len(spotpos)-1]
|
|
|
|
g.msg("the %s bounces", name)
|
|
|
|
continue
|
|
}
|
|
|
|
if tp := g.Level.MonsterAt(pos.Y, pos.X); !hitHero && tp != nil {
|
|
hitHero = true
|
|
changed = !changed
|
|
used = g.boltStrikesMonster(tp, bolt, pos, ch, name, fromHero)
|
|
} else if hitHero && pos == p.Pos {
|
|
hitHero = false
|
|
changed = !changed
|
|
used = g.boltStrikesHero(start, name, fromHero)
|
|
}
|
|
|
|
g.mvaddch(pos.Y, pos.X, dirch)
|
|
g.refresh()
|
|
}
|
|
// erase the bolt trail
|
|
for _, c2 := range spotpos {
|
|
g.mvaddch(c2.Y, c2.X, g.Level.Char(c2.Y, c2.X))
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// ws_type[] is indexed by Which; a malformed one is treated as a wand,
|
|
// which is the branch the C string compare would take against any
|
|
// value that is not literally "staff". The charge switch below already
|
|
// funnels everything but WandLight into its default arm.
|
|
if cur.hasValidWhich() && g.Items.WandType[cur.Which] == staffName {
|
|
cur.Damage = dice("2x3")
|
|
} else {
|
|
cur.Damage = dice("1x1")
|
|
}
|
|
|
|
cur.HurlDmg = dice("1x1")
|
|
|
|
//nolint:exhaustive // C-faithful: only the cases C handled (approved 2026-07-07)
|
|
switch cur.WandKind() {
|
|
case WandLight:
|
|
cur.Charges = g.rnd(10) + 10
|
|
default:
|
|
cur.Charges = g.rnd(5) + 3
|
|
}
|
|
}
|
|
|
|
// chargeStr returns the charge-count suffix for an identified stick
|
|
// (sticks.c charge_str).
|
|
func chargeStr(g *RogueGame, obj *Object) string {
|
|
if !obj.Flags.Has(Known) {
|
|
return ""
|
|
}
|
|
|
|
if g.Options.Terse {
|
|
return fmt.Sprintf(" [%d]", obj.Charges)
|
|
}
|
|
|
|
return fmt.Sprintf(" [%d charges]", obj.Charges)
|
|
}
|