Files
rgoue/game/sticks.go
sneak 1142f43aed Restore three lost C behaviors: schtick message, forced redraw, greeting (closes #13)
Three behaviors from 5.4.4 that the port dropped silently. Each is a few
lines; grouped because they are all "restore something C did".

1. sticks.c 237: the "otherwise" arm closing do_zap's switch printed
   "what a bizarre schtick!", and doZap had turned it into doing nothing.
   The arm is under #ifdef MASTER, not under a runtime wizard test, so in
   the MASTER build this port is it printed for every player and must not
   be gated on g.Wizard. WS_NOP is a case of that switch in its own right
   ("when WS_NOP: break;"), so "no handler ran" cannot be the trigger:
   the wand of nothing does nothing quietly. C's switch covers all 14 WS_
   values, so its otherwise is reachable only for an o_which outside the
   table, which is what Object.hasValidWhich already screens for. All
   three arms fall through to obj.Charges--, as C's do.

2. command.c 288-291: CTRL('R') is "after = FALSE; clearok(curscr, TRUE);
   wrefresh(curscr);" — a forced full repaint. The port called
   g.refresh(), the ordinary diffing blit, which cannot fix the only
   situation the command exists for: a screen corrupted by another
   program's output leaves the game's record of it still correct, so the
   diff sends nothing. New Terminal.Repaint (tcell Screen.Sync, which
   discards tcell's record of the terminal rather than diffing against
   it), Screen.Repaint and g.repaint(), implemented in term.Tcell and in
   both headless test terminals. Named for the curses operation: the
   interface is the game's abstraction, not tcell's. It repaints what was
   last rendered — C repainted curscr, not stdscr — so it takes no
   window.

3. main.c 107-113: the startup greeting existed nowhere in the tree. New
   game.Greeting, printed on stdout by cmd/rogue/main.go before
   term.New(), the port's initscr(). Only the wizard wording is #ifdef
   MASTER; the other is unconditional. The %d is dnum, which main.c has
   just assigned to seed, so it is Params.Seed. Neither wording ends in a
   newline. Two placement details the tests pin: the printf sits after
   parse_opts, so a ROGUEOPTS name= is what the player is greeted by; and
   it sits after the -s/-d handling and after restore(), which never
   returns, so a resumed game does not announce that a dungeon is being
   dug (digsNewDungeon).

Greeting parses ROGUEOPTS into a throwaway game built the way New builds
the real one, tables and home directory included: ParseOpts handles every
option, not just the one the greeting reads, and inven= is matched
against inv_t_name[], which lives on the game.

All three message strings verified byte-for-byte against origin/c-master
sticks.c and main.c. No RNG call is added on any path and nothing under
game/testdata/ changed; TestSeedCompatItemTables is green against the
untouched golden.

Mutation-proved, each behavior removed in turn with only its own test
failing: dropping the message arm fails
TestZapUnhandledWandSaysBizarreSchtick; extending the message to WS_NOP
fails TestZapWandOfNothingIsSilent; putting g.refresh() back fails
TestRedrawCommandForcesFullRepaint; swapping the two wordings, and
ignoring the ROGUEOPTS name, both fail TestGreeting; greeting on the
restore path fails TestDigsNewDungeon.

ARCHITECTURE.md 5.3 gains Repaint and why a blit cannot substitute for
it; nothing here is deliberately dropped, so section 9 is unchanged.
TODO.md gets a Completed Steps entry; Next Step deliberately not rotated,
this being out-of-band issue work.
2026-08-09 10:04:54 +00:00

559 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
}
// C's switch has a case for every one of the 14 WS_ kinds, so its
// closing "otherwise: msg(...)" arm is reachable only for an o_which
// outside the table — the malformed objects hasValidWhich screens
// for, which is why no handler and a legal Which can only mean WS_NOP.
//
// The message is under #ifdef MASTER, not under a wizard test: C
// printed it for every player of a MASTER build, which is the build
// this port is (see the '+' command, issue #11). Do not gate it on
// g.Wizard.
//
// WS_NOP is a case of its own ("when WS_NOP: break;"): the wand that
// deliberately does nothing says nothing either. All three arms fall
// out of the switch into o_charges--, so even the bizarre schtick
// costs a charge.
h := g.data.zapHandler(obj)
switch {
case h != nil:
if !h(g, obj) {
return // the zap aborted; no charge is used
}
case obj.hasValidWhich(): // WS_NOP
default:
g.msg("what a bizarre schtick!")
}
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)
}