From 3c5add87cd63ba1685fb12a314cf937c82f6c2ed Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 6 Jul 2026 19:23:45 +0200 Subject: [PATCH] go: port combat, chase driver, traps, zapping, death and scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- game/chase.go | 265 ++++++++++++++++++++- game/daemons.go | 55 +++++ game/fight.go | 558 ++++++++++++++++++++++++++++++++++++++++++++- game/fight_test.go | 118 ++++++++++ game/game.go | 16 +- game/monsters.go | 4 +- game/move.go | 326 +++++++++++++++++++++++++- game/rip.go | 229 +++++++++++++++++++ game/score.go | 196 ++++++++++++++++ game/sticks.go | 318 +++++++++++++++++++++++++- game/term_test.go | 25 ++ game/weapons.go | 118 +++++++++- game/wizard.go | 111 +++++++++ 13 files changed, 2315 insertions(+), 24 deletions(-) create mode 100644 game/fight_test.go create mode 100644 game/rip.go create mode 100644 game/score.go create mode 100644 game/term_test.go create mode 100644 game/wizard.go diff --git a/game/chase.go b/game/chase.go index cfbb5cd..222624d 100644 --- a/game/chase.go +++ b/game/chase.go @@ -1,8 +1,265 @@ package game -// chase.c — the navigation and visibility half. The chase driver (runners, -// move_monst, do_chase, chase) arrives with the combat phase, since it -// attacks the player and fires dragon bolts. +// chase.c — code for one creature to chase another. + +// dragonShot: one chance in DRAGONSHOT that a dragon will flame. +const dragonShot = 5 + +// runners makes all the running monsters move (chase.c runners). +func (g *RogueGame) runners(int) { + list := append([]*Monster(nil), g.Level.Monsters...) + for _, tp := range list { + if !tp.On(IsHeld) && tp.On(IsRun) { + origPos := tp.Pos + wastarget := tp.On(IsTarget) + if g.moveMonst(tp) == -1 { + continue + } + if tp.On(IsFly) && distCp(g.Player.Pos, tp.Pos) >= 3 { + if g.moveMonst(tp) == -1 { + continue + } + } + if wastarget && origPos != tp.Pos { + tp.Flags.Clear(IsTarget) + g.ToDeath = false + } + } + } + if g.HasHit { + g.endmsg() + g.HasHit = false + } +} + +// moveMonst executes a single turn of running for a monster (chase.c +// move_monst). Returns -1 if the monster died or left the level. +func (g *RogueGame) moveMonst(tp *Monster) int { + if !tp.On(IsSlow) || tp.Turn { + if g.doChase(tp) == -1 { + return -1 + } + } + if tp.On(IsHaste) { + if g.doChase(tp) == -1 { + return -1 + } + } + tp.Turn = !tp.Turn + return 0 +} + +// relocate makes the monster's new location be the specified one, updating +// all the relevant state (chase.c relocate). +func (g *RogueGame) relocate(th *Monster, newLoc Coord) { + if newLoc != th.Pos { + g.mvaddch(th.Pos.Y, th.Pos.X, th.OldCh) + th.Room = g.roomin(newLoc) + g.setOldch(th, newLoc) + oroom := th.Room + g.Level.SetMonsterAt(th.Pos.Y, th.Pos.X, nil) + + if oroom != th.Room { + th.Dest = g.findDest(th) + } + th.Pos = newLoc + g.Level.SetMonsterAt(newLoc.Y, newLoc.X, th) + } + g.move(newLoc.Y, newLoc.X) + if g.seeMonst(th) { + g.addch(th.Disguise) + } else if g.Player.On(SeeMonst) { + g.standout() + g.addch(th.Type) + g.standend() + } +} + +// doChase makes one thing chase another (chase.c do_chase). Returns -1 if +// the chaser died in the attempt. +func (g *RogueGame) doChase(th *Monster) int { + p := &g.Player + stoprun := false // true means we are there + mindist := 32767 + + rer := th.Room // find room of chaser + if th.On(IsGreed) && 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) + } + // We don't count doors as inside rooms for this routine + door := g.Level.Char(th.Pos.Y, th.Pos.X) == Door + var this Coord + +over: + // 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)&FPNum] + door = false + goto over + } + } 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(IsCanc) && 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(IsTarget) { + g.ToDeath = false + g.Kamikaze = false + } + return 0 + } + } + // 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 { + detachObj(&g.Level.Objects, obj) + attachObj(&th.Pack, obj) + if th.Room.Flags.Has(IsGone) { + 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 th.Type == 'F' { + return 0 + } + } + g.relocate(th, g.chRet) + // And stop running if need be + if stoprun && th.Pos == *th.Dest { + th.Flags.Clear(IsRun) + } + return 0 +} + +// 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 + // are slightly confused all of the time, and bats are quite confused + // all the time + if (tp.On(IsHuh) && g.rnd(5) != 0) || (tp.Type == 'P' && g.rnd(5) == 0) || + (tp.Type == 'B' && g.rnd(2) == 0) { + // get a valid random move + g.chRet = g.rndmove(&tp.Creature) + curdist = distCp(g.chRet, ee) + // Small chance that it will become un-confused + if g.rnd(20) == 0 { + tp.Flags.Clear(IsHuh) + } + } 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 + + 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 + } + 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.Which == SScare { + 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 + } + } + } + } + } + } + return curdist != 0 && g.chRet != p.Pos +} // setOldch sets the oldch character for the monster (chase.c set_oldch). func (g *RogueGame) setOldch(tp *Monster, cp Coord) { @@ -113,7 +370,7 @@ func (g *RogueGame) cansee(y, x int) bool { // findDest finds the proper destination for the monster (chase.c // find_dest). func (g *RogueGame) findDest(tp *Monster) *Coord { - prob := monsterTable[tp.Type-'A'].Carry + prob := g.Monsters[tp.Type-'A'].Carry if prob <= 0 || tp.Room == g.Player.Room || g.seeMonst(tp) { return &g.Player.Pos } diff --git a/game/daemons.go b/game/daemons.go index c5dda1c..b0fcb54 100644 --- a/game/daemons.go +++ b/game/daemons.go @@ -10,6 +10,10 @@ func (g *RogueGame) runDaemon(id DaemonID, arg int) { 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: @@ -120,6 +124,57 @@ func (g *RogueGame) nohaste(int) { g.msg("you feel yourself slowing down") } +// stomach digests the hero's food (daemons.c stomach). +func (g *RogueGame) stomach(int) { + p := &g.Player + origHungry := p.HungryState + if p.FoodLeft <= 0 { + if p.FoodLeft--; p.FoodLeft < -StarveTime { + 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.addmsg("%s", g.chooseStr( + "the munchies overpower your motor capabilities. ", + "you feel too weak from lack of food. ")) + } + g.msg("%s", g.chooseStr("You freak out", "You faint")) + } else { + oldfood := p.FoodLeft + amulet := 0 + if g.HasAmulet { + amulet = 1 + } + p.FoodLeft -= g.ringEat(Left) + g.ringEat(Right) + 1 - amulet + + if p.FoodLeft < MoreTime && oldfood >= MoreTime { + p.HungryState = 2 + g.msg("%s", g.chooseStr( + "the munchies are interfering with your motor capabilites", + "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")) + } + } + } + if p.HungryState != origHungry { + p.Flags.Clear(IsRun) + g.Running = false + g.ToDeath = false + g.Count = 0 + } +} + // comeDown takes the hero down off her acid trip (daemons.c come_down). func (g *RogueGame) comeDown(int) { p := &g.Player diff --git a/game/fight.go b/game/fight.go index 452d8a3..f6359c3 100644 --- a/game/fight.go +++ b/game/fight.go @@ -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() + } +} diff --git a/game/fight_test.go b/game/fight_test.go new file mode 100644 index 0000000..c700e81 --- /dev/null +++ b/game/fight_test.go @@ -0,0 +1,118 @@ +package game + +import "testing" + +// mkGame builds a headless game on a generated level, initializing the +// look() state the way playit() does before the first command. +func mkGame(t *testing.T, seed int32) *RogueGame { + t.Helper() + g := NewGame(Config{Seed: seed, Term: &testTerm{}}) + g.NewLevel() + g.Oldpos = g.Player.Pos + g.Oldrp = g.roomin(g.Player.Pos) + return g +} + +// spawnAdjacent puts a monster of the given type next to the hero. +func spawnAdjacent(g *RogueGame, typ byte) *Monster { + pos := Coord{X: g.Player.Pos.X + 1, Y: g.Player.Pos.Y} + tp := &Monster{} + g.newMonster(tp, typ, pos) + return tp +} + +func TestRollEmParsesMultiAttackDice(t *testing.T) { + g := mkGame(t, 42) + att := &Creature{Stats: Stats{Str: 16, Lvl: 20, Dmg: "1x4/1x4/1x4"}} + def := &Creature{Stats: Stats{Arm: 10, HP: 1000}} + def.Flags.Set(IsRun) + // With attacker level 20 vs armor 10, swing always hits + // (rnd(20)+wplus >= (20-20)-10 is always true), so three attacks of + // 1x4 + str bonus 1 each must deal between 6 and 15 damage. + if !g.rollEm(att, def, nil, false) { + t.Fatal("attack with guaranteed swing missed") + } + dmg := 1000 - def.Stats.HP + if dmg < 6 || dmg > 15 { + t.Errorf("three 1x4+1 attacks dealt %d damage, want 6..15", dmg) + } +} + +func TestFightKillsMonster(t *testing.T) { + g := mkGame(t, 7) + tp := spawnAdjacent(g, 'B') // bat: 1 hit die + tp.Stats.HP = 1 + g.Player.Stats.Lvl = 20 // always hits + before := len(g.Level.Monsters) + g.fight(tp.Pos, g.Player.CurWeapon, false) + if len(g.Level.Monsters) != before-1 { + t.Error("monster not removed after fatal fight") + } + if g.Level.MonsterAt(tp.Pos.Y, tp.Pos.X) != nil { + t.Error("map still records dead monster") + } + if g.Player.Stats.Exp == 0 { + t.Error("no experience for the kill") + } +} + +func TestAttackHurtsPlayer(t *testing.T) { + g := mkGame(t, 9) + tp := spawnAdjacent(g, 'T') // troll: 1x8/1x8/2x6 + tp.Stats.Lvl = 20 // always hits + tp.Flags.Clear(IsCanc) + hpBefore := g.Player.Stats.HP + g.Player.Stats.HP = 500 + g.Player.Stats.MaxHP = 500 + g.attack(tp) + if g.Player.Stats.HP >= 500 { + t.Errorf("player HP unchanged (%d -> %d)", hpBefore, g.Player.Stats.HP) + } +} + +func TestDeathUnwindsWithGameEnd(t *testing.T) { + g := mkGame(t, 11) + defer func() { + r := recover() + if _, ok := r.(gameEnd); !ok { + t.Fatalf("death did not unwind with gameEnd, got %v", r) + } + if g.Playing { + t.Error("still playing after death") + } + }() + g.Options.Tombstone = false + g.death('K') +} + +func TestRunnersChaseHero(t *testing.T) { + g := mkGame(t, 3) + // Place a hobgoblin a few squares away in the hero's room and set it + // running at the hero. + p := &g.Player + pos := Coord{X: p.Pos.X + 3, Y: p.Pos.Y} + if !stepOk(g.Level.Char(pos.Y, pos.X)) || g.Level.MonsterAt(pos.Y, pos.X) != nil { + t.Skip("no clear lane on this seed") + } + tp := &Monster{} + g.newMonster(tp, 'H', pos) + tp.Flags.Set(IsRun) + tp.Dest = &p.Pos + d0 := distCp(tp.Pos, p.Pos) + g.runners(0) + if d1 := distCp(tp.Pos, p.Pos); d1 >= d0 { + t.Errorf("monster did not close distance: %d -> %d", d0, d1) + } +} + +func TestKilledLeprechaunDropsGoldViaFall(t *testing.T) { + g := mkGame(t, 21) + tp := spawnAdjacent(g, 'L') + tp.Stats.HP = 0 + objsBefore := len(g.Level.Objects) + g.killed(tp, false) + // At max depth the leprechaun's hoard falls to the floor. + if len(g.Level.Objects) <= objsBefore-1 { + t.Error("level object list shrank unexpectedly") + } +} diff --git a/game/game.go b/game/game.go index 1a6b9a1..60b0597 100644 --- a/game/game.go +++ b/game/game.go @@ -99,6 +99,7 @@ type RogueGame struct { maze mazeState // rooms.c maze statics pnum int // passages.c passnum statics newpnum bool + chRet Coord // chase.c static ch_ret: where chasing takes you // daemons/fuses Daemons DaemonList @@ -121,6 +122,10 @@ type RogueGame struct { // options and item identity Options Options Items ItemLore + // Monsters is the per-game copy of the bestiary: the C code mutates + // the venus flytrap's damage string during play, and the table is + // part of the save state. + Monsters [26]MonsterKind // scores LastScore int @@ -159,6 +164,7 @@ func NewGame(cfg Config) *RogueGame { } // TODO(port): parse cfg.RogueOpts once options.go lands. + g.Monsters = monsterTable g.Items.Group = 2 // weapons.c: int group = 2 for i := range g.Level.Passages { g.Level.Passages[i].Flags = IsGone | IsDark @@ -187,8 +193,8 @@ func (g *RogueGame) quit(int) { g.scr.Std.MvPrintw(NumLines-2, 0, "You quit with %d gold pieces", g.Player.Purse) g.move(NumLines-1, 0) g.refresh() - // TODO(port): score(purse, 1, 0) once rip.go lands. - g.gameOver() + g.score(g.Player.Purse, 1, 0) + g.myExit(0) return } g.move(0, 0) @@ -200,9 +206,3 @@ func (g *RogueGame) quit(int) { g.Count = 0 g.ToDeath = false } - -// gameOver ends the game loop; it replaces the C exit() calls so that Run -// can unwind normally and restore the terminal. -func (g *RogueGame) gameOver() { - g.Playing = false -} diff --git a/game/monsters.go b/game/monsters.go index 8204d11..7c1a915 100644 --- a/game/monsters.go +++ b/game/monsters.go @@ -50,7 +50,7 @@ func (g *RogueGame) newMonster(tp *Monster, typ byte, cp Coord) { tp.OldCh = g.inch() tp.Room = g.roomin(cp) g.Level.SetMonsterAt(cp.Y, cp.X, tp) - mp := &monsterTable[tp.Type-'A'] + mp := &g.Monsters[tp.Type-'A'] tp.Stats.Lvl = mp.Stats.Lvl + levAdd tp.Stats.MaxHP = g.roll(tp.Stats.Lvl, 8) tp.Stats.HP = tp.Stats.MaxHP @@ -165,7 +165,7 @@ func (g *RogueGame) wakeMonster(y, x int) *Monster { // givePack gives a pack to a monster if it deserves one (monsters.c // give_pack). func (g *RogueGame) givePack(tp *Monster) { - if g.Depth >= g.MaxDepth && g.rnd(100) < monsterTable[tp.Type-'A'].Carry { + if g.Depth >= g.MaxDepth && g.rnd(100) < g.Monsters[tp.Type-'A'].Carry { attachObj(&tp.Pack, g.newThing()) } } diff --git a/game/move.go b/game/move.go index 7180798..6535305 100644 --- a/game/move.go +++ b/game/move.go @@ -1,7 +1,180 @@ package game -// move.c — hero movement. doorOpen arrives first (room entry needs it); -// the movement commands and traps come with the UI phase. +// move.c — hero movement commands. + +// doRun starts the hero running (move.c do_run). +func (g *RogueGame) doRun(ch byte) { + g.Running = true + g.After = false + g.RunCh = ch +} + +// doMove checks that a move is legal and handles the consequences — +// fighting, picking up, etc. (move.c do_move). +func (g *RogueGame) doMove(dy, dx int) { + p := &g.Player + g.Firstmove = false + if g.NoMove > 0 { + g.NoMove-- + g.msg("you are still stuck in the bear trap") + return + } + // Do a confused move (maybe) + var nh Coord + if p.On(IsHuh) && g.rnd(5) != 0 { + nh = g.rndmove(&p.Creature) + if nh == p.Pos { + g.After = false + g.Running = false + g.ToDeath = false + return + } + } else { + nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx} + } + +over: + // Check if he tried to move off the screen or make an illegal diagonal + // move, and stop him if he did. + hitBound := nh.X < 0 || nh.X >= NumCols || nh.Y <= 0 || nh.Y >= NumLines-1 + var ch byte + var fl PlaceFlags + 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(IsLevit) { + ch = Trap + g.Level.SetChar(nh.Y, nh.X, Trap) + g.Level.FlagsAt(nh.Y, nh.X).Set(FReal) + } + } else if p.On(IsHeld) && ch != 'F' { + g.msg("you are being held") + return + } + } + if hitBound { + ch = ' ' // fall into the wall case below + } + switch ch { + case ' ', '|', '-': + if g.Options.PassGo && g.Running && p.Room.Flags.Has(IsGone) && + !p.On(IsBlind) { + 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 + } + dx = 0 + g.turnref() + nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx} + goto over + } + case 'j', 'k': + b1 = p.Pos.X != 0 && g.turnOk(p.Pos.Y, p.Pos.X-1) + b2 = p.Pos.X != NumCols-1 && g.turnOk(p.Pos.Y, p.Pos.X+1) + if b1 != b2 { + if b1 { + g.RunCh = 'h' + dx = -1 + } else { + g.RunCh = 'l' + dx = 1 + } + dy = 0 + g.turnref() + nh = Coord{Y: p.Pos.Y + dy, X: p.Pos.X + dx} + goto over + } + } + } + g.Running = false + g.After = false + case Door: + g.Running = false + if g.Level.FlagsAt(p.Pos.Y, p.Pos.X).Has(FPass) { + g.enterRoom(nh) + } + g.moveStuff(nh, fl) + case Trap: + tr := g.beTrapped(nh) + if tr == TDoor || tr == TTelep { + return + } + g.moveStuff(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.moveStuff(nh, fl) + case Floor: + if !fl.Has(FReal) { + g.beTrapped(p.Pos) + } + g.moveStuff(nh, fl) + default: + if ch == Stairs { + g.SeenStairs = true + } + g.Running = false + if isUpper(ch) || g.Level.MonsterAt(nh.Y, nh.X) != nil { + g.fight(nh, p.CurWeapon, false) + } else { + if ch != Stairs { + g.Take = ch + } + g.moveStuff(nh, fl) + } + } +} + +// moveStuff is the move_stuff label in do_move: complete the step. +func (g *RogueGame) moveStuff(nh Coord, fl PlaceFlags) { + p := &g.Player + g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt()) + if fl.Has(FPass) && g.Level.Char(g.Oldpos.Y, g.Oldpos.X) == Door { + g.leaveRoom(nh) + } + p.Pos = nh +} + +// turnOk decides whether it is legal to turn onto the given space +// (move.c turn_ok). +func (g *RogueGame) turnOk(y, x int) bool { + pp := g.Level.At(y, x) + return pp.Ch == Door || pp.Flags&(FReal|FPass) == (FReal|FPass) +} + +// turnref decides whether to refresh at a passage turning (move.c turnref). +func (g *RogueGame) turnref() { + p := &g.Player + pp := g.Level.At(p.Pos.Y, p.Pos.X) + if !pp.Flags.Has(FSeen) { + if g.Options.Jump { + g.refresh() + } + pp.Flags.Set(FSeen) + } +} // doorOpen is called to wake up things in a room that might move when the // hero enters (move.c door_open). @@ -17,3 +190,152 @@ func (g *RogueGame) doorOpen(rp *Room) { } } } + +// beTrapped makes him pay for stepping on a trap (move.c be_trapped). +func (g *RogueGame) beTrapped(tc Coord) int { + p := &g.Player + if p.On(IsLevit) { + return TRust // anything that's not a door or teleport + } + g.Running = false + g.Count = 0 + pp := g.Level.At(tc.Y, tc.X) + pp.Ch = Trap + tr := int(pp.Flags & FTMask) + pp.Flags.Set(FSeen) + switch tr { + case TDoor: + g.Depth++ + g.NewLevel() + g.msg("you fell into a trap!") + case TBear: + g.NoMove += g.spread(3) // BEARTIME + g.msg("you are caught in a bear trap") + case TMyst: + 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", rainbow[g.rnd(len(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", rainbow[g.rnd(len(rainbow))]) + case 5: + g.msg("a spike shoots past your ear!") + case 6: + g.msg("%s sparks dance across your armor", rainbow[g.rnd(len(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!", rainbow[g.rnd(len(rainbow))]) + } + case TSleep: + g.NoCommand += g.spread(5) // SLEEPTIME + p.Flags.Clear(IsRun) + g.msg("a strange white mist envelops you and you fall asleep") + case TArrow: + if g.swing(p.Stats.Lvl-1, p.Stats.Arm, 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, Arrow) + arrow.Count = 1 + arrow.Pos = p.Pos + g.fall(arrow, false) + g.msg("an arrow shoots past you") + } + case TTelep: + // 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 TDart: + if !g.swing(p.Stats.Lvl+1, p.Stats.Arm, 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(RSustStr) && !g.save(VsPoison) { + g.chgStr(-1) + } + g.msg("a small dart just hit you in the shoulder") + } + case TRust: + g.msg("a gush of water hits you on the head") + g.rustArmor(p.CurArmor) + } + g.flushType() + return tr +} + +// rndmove moves in a random direction if the monster/person is confused +// (move.c rndmove). +func (g *RogueGame) rndmove(who *Creature) Coord { + ret := Coord{ + Y: who.Pos.Y + g.rnd(3) - 1, + X: who.Pos.X + g.rnd(3) - 1, + } + // Now check to see if that's a legal move. If not, don't move. + // (I.e., bump into the wall or whatever) + if ret == who.Pos { + return ret + } + if !g.diagOk(who.Pos, ret) { + return who.Pos + } + ch := g.Level.VisibleChar(ret.Y, ret.X) + if !stepOk(ch) { + return who.Pos + } + if ch == Scroll { + var found *Object + for _, obj := range g.Level.Objects { + if ret.Y == obj.Pos.Y && ret.X == obj.Pos.X { + found = obj + break + } + } + if found != nil && found.Which == SScare { + return who.Pos + } + } + return ret +} + +// rustArmor rusts the given armor, if it is a legal kind to rust, and we +// aren't wearing a magic ring (move.c rust_armor). +func (g *RogueGame) rustArmor(arm *Object) { + if arm == nil || arm.Type != Armor || arm.Which == Leather || + arm.Arm >= 9 { + return + } + + if arm.Flags.Has(IsProt) || g.Player.IsWearing(RSustArm) { + if !g.ToDeath { + g.msg("the rust vanishes instantly") + } + } else { + arm.Arm++ + if !g.Options.Terse { + g.msg("your armor appears to be weaker now. Oh my!") + } else { + g.msg("your armor weakens") + } + } +} diff --git a/game/rip.go b/game/rip.go new file mode 100644 index 0000000..8498ac7 --- /dev/null +++ b/game/rip.go @@ -0,0 +1,229 @@ +package game + +import ( + "fmt" + "time" +) + +// rip.c — the fun ends: death or a total win. +// +// The C functions here call exit(); the port panics with a gameEnd sentinel +// that Run recovers, so the terminal is restored by normal unwinding. + +// gameEnd is the sentinel carried by the panic that replaces my_exit(). +type gameEnd struct{ status int } + +// myExit leaves the process properly (main.c my_exit): it unwinds to Run. +func (g *RogueGame) myExit(st int) { + g.Playing = false + panic(gameEnd{status: st}) +} + +var ripArt = []string{ + " __________", + " / \\", + " / REST \\", + " / IN \\", + " / PEACE \\", + " / \\", + " | |", + " | |", + " | killed by a |", + " | |", + " | 1980 |", + " *| * * * | *", + " ________)/\\\\_//(\\/(/\\)/\\//\\/|_)_______", +} + +// death does something really fun when he dies (rip.c death). +func (g *RogueGame) death(monst byte) { + p := &g.Player + p.Purse -= p.Purse / 10 + g.clear() + killer := g.killname(monst, false) + if !g.Options.Tombstone { + g.scr.Std.MvPrintw(NumLines-2, 0, "Killed by ") + if monst != 's' && monst != 'h' { + g.printw("a%s ", vowelstr(killer)) + } + g.printw("%s with %d gold", killer, p.Purse) + } else { + year := time.Now().Year() + for i, line := range ripArt { + g.scr.Std.MvAddStr(8+i, 0, line) + } + g.mvaddstr(17, center(killer), killer) + if monst == 's' || monst == 'h' { + g.mvaddch(16, 32, ' ') + } else { + g.mvaddstr(16, 33, vowelstr(killer)) + } + g.mvaddstr(14, center(g.Whoami), g.Whoami) + au := fmt.Sprintf("%d Au", p.Purse) + g.mvaddstr(15, center(au), au) + g.mvaddstr(18, 26, fmt.Sprintf("%4d", year)) + } + g.mvaddstr(NumLines-1, 0, "[Press return to continue]") + g.refresh() + flags := 0 + if g.HasAmulet { + flags = 3 + } + g.score(p.Purse, flags, monst) + g.waitFor('\n') + g.myExit(0) +} + +// center returns the column to center the given string on the tombstone +// (rip.c center). +func center(str string) int { + return 28 - (len(str)+1)/2 +} + +// totalWinner is the code for a winner (rip.c total_winner). +func (g *RogueGame) totalWinner() { + p := &g.Player + g.clear() + g.standout() + banner := []string{ + " ", + " @ @ @ @ @ @@@ @ @ ", + " @ @ @@ @@ @ @ @ @ ", + " @ @ @@@ @ @ @ @ @ @@@ @@@@ @@@ @ @@@ @ ", + " @@@@ @ @ @ @ @ @ @ @ @ @ @ @ @ @ ", + " @ @ @ @ @ @ @ @@@@ @ @ @@@@@ @ @ @ ", + " @ @ @ @ @ @@ @ @ @ @ @ @ @ @ @ @ ", + " @@@ @@@ @@ @ @ @ @@@@ @@@@ @@@ @@@ @@ @ ", + " ", + " Congratulations, you have made it to the light of day! ", + } + for i, line := range banner { + g.scr.Std.MvAddStr(i, 0, line) + } + g.standend() + g.scr.Std.MvAddStr(10, 0, "You have joined the elite ranks of those who have escaped the") + g.scr.Std.MvAddStr(11, 0, "Dungeons of Doom alive. You journey home and sell all your loot at") + g.scr.Std.MvAddStr(12, 0, "a great profit and are admitted to the Fighters' Guild.") + g.mvaddstr(NumLines-1, 0, "--Press space to continue--") + g.refresh() + g.waitFor(' ') + g.clear() + g.mvaddstr(0, 0, " Worth Item") + g.move(1, 0) + oldpurse := p.Purse + line := 1 + for _, obj := range p.Pack { + worth := 0 + it := &g.Items + switch obj.Type { + case Food: + worth = 2 * obj.Count + case Weapon: + worth = it.Weapons[obj.Which].Worth + worth *= 3*(obj.HPlus+obj.DPlus) + obj.Count + obj.Flags.Set(IsKnow) + case Armor: + worth = it.Armors[obj.Which].Worth + worth += (9 - obj.Arm) * 100 + worth += 10 * (aClass[obj.Which] - obj.Arm) + obj.Flags.Set(IsKnow) + case Scroll: + op := &it.Scrolls[obj.Which] + worth = op.Worth * obj.Count + if !op.Know { + worth /= 2 + } + op.Know = true + case Potion: + op := &it.Potions[obj.Which] + worth = op.Worth * obj.Count + if !op.Know { + worth /= 2 + } + op.Know = true + case Ring: + op := &it.Rings[obj.Which] + worth = op.Worth + if obj.Which == RAddStr || obj.Which == RAddDam || + obj.Which == RProtect || obj.Which == RAddHit { + if obj.Arm > 0 { + worth += obj.Arm * 100 + } else { + worth = 10 + } + } + if !obj.Flags.Has(IsKnow) { + worth /= 2 + } + obj.Flags.Set(IsKnow) + op.Know = true + case Stick: + op := &it.Sticks[obj.Which] + worth = op.Worth + worth += 20 * obj.Charges() + if !obj.Flags.Has(IsKnow) { + worth /= 2 + } + obj.Flags.Set(IsKnow) + op.Know = true + case Amulet: + worth = 1000 + } + if worth < 0 { + worth = 0 + } + g.scr.Std.MvPrintw(line, 0, "%c) %5d %s", obj.PackCh, worth, + g.invName(obj, false)) + line++ + p.Purse += worth + } + g.scr.Std.MvPrintw(line, 0, " %5d Gold Pieces ", oldpurse) + g.refresh() + g.score(p.Purse, 2, ' ') + g.myExit(0) +} + +// killnameTable is the rip.c nlist[]: special death causes. +var killnameTable = []helpEntry{ + {'a', "arrow", true}, + {'b', "bolt", true}, + {'d', "dart", true}, + {'h', "hypothermia", false}, + {'s', "starvation", false}, +} + +// killname converts a code to a monster name (rip.c killname). +func (g *RogueGame) killname(monst byte, doart bool) string { + var sp string + var article bool + if isUpper(monst) { + sp = g.Monsters[monst-'A'].Name + article = true + } else { + sp = "Wally the Wonder Badger" + article = false + for _, hp := range killnameTable { + if hp.Ch == monst { + sp = hp.Desc + article = hp.Print + break + } + } + } + if doart && article { + return "a" + vowelstr(sp) + " " + sp + } + return sp +} + +// deathMonst returns a monster appropriate for a random death (rip.c +// death_monst). +func (g *RogueGame) deathMonst() byte { + poss := []byte{ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', + 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'h', 'd', 's', + ' ', // generates the "Wally the Wonder Badger" message + } + return poss[g.rnd(len(poss))] +} diff --git a/game/score.go b/game/score.go new file mode 100644 index 0000000..6a5f4db --- /dev/null +++ b/game/score.go @@ -0,0 +1,196 @@ +package game + +import ( + "encoding/gob" + "fmt" + "os" + "time" +) + +// score.h / rip.c score() / save.c rd_score+wr_score — the scoreboard. +// The C scorefile was XOR-encrypted structs; the port stores a gob list at +// ScorePath (empty path disables persistence but still shows the list). + +// numScores is the C numscores default: the top ten. +const numScores = 10 + +// ScoreEnt is score.h struct sc_ent. +type ScoreEnt struct { + UID int + Score int + Flags int // 0=killed, 1=quit, 2=winner, 3=killed with amulet + Monster byte + Name string + Level int + Time int64 +} + +var scoreReasons = [4]string{ + "killed", + "quit", + "A total winner", + "killed with Amulet", +} + +// rdScore reads the scoreboard file (save.c rd_score). +func (g *RogueGame) rdScore() []ScoreEnt { + topTen := make([]ScoreEnt, numScores) + if g.ScorePath == "" { + return topTen + } + f, err := os.Open(g.ScorePath) + if err != nil { + return topTen + } + defer f.Close() + var onDisk []ScoreEnt + if err := gob.NewDecoder(f).Decode(&onDisk); err != nil { + return topTen + } + copy(topTen, onDisk) + return topTen +} + +// wrScore writes the scoreboard file (save.c wr_score). +func (g *RogueGame) wrScore(topTen []ScoreEnt) { + if g.ScorePath == "" { + return + } + // lock_sc/unlock_sc: exclusive-create lock file with stale takeover. + lock := g.ScorePath + ".lck" + for range 5 { + lf, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + lf.Close() + defer os.Remove(lock) + break + } + if fi, serr := os.Stat(lock); serr == nil && + time.Since(fi.ModTime()) > 10*time.Second { + os.Remove(lock) + continue + } + time.Sleep(time.Second) + } + f, err := os.OpenFile(g.ScorePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + gob.NewEncoder(f).Encode(topTen) +} + +// score figures the score and posts it (rip.c score). flags -1 means just +// display the list (the -s command line option). +func (g *RogueGame) score(amount, flags int, monst byte) { + if flags >= 0 && g.scr != nil && g.scr.term != nil { + g.mvaddstr(NumLines-1, 0, "[Press return to continue]") + g.refresh() + g.waitFor('\n') + } + + topTen := g.rdScore() + // Insert her in list if need be + ins := -1 + if !g.NoScore && flags >= 0 { + uid := os.Getuid() + scp := len(topTen) + for i := range topTen { + if amount > topTen[i].Score { + scp = i + break + } else if !g.AllScore && flags != 2 && + topTen[i].UID == uid && topTen[i].Flags != 2 { + // only one score per nowin uid + scp = len(topTen) + break + } + } + if scp < len(topTen) { + sc2 := len(topTen) - 1 + if flags != 2 && !g.AllScore { + for i := scp; i < len(topTen); i++ { + if topTen[i].UID == uid && topTen[i].Flags != 2 { + sc2 = i + break + } + } + } + for sc2 > scp { + topTen[sc2] = topTen[sc2-1] + sc2-- + } + lvl := g.Depth + if flags == 2 { + lvl = g.MaxDepth + } + topTen[scp] = ScoreEnt{ + UID: uid, + Score: amount, + Flags: flags, + Monster: monst, + Name: g.Whoami, + Level: lvl, + Time: time.Now().Unix(), + } + ins = scp + } + } + + // Build the list display + label := "Rogueists" + if g.AllScore { + label = "Scores" + } + lines := []string{ + fmt.Sprintf("Top Ten %s:", label), + " Score Name", + } + highlight := -1 + for i := range topTen { + scp := &topTen[i] + if scp.Score == 0 { + break + } + line := fmt.Sprintf("%2d %5d %s: %s on level %d", i+1, + scp.Score, scp.Name, scoreReasons[scp.Flags], scp.Level) + if scp.Flags == 0 || scp.Flags == 3 { + line += fmt.Sprintf(" by %s", g.killname(scp.Monster, true)) + } + line += "." + if i == ins { + highlight = len(lines) + } + lines = append(lines, line) + } + + if g.scr != nil && g.scr.term != nil { + g.clear() + for i, line := range lines { + if i == highlight { + g.standout() + } + g.mvaddstr(i, 0, line) + if i == highlight { + g.standend() + } + } + g.refresh() + } else { + for _, line := range lines { + fmt.Println(line) + } + } + + // Update the list file + if ins >= 0 { + g.wrScore(topTen) + } +} + +// ShowScores implements the -s command line option: print the scoreboard +// and nothing else. +func (g *RogueGame) ShowScores() { + g.NoScore = true + g.score(0, -1, 0) +} diff --git a/game/sticks.go b/game/sticks.go index 1c5de92..ccd9da8 100644 --- a/game/sticks.go +++ b/game/sticks.go @@ -2,8 +2,322 @@ package game import "fmt" -// sticks.c — wand and staff setup and naming. Zapping (do_zap, drain, -// fire_bolt) arrives with the combat phase. +// sticks.c — zap wands and staffs. + +// doZap performs a zap with a wand (sticks.c do_zap). +func (g *RogueGame) doZap() { + p := &g.Player + obj := g.getItem("zap with", int(Stick)) + if obj == nil { + return + } + if obj.Type != Stick { + g.After = false + g.msg("you can't zap with that!") + return + } + if obj.Charges() == 0 { + g.msg("nothing happens") + return + } + switch obj.Which { + case WsLight: + // Reddy Kilowatt wand. Light up the room + g.Items.Sticks[WsLight].Know = true + if p.Room.Flags.Has(IsGone) { + g.msg("the corridor glows and then fades") + } else { + p.Room.Flags.Clear(IsDark) + // Light the room and put the player back up + g.enterRoom(p.Pos) + g.addmsg("the room is lit") + if !g.Options.Terse { + g.addmsg(" by a shimmering %s light", g.pickColor("blue")) + } + g.endmsg() + } + case WsDrain: + // 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 WsInvis, WsPolymorph, WsTelAway, WsTelTo, WsCancel: + 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(IsHeld) + } + switch obj.Which { + case WsInvis: + tp.Flags.Set(IsInvis) + if g.cansee(y, x) { + g.mvaddch(y, x, tp.OldCh) + } + case WsPolymorph: + pp := tp.Pack + detachMon(&g.Level.Monsters, tp) + if g.seeMonst(tp) { + g.mvaddch(y, x, g.Level.Char(y, x)) + } + oldch := tp.OldCh + g.Delta.Y = y + g.Delta.X = x + monster = byte(g.rnd(26) + 'A') + 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[WsPolymorph].Know = true + } + case WsCancel: + tp.Flags.Set(IsCanc) + tp.Flags.Clear(IsInvis | CanHuh) + tp.Disguise = tp.Type + if g.seeMonst(tp) { + g.mvaddch(y, x, tp.Disguise) + } + case WsTelAway, WsTelTo: + var newPos Coord + if obj.Which == WsTelAway { + for { + newPos, _ = g.findFloor(nil, 0, 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(IsRun) + g.relocate(tp, newPos) + } + } + case WsMissile: + g.Items.Sticks[WsMissile].Know = true + bolt := newObject() + bolt.Type = '*' + bolt.HurlDmg = "1x4" + bolt.HPlus = 100 + bolt.DPlus = 1 + bolt.Flags = IsMissl + if p.CurWeapon != nil { + bolt.Launch = 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") + } else { + g.msg("the missle vanishes with a puff of smoke") + } + case WsHasteM, WsSlowM: + 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.Which == WsHasteM { + if tp.On(IsSlow) { + tp.Flags.Clear(IsSlow) + } else { + tp.Flags.Set(IsHaste) + } + } else { + if tp.On(IsHaste) { + tp.Flags.Clear(IsHaste) + } else { + tp.Flags.Set(IsSlow) + } + tp.Turn = true + } + g.Delta.Y = y + g.Delta.X = x + g.runto(g.Delta) + } + case WsElect, WsFire, WsCold: + var name string + switch obj.Which { + case WsElect: + name = "bolt" + case WsFire: + name = "flame" + default: + name = "ice" + } + g.fireBolt(p.Pos, &g.Delta, name) + g.Items.Sticks[obj.Which].Know = true + case WsNop: + } + obj.SetCharges(obj.Charges() - 1) +} + +// 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)&FPNum] + } + inpass := p.Room.Flags.Has(IsGone) + 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)&FPNum] == p.Room) { + 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) + } + } +} + +// 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.Type = Weapon + bolt.Which = Flame + bolt.HurlDmg = "6x6" + bolt.HPlus = 100 + bolt.DPlus = 0 + g.Items.Weapons[Flame].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 = '\\' + } + 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) + 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 !changed { + hitHero = !hitHero + } + changed = false + dir.Y = -dir.Y + dir.X = -dir.X + spotpos = spotpos[:len(spotpos)-1] + g.msg("the %s bounces", name) + continue + } + if tp := g.Level.MonsterAt(pos.Y, pos.X); !hitHero && tp != nil { + hitHero = true + changed = !changed + tp.OldCh = g.Level.Char(pos.Y, pos.X) + if !g.saveThrow(VsMagic, &tp.Stats) { + bolt.Pos = pos + used = true + if tp.Type == 'D' && name == "flame" { + g.addmsg("the flame bounces") + if !g.Options.Terse { + g.addmsg(" 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)) + } + } + } 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) + } + } + 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)) + } +} // fixStick sets up a new wand or staff (sticks.c fix_stick). func (g *RogueGame) fixStick(cur *Object) { diff --git a/game/term_test.go b/game/term_test.go new file mode 100644 index 0000000..068e5ef --- /dev/null +++ b/game/term_test.go @@ -0,0 +1,25 @@ +package game + +// testTerm is a headless Terminal for tests: rendering is a no-op and +// input plays a script, then alternates space/newline so that --More-- +// and [Press return] prompts never block. +type testTerm struct { + input []byte + pos int + tick int +} + +func (t *testTerm) Render(*Window) {} + +func (t *testTerm) ReadChar() byte { + if t.pos < len(t.input) { + c := t.input[t.pos] + t.pos++ + return c + } + t.tick++ + if t.tick%2 == 0 { + return '\n' + } + return ' ' +} diff --git a/game/weapons.go b/game/weapons.go index 723d58c..469c902 100644 --- a/game/weapons.go +++ b/game/weapons.go @@ -2,11 +2,125 @@ package game import "fmt" -// weapons.c — weapon initialization and naming. The throwing half -// (missile, do_motion, hit_monster) arrives with the combat phase. +// weapons.c — functions for dealing with problems brought about by weapons. const noWeapon = -1 +// missile fires a missile in a given direction (weapons.c missile). +func (g *RogueGame) missile(ydelta, xdelta int) { + // Get which thing we are hurling + obj := g.getItem("throw", int(Weapon)) + if obj == nil { + return + } + if !g.dropCheck(obj) || g.isCurrent(obj) { + return + } + obj = g.leavePack(obj, true, false) + g.doMotion(obj, ydelta, xdelta) + // AHA! Here it has hit something. If it is a wall or a door, or if + // it misses (combat) the monster, put it on the floor + if g.Level.MonsterAt(obj.Pos.Y, obj.Pos.X) == nil || + !g.hitMonster(obj.Pos, obj) { + g.fall(obj, true) + } +} + +// doMotion does the actual motion on the screen done by an object +// traveling across the room (weapons.c do_motion). +func (g *RogueGame) doMotion(obj *Object, ydelta, xdelta int) { + p := &g.Player + // 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) + } + // 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.Type) + g.refresh() + } + continue + } + break + } +} + +// 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 { + pp := g.Level.At(fpos.Y, fpos.X) + pp.Ch = obj.Type + obj.Pos = fpos + if g.cansee(fpos.Y, fpos.X) { + if pp.Monst != nil { + pp.Monst.OldCh = obj.Type + } else { + g.mvaddch(fpos.Y, fpos.X, obj.Type) + } + } + attachObj(&g.Level.Objects, obj) + return + } + if pr { + if g.HasHit { + g.endmsg() + g.HasHit = false + } + g.msg("the %s vanishes as it hits the ground", + g.Items.Weapons[obj.Which].Name) + } +} + +// hitMonster checks if the missile hits the monster (weapons.c +// hit_monster). +func (g *RogueGame) hitMonster(mp Coord, obj *Object) bool { + return g.fight(mp, obj, true) +} + +// wield pulls out a certain weapon (weapons.c wield). +func (g *RogueGame) wield() { + p := &g.Player + oweapon := p.CurWeapon + if !g.dropCheck(p.CurWeapon) { + p.CurWeapon = oweapon + return + } + p.CurWeapon = oweapon + obj := g.getItem("wield", int(Weapon)) + if obj == nil { + g.After = false + return + } + if obj.Type == Armor { + g.msg("you can't wield armor") + g.After = false + return + } + if g.isCurrent(obj) { + g.After = false + return + } + + sp := g.invName(obj, true) + p.CurWeapon = obj + if !g.Options.Terse { + g.addmsg("you are now ") + } + g.msg("wielding %s (%c)", sp, obj.PackCh) +} + // initWeaps is the weapons.c init_dam[] table. var initWeaps = [MaxWeapons]struct { dam string // damage when wielded diff --git a/game/wizard.go b/game/wizard.go new file mode 100644 index 0000000..4ee19aa --- /dev/null +++ b/game/wizard.go @@ -0,0 +1,111 @@ +package game + +// wizard.c — special wizard commands, some of which are also non-wizard +// commands under strange circumstances. The MASTER-only debug commands +// (create_obj, show_map) arrive with the UI phase. + +// whatis identifies what a certain object is (wizard.c whatis). +func (g *RogueGame) whatis(insist bool, typ int) { + p := &g.Player + if len(p.Pack) == 0 { + g.msg("you don't have anything in your pack to identify") + return + } + + var obj *Object + for { + obj = g.getItem("identify", typ) + if !insist { + break + } + if g.NObjs == 0 { + return + } + if obj == nil { + g.msg("you must identify something") + } else if typ != 0 && int(obj.Type) != typ && + !(typ == RorS && (obj.Type == Ring || obj.Type == Stick)) { + g.msg("you must identify a %s", typeName(typ)) + } else { + break + } + } + + if obj == nil { + return + } + + switch obj.Type { + case Scroll: + setKnow(obj, g.Items.Scrolls[:]) + case Potion: + setKnow(obj, g.Items.Potions[:]) + case Stick: + setKnow(obj, g.Items.Sticks[:]) + case Weapon, Armor: + obj.Flags.Set(IsKnow) + case Ring: + setKnow(obj, g.Items.Rings[:]) + } + g.msg("%s", g.invName(obj, false)) +} + +// setKnow sets things up when we really know what a thing is (wizard.c +// set_know). +func setKnow(obj *Object, info []ObjInfo) { + info[obj.Which].Know = true + obj.Flags.Set(IsKnow) + info[obj.Which].Guess = "" +} + +// typeNameTable is the wizard.c type_name static tlist. +var typeNameTable = []struct { + ch int + desc string +}{ + {int(Potion), "potion"}, + {int(Scroll), "scroll"}, + {int(Food), "food"}, + {RorS, "ring, wand or staff"}, + {int(Ring), "ring"}, + {int(Stick), "wand or staff"}, + {int(Weapon), "weapon"}, + {int(Armor), "suit of armor"}, +} + +// typeName returns the name of an object type (wizard.c type_name). +func typeName(typ int) string { + for _, hp := range typeNameTable { + if typ == hp.ch { + return hp.desc + } + } + return "" +} + +// teleport bamfs the hero someplace else (wizard.c teleport). +func (g *RogueGame) teleport() { + p := &g.Player + g.mvaddch(p.Pos.Y, p.Pos.X, g.floorAt()) + c, _ := g.findFloor(nil, 0, true) + if g.roomin(c) != p.Room { + g.leaveRoom(p.Pos) + p.Pos = c + g.enterRoom(p.Pos) + } else { + p.Pos = c + g.look(true) + } + g.mvaddch(p.Pos.Y, p.Pos.X, PlayerCh) + // turn off ISHELD in case teleportation was done while fighting a + // Flytrap + if p.On(IsHeld) { + p.Flags.Clear(IsHeld) + p.VfHit = 0 + g.Monsters['F'-'A'].Stats.Dmg = "000x0" + } + g.NoMove = 0 + g.Count = 0 + g.Running = false + g.flushType() +}