go: port combat, chase driver, traps, zapping, death and scores

- fight.c in full: fight/attack with all eight special monster attacks
  (aquator rust, ice freeze, rattlesnake poison, wraith/vampire drains,
  flytrap hold, leprechaun/nymph theft), swing, roll_em dice parsing
  (with a C-semantics atoi — Go's strconv rejects '1x4/...'), hit/miss
  message variants, killed, remove_mon
- chase.c completed: runners, move_monst, relocate, do_chase with
  dragon breath, chase target selection (C's dead oroom comparison in
  relocate is preserved as-is)
- move.c in full: do_move with passgo turning, all eight traps,
  rndmove, rust_armor
- sticks.c completed: do_zap (all 14 sticks), drain, fire_bolt with
  wall bounces; weapons.c completed: missile/do_motion/fall/wield
- rip.c: death tombstone, total_winner, killname; C exit() becomes a
  gameEnd panic that Run will recover
- score.go: top-ten scoreboard as a gob file with lock-file protocol
  replacing the XOR-encrypted C format
- wizard.c: whatis/set_know/teleport; daemons.c stomach
- monster bestiary moved to per-game state (C mutates the flytrap
  damage string during play)

Tests: combat math, kill/removal bookkeeping, monster chase pursuit,
death unwinding, scripted-input headless terminal.
This commit is contained in:
2026-07-06 19:23:45 +02:00
parent a69ef7dc04
commit 3c5add87cd
13 changed files with 2315 additions and 24 deletions

View File

@@ -1,7 +1,49 @@
package game
// fight.c — combat. setMname arrives first (monster wake-ups need it); the
// combat resolution functions come with the combat phase.
import (
"fmt"
"strconv"
"strings"
)
// fight.c — all the fighting gets done here.
// hNames are the strings for hitting; the first four are used when the
// player strikes, the second four for monsters (fight.c h_names).
var hNames = [8]string{
" scored an excellent hit on ",
" hit ",
" have injured ",
" swing and hit ",
" scored an excellent hit on ",
" hit ",
" has injured ",
" swings and hits ",
}
// mNames are the strings for missing (fight.c m_names).
var mNames = [8]string{
" miss",
" swing and miss",
" barely miss",
" don't hit",
" misses",
" swings and misses",
" barely misses",
" doesn't hit",
}
// strPlus adjusts hit probabilities due to strength (fight.c str_plus).
var strPlus = [32]int{
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3,
}
// addDam adjusts damage done due to strength (fight.c add_dam).
var addDam = [32]int{
-7, -6, -5, -4, -3, -2, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 3,
3, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6,
}
// setMname returns the monster name for the given monster (fight.c
// set_mname).
@@ -20,9 +62,517 @@ func (g *RogueGame) setMname(tp *Monster) string {
} else {
ch -= 'A'
}
mname = monsterTable[ch].Name
mname = g.Monsters[ch].Name
} else {
mname = monsterTable[tp.Type-'A'].Name
mname = g.Monsters[tp.Type-'A'].Name
}
return "the " + mname
}
// fight has the player attack the monster at mp (fight.c fight).
func (g *RogueGame) fight(mp Coord, weap *Object, thrown bool) bool {
p := &g.Player
// Find the monster we want to fight
tp := g.Level.MonsterAt(mp.Y, mp.X)
if tp == nil {
return false
}
// Since we are fighting, things are not quiet so no healing takes
// place.
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(IsBlind) {
tp.Disguise = 'X'
if p.On(IsHalu) {
g.mvaddch(tp.Pos.Y, tp.Pos.X, byte(g.rnd(26)+'A'))
}
g.msg("%s", g.chooseStr("heavy! That's a nasty critter!",
"wait! That's a xeroc!"))
if !thrown {
return false
}
}
mname := g.setMname(tp)
didHit := false
g.HasHit = g.Options.Terse && !g.ToDeath
if g.rollEm(&p.Creature, &tp.Creature, weap, thrown) {
didHit = false
if thrown {
g.thunk(weap, mname, g.Options.Terse)
} else {
g.hit("", mname, g.Options.Terse)
}
if p.On(CanHuh) {
didHit = true
tp.Flags.Set(IsHuh)
p.Flags.Clear(CanHuh)
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(IsBlind) {
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 didHit
}
// attack has the monster attack the player (fight.c attack). Returns -1 if
// the monster removed itself from the level during its own attack.
func (g *RogueGame) attack(mp *Monster) int {
p := &g.Player
// Since this is an attack, stop running and any healing that was
// going on at the time.
g.Running = false
g.Count = 0
g.Quiet = 0
if g.ToDeath && !mp.On(IsTarget) {
g.ToDeath = false
g.Kamikaze = false
}
if mp.Type == 'X' && mp.Disguise != 'X' && !p.On(IsBlind) {
mp.Disguise = 'X'
if p.On(IsHalu) {
g.mvaddch(mp.Pos.Y, mp.Pos.X, byte(g.rnd(26)+'A'))
}
}
mname := g.setMname(mp)
oldhp := p.Stats.HP
removed := false
if g.rollEm(&mp.Creature, &p.Creature, nil, false) {
if mp.Type != 'I' {
if g.HasHit {
g.addmsg(". ")
}
g.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(IsCanc) {
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(IsRun)
if g.NoCommand == 0 {
g.addmsg("you are frozen")
if !g.Options.Terse {
g.addmsg(" 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(RSustStr) {
g.chgStr(-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 = 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(IsHeld)
p.VfHit++
g.Monsters['F'-'A'].Stats.Dmg = fmt.Sprintf("%dx1", p.VfHit)
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] &&
obj.isMagic() {
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.invName(steal, true))
}
}
}
} else if mp.Type != 'I' {
if g.HasHit {
g.addmsg(". ")
g.HasHit = false
}
if mp.Type == 'F' {
p.Stats.HP -= p.VfHit
if p.Stats.HP <= 0 {
g.death(mp.Type) // Bye bye life ...
}
}
g.miss(mname, "", false)
}
if g.Options.FightFlush && !g.ToDeath {
g.flushType()
}
g.Count = 0
g.status()
if removed {
return -1
}
return 0
}
// swing returns true if the swing hits (fight.c swing).
func (g *RogueGame) swing(atLvl, opArm, wplus int) bool {
res := g.rnd(20)
need := (20 - atLvl) - opArm
return res+wplus >= need
}
// rollEm rolls several attacks (fight.c roll_em).
func (g *RogueGame) rollEm(thatt, thdef *Creature, weap *Object, hurl bool) bool {
p := &g.Player
att := &thatt.Stats
def := &thdef.Stats
var cp string
var hplus, dplus int
if weap == nil {
cp = att.Dmg
} else {
hplus = weap.HPlus
dplus = weap.DPlus
if weap == p.CurWeapon {
if p.IsRing(Left, RAddDam) {
dplus += p.CurRing[Left].Arm
} else if p.IsRing(Left, RAddHit) {
hplus += p.CurRing[Left].Arm
}
if p.IsRing(Right, RAddDam) {
dplus += p.CurRing[Right].Arm
} else if p.IsRing(Right, RAddHit) {
hplus += p.CurRing[Right].Arm
}
}
cp = weap.Damage
if hurl {
if weap.Flags.Has(IsMissl) && p.CurWeapon != nil &&
p.CurWeapon.Which == weap.Launch {
cp = weap.HurlDmg
hplus += p.CurWeapon.HPlus
dplus += p.CurWeapon.DPlus
} else if weap.Launch < 0 {
cp = weap.HurlDmg
}
}
}
// If the creature being attacked is not running (asleep or held) then
// the attacker gets a plus four bonus to hit.
if !thdef.Flags.Has(IsRun) {
hplus += 4
}
defArm := def.Arm
if def == &p.Stats {
if p.CurArmor != nil {
defArm = p.CurArmor.Arm
}
if p.IsRing(Left, RProtect) {
defArm -= p.CurRing[Left].Arm
}
if p.IsRing(Right, RProtect) {
defArm -= p.CurRing[Right].Arm
}
}
didHit := false
for cp != "" {
ndice := cAtoi(cp)
xi := strings.IndexByte(cp, 'x')
if xi < 0 {
break
}
cp = cp[xi+1:]
nsides := cAtoi(cp)
if g.swing(att.Lvl, defArm, hplus+strPlus[att.Str]) {
proll := g.roll(ndice, nsides)
damage := dplus + proll + addDam[att.Str]
if damage > 0 {
def.HP -= damage
}
didHit = true
}
si := strings.IndexByte(cp, '/')
if si < 0 {
break
}
cp = cp[si+1:]
}
return didHit
}
// cAtoi parses a leading integer like C atoi: trailing non-digits are
// ignored rather than an error.
func cAtoi(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
n, _ := strconv.Atoi(s[:i])
return n
}
// prname gives the print name of a combatant; "" is the player (fight.c
// prname).
func prname(mname string, upper bool) string {
out := mname
if out == "" {
out = "you"
}
if upper {
out = string(toUpper(out[0])) + out[1:]
}
return out
}
// thunk announces that a missile hit a monster (fight.c thunk).
func (g *RogueGame) thunk(weap *Object, mname string, noend bool) {
if g.ToDeath {
return
}
if weap.Type == Weapon {
g.addmsg("the %s hits ", g.Items.Weapons[weap.Which].Name)
} else {
g.addmsg("you hit ")
}
g.addmsg("%s", mname)
if !noend {
g.endmsg()
}
}
// hit prints a message to indicate a successful hit (fight.c hit).
func (g *RogueGame) hit(er, ee string, noend bool) {
if g.ToDeath {
return
}
g.addmsg("%s", prname(er, true))
var s string
if g.Options.Terse {
s = " hit"
} else {
i := g.rnd(4)
if er != "" {
i += 4
}
s = hNames[i]
}
g.addmsg("%s", s)
if !g.Options.Terse {
g.addmsg("%s", prname(ee, false))
}
if !noend {
g.endmsg()
}
}
// miss prints a message to indicate a poor swing (fight.c miss).
func (g *RogueGame) miss(er, ee string, noend bool) {
if g.ToDeath {
return
}
g.addmsg("%s", prname(er, true))
i := 0
if !g.Options.Terse {
i = g.rnd(4)
}
if er != "" {
i += 4
}
g.addmsg("%s", mNames[i])
if !g.Options.Terse {
g.addmsg(" %s", prname(ee, false))
}
if !noend {
g.endmsg()
}
}
// bounce announces that a missile missed a monster (fight.c bounce).
func (g *RogueGame) bounce(weap *Object, mname string, noend bool) {
if g.ToDeath {
return
}
if weap.Type == Weapon {
g.addmsg("the %s misses ", g.Items.Weapons[weap.Which].Name)
} else {
g.addmsg("you missed ")
}
g.addmsg("%s", mname)
if !noend {
g.endmsg()
}
}
// removeMon removes a monster from the screen (fight.c remove_mon).
func (g *RogueGame) removeMon(mp Coord, tp *Monster, waskill bool) {
pack := append([]*Object(nil), tp.Pack...)
for _, obj := range pack {
obj.Pos = tp.Pos
detachObj(&tp.Pack, obj)
if waskill {
g.fall(obj, false)
}
}
g.Level.SetMonsterAt(mp.Y, mp.X, nil)
g.mvaddch(mp.Y, mp.X, tp.OldCh)
detachMon(&g.Level.Monsters, tp)
if tp.On(IsTarget) {
g.Kamikaze = false
g.ToDeath = false
if g.Options.FightFlush {
g.flushType()
}
}
}
// killed is called to put a monster to death (fight.c killed).
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(IsHeld)
p.VfHit = 0
g.Monsters['F'-'A'].Stats.Dmg = "000x0"
case 'L':
pos, ok := g.fallpos(tp.Pos)
if ok {
tp.Room.Gold = pos
}
if ok && g.Depth >= g.MaxDepth {
gold := newObject()
gold.Type = Gold
gold.SetGoldVal(g.goldCalc())
if g.save(VsMagic) {
gold.Arm += g.goldCalc() + g.goldCalc() + g.goldCalc() + g.goldCalc()
}
attachObj(&tp.Pack, gold)
}
}
// Get rid of the monster.
mname := g.setMname(tp)
g.removeMon(tp.Pos, tp, true)
if pr {
if g.HasHit {
g.addmsg(". Defeated ")
g.HasHit = false
} else {
if !g.Options.Terse {
g.addmsg("you have ")
}
g.addmsg("defeated ")
}
g.msg("%s", mname)
}
// Do adjustments if he went up a level
g.checkLevel()
if g.Options.FightFlush {
g.flushType()
}
}
// flushType flushes typeahead for the fight_flush option (mach_dep.c
// flush_type / curses flushinp).
func (g *RogueGame) flushType() {
if f, ok := g.scr.term.(interface{ FlushInput() }); ok {
f.FlushInput()
}
}