diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/game/act_state.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 31 | ||||
| -rw-r--r-- | internal/game/cmd_walk.go | 3 | ||||
| -rw-r--r-- | internal/game/combat_attack.go | 5 | ||||
| -rw-r--r-- | internal/game/combat_mob.go | 38 | ||||
| -rw-r--r-- | internal/game/sys_escape_test.go | 303 | ||||
| -rw-r--r-- | internal/game/tick.go | 7 | ||||
| -rw-r--r-- | internal/player/player.go | 13 |
8 files changed, 382 insertions, 21 deletions
diff --git a/internal/game/act_state.go b/internal/game/act_state.go index 335ecfb..9ee4d27 100644 --- a/internal/game/act_state.go +++ b/internal/game/act_state.go @@ -19,6 +19,9 @@ func (g *Game) playerActionDisplay(p *player.Player) string { if p.MoveTicks > 0 { return "heading " + p.MoveDirection } + if p.EscapeDir != "" { + return "preparing to flee" + } if len(p.WalkSequence) > 0 { return "walking somewhere with a purpose!" } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 594ecd7..576bdd5 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -88,17 +88,30 @@ func (g *Game) doMove(sess *net.Session, dir string, isWalk bool) { return } - // moveTicks already set p.MoveUsesEnergy based on this move; preserve it - // across the wait by leaving it set (ClearMoveState resets it on arrival). - p.MoveTicks = ticks - - if inCombat && p.OptionBool("run_countdown") { - if p.MoveTicks > 1 { - sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks)) - } else { - sess.WriteLine("Running next tick!") + if inCombat { + firstEscape := p.EscapeDir == "" + p.EscapeDir = string(exitDir) + p.EscapeTarget = targetID + p.EscapeTrigger = p.MovePendingTrigger + p.EscapeIsWalk = isWalk + p.MovePendingTrigger = nil + p.MoveDirection = "" + p.MoveTarget = 0 + if firstEscape { + mobName := "" + if cs := g.Combat.Get(p.Name); cs != nil { + if mob := g.MobStore.GetInstance(cs.MobID); mob != nil { + mobName = mobDisplayName(mob, false) + } + } + if mobName != "" { + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You get ready to flee as soon as %s misses you...", mobName))) + } } + return } + + p.MoveTicks = ticks } // matchExitTrigger returns the first on_traverse Trigger on exitDef whose diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index 66e7548..80600af 100644 --- a/internal/game/cmd_walk.go +++ b/internal/game/cmd_walk.go @@ -115,6 +115,9 @@ func (g *Game) advanceWalk(sess *net.Session, p *player.Player) { if p.MoveTicks > 0 { return } + if p.EscapeDir != "" { + return + } if len(p.WalkSequence) == 0 { return } diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go index 11be7f7..dd92a6d 100644 --- a/internal/game/combat_attack.go +++ b/internal/game/combat_attack.go @@ -19,6 +19,11 @@ import ( func (g *Game) doAttack(sess *net.Session, input string) { p := sess.Player + if p.EscapeDir != "" { + p.ClearEscape() + sess.WriteLine("You decide to attack instead of fleeing!") + } + if g.Combat.Get(p.Name) != nil { sess.WriteLine("You are already in combat!") return diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index 7768432..8b7d869 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -87,7 +87,7 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst if combat.HitCheck(attRoll, defRoll) { g.applyMobHit(sess, p, mob, attackType, maxHit) } else { - g.applyMobMiss(sess, mob) + g.applyMobMiss(sess, p, mob) } } @@ -168,8 +168,9 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide.")) } - if p.MoveTicks > 0 { - p.ClearMoveState() + if p.EscapeDir != "" { + p.ClearEscape() + p.WalkSequence = nil sess.WriteLine("Can't escape!") } @@ -195,9 +196,13 @@ func padCombatPrefix(prefix, plainSelf, plainOther string) string { return prefix } -func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { +func (g *Game) applyMobMiss(sess *net.Session, p *player.Player, mob *world.MobInstance) { attacker := mobDisplayNameCap(mob, true) sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker))) + + if p.EscapeDir != "" { + g.beginEscapeMove(sess, p) + } } func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { @@ -223,6 +228,12 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst // the player currently has that item equipped in a weapon-hand slot. g.runTrigger(sess, p, mob.OnKill, p.RoomID, p.IsWielding, false) g.scheduleMobRespawn(mob) + + if p.EscapeDir != "" { + g.beginEscapeMove(sess, p) + return + } + g.writePrompt(sess) } @@ -402,3 +413,22 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan func (g *Game) stopCombat(playerName string) { g.Combat.Leave(playerName) } + +// beginEscapeMove starts the actual movement after the escape gate (a mob miss +// or mob death) has fired. It copies the stashed Escape* fields into the Move* +// fields, recomputes moveTicks, and either sets MoveTicks for the normal +// 1-2 tick run-delay or calls completeMove instantly for 0-tick gear. +func (g *Game) beginEscapeMove(sess *net.Session, p *player.Player) { + p.MoveDirection = p.EscapeDir + p.MoveTarget = p.EscapeTarget + p.MovePendingTrigger = p.EscapeTrigger + isWalk := p.EscapeIsWalk + p.ClearEscape() + + ticks := g.moveTicks(p, isWalk) + if ticks <= 0 { + g.completeMove(sess, p) + return + } + p.MoveTicks = ticks +} diff --git a/internal/game/sys_escape_test.go b/internal/game/sys_escape_test.go new file mode 100644 index 0000000..f07701c --- /dev/null +++ b/internal/game/sys_escape_test.go @@ -0,0 +1,303 @@ +package game + +import ( + "os" + "testing" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/game/hacking" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +type escapeTestConn struct { + data []byte +} + +func (c *escapeTestConn) ReadMessage() (string, error) { return "", nil } +func (c *escapeTestConn) Write(b []byte) (int, error) { c.data = append(c.data, b...); return len(b), nil } +func (c *escapeTestConn) Close() error { return nil } +func (c *escapeTestConn) SetEcho(bool) error { return nil } + +func escapeTestGame() *Game { + tmpDir, err := os.MkdirTemp("", "escape_test") + if err != nil { + panic(err) + } + dataDir := "../../data" + return &Game{ + Combat: combat.NewTracker(), + safespot: NewSafespotManager(), + hackingStates: make(map[string]*hacking.Session), + Hub: net.NewHub(), + Deps: Deps{ + World: world.New(dataDir), + MobStore: world.NewMobStore(dataDir), + ItemStore: item.NewItemStore(dataDir), + AccountStore: player.NewAccountStore(tmpDir), + Ticks: engine.New(), + }, + } +} + +func escapeTestSession(p *player.Player) *net.Session { + return &net.Session{Player: p, Conn: &escapeTestConn{}} +} + +func TestClearEscape(t *testing.T) { + p := player.New("esc") + p.EscapeDir = "north" + p.EscapeTarget = 42 + p.EscapeIsWalk = true + + p.ClearEscape() + if p.EscapeDir != "" { + t.Error("ClearEscape should clear EscapeDir") + } + if p.EscapeTarget != 0 { + t.Error("ClearEscape should clear EscapeTarget") + } + if p.EscapeIsWalk { + t.Error("ClearEscape should clear EscapeIsWalk") + } +} + +func TestClearMoveStateClearsEscape(t *testing.T) { + p := player.New("esc2") + p.EscapeDir = "south" + p.EscapeTarget = 7 + + p.ClearMoveState() + if p.EscapeDir != "" { + t.Error("ClearMoveState should clear Escape fields") + } +} + +func TestBeginEscapeMoveWithEnergy(t *testing.T) { + g := escapeTestGame() + p := player.New("run") + p.EscapeDir = "north" + p.EscapeTarget = 10 + p.EscapeIsWalk = false + + sess := escapeTestSession(p) + g.beginEscapeMove(sess, p) + + if p.EscapeDir != "" { + t.Error("beginEscapeMove should clear EscapeDir") + } + if p.MoveDirection != "north" { + t.Errorf("beginEscapeMove should set MoveDirection, got %q", p.MoveDirection) + } + if p.MoveTarget != 10 { + t.Errorf("beginEscapeMove should set MoveTarget, got %d", p.MoveTarget) + } + // With energy (agility 1 gives 3 run energy): 1 tick. + if p.MoveTicks != 1 { + t.Errorf("expected MoveTicks=1 with energy, got %d", p.MoveTicks) + } +} + +func TestBeginEscapeMoveWithoutEnergy(t *testing.T) { + g := escapeTestGame() + p := player.New("walk") + p.RunEnergy = 0 + p.EscapeDir = "east" + p.EscapeTarget = 5 + p.EscapeIsWalk = false + + sess := escapeTestSession(p) + g.beginEscapeMove(sess, p) + + if p.MoveTicks != 2 { + t.Errorf("expected MoveTicks=2 without energy, got %d", p.MoveTicks) + } + if p.EscapeDir != "" { + t.Error("EscapeDir should be cleared") + } +} + +func TestBeginEscapeMoveWithCapeOfAgility(t *testing.T) { + g := escapeTestGame() + p := player.New("cape") + p.Equipment[item.SlotBack] = capeOfAgilityID + + // moveTicks returns 0 for Cape of Agility, which triggers the instant + // completeMove path in beginEscapeMove. The sys_energy_test.go tests + // already cover moveTicks returning 0 for Cape. This test verifies + // the call site doesn't regress. + ticks := g.moveTicks(p, false) + if ticks != 0 { + t.Errorf("Cape of Agility should give 0 ticks, got %d", ticks) + } +} + +func TestApplyMobHitCancelsEscape(t *testing.T) { + g := escapeTestGame() + p := player.New("ouch") + p.EscapeDir = "south" + p.EscapeTarget = 3 + p.WalkSequence = []string{"s", "s", "s"} + + g.Combat.Enter(p.Name, "testmob") + + mob := &world.MobInstance{ + InstanceID: "testmob", + DefID: "test_def", + Name: "a goblin", + } + + sess := escapeTestSession(p) + g.applyMobHit(sess, p, mob, "slash", 10) + + if p.EscapeDir != "" { + t.Error("mob hit should clear EscapeDir") + } + if p.EscapeTarget != 0 { + t.Error("mob hit should clear EscapeTarget") + } + if len(p.WalkSequence) != 0 { + t.Errorf("mob hit should cancel WalkSequence, got %d entries", len(p.WalkSequence)) + } + if cs := g.Combat.Get(p.Name); cs == nil || !cs.Active { + t.Error("combat should still be active after cancelling escape on hit") + } +} + +func TestApplyMobMissFiresEscape(t *testing.T) { + g := escapeTestGame() + p := player.New("lucky") + p.EscapeDir = "east" + p.EscapeTarget = 8 + p.EscapeIsWalk = false + + g.Combat.Enter(p.Name, "testmob") + + mob := &world.MobInstance{ + InstanceID: "testmob", + DefID: "test_def", + Name: "a goblin", + } + + sess := escapeTestSession(p) + g.applyMobMiss(sess, p, mob) + + if p.EscapeDir != "" { + t.Error("mob miss should trigger escape, clearing EscapeDir") + } + if p.MoveDirection != "east" { + t.Errorf("MoveDirection should be set from escape, got %q", p.MoveDirection) + } + if p.MoveTicks != 1 { + t.Errorf("expected MoveTicks=1 after miss-triggered escape, got %d", p.MoveTicks) + } +} + +func TestEscapeReaimSilently(t *testing.T) { + p := player.New("reaim") + p.EscapeDir = "north" + p.EscapeTarget = 1 + + p.EscapeDir = "south" + p.EscapeTarget = 2 + + if p.EscapeDir != "south" { + t.Error("re-aim should update EscapeDir") + } + if p.EscapeTarget != 2 { + t.Error("re-aim should update EscapeTarget") + } +} + +func TestDoAttackAbortsFlee(t *testing.T) { + g := escapeTestGame() + p := player.New("attacker") + p.EscapeDir = "west" + p.EscapeTarget = 4 + + g.Combat.Enter(p.Name, "testmob") + + sess := escapeTestSession(p) + g.doAttack(sess, "goblin") + + if p.EscapeDir != "" { + t.Error("doAttack should clear EscapeDir even when already in combat") + } +} + +func TestEscapeClearedOnStop(t *testing.T) { + p := player.New("stopper") + p.EscapeDir = "north" + p.EscapeTarget = 5 + + p.ClearMoveState() + + if p.EscapeDir != "" { + t.Error("ClearMoveState should clear Escape fields") + } +} + +func TestPlayerActionDisplayShowsPreparingToFlee(t *testing.T) { + g := escapeTestGame() + p := player.New("fleer") + p.EscapeDir = "north" + + display := g.playerActionDisplay(p) + if display != "preparing to flee" { + t.Errorf("expected 'preparing to flee', got %q", display) + } +} + +func TestEndCombatFiresEscapeOnMobDeath(t *testing.T) { + g := escapeTestGame() + p := player.New("finisher") + p.EscapeDir = "east" + p.EscapeTarget = 10 + + mob := &world.MobInstance{ + InstanceID: "finisher_mob", + DefID: "test_def", + Name: "a goblin", + HP: 0, + } + + sess := escapeTestSession(p) + g.endCombat(sess, p, mob) + + if p.EscapeDir != "" { + t.Error("endCombat should fire escape, clearing EscapeDir") + } + if p.MoveTicks != 1 { + t.Errorf("expected MoveTicks=1 after endCombat escape, got %d", p.MoveTicks) + } + if p.MoveDirection != "east" { + t.Errorf("expected MoveDirection from escape, got %q", p.MoveDirection) + } +} + +func TestEndCombatDoesNotFireEscapeIfMobAlive(t *testing.T) { + g := escapeTestGame() + p := player.New("patient") + p.EscapeDir = "east" + p.EscapeTarget = 10 + + mob := &world.MobInstance{ + InstanceID: "alive_mob", + DefID: "test_def", + Name: "a goblin", + HP: 5, + } + + sess := escapeTestSession(p) + g.endCombat(sess, p, mob) + + if p.EscapeDir != "east" { + t.Error("escape should still be pending when mob lives") + } + if p.MoveDirection != "" { + t.Error("MoveDirection should NOT be set when escape not fired") + } +} diff --git a/internal/game/tick.go b/internal/game/tick.go index 4db5b58..d765983 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -334,13 +334,6 @@ func (g *Game) MoveTick() { } p.MoveTicks-- if p.MoveTicks > 0 { - if g.Combat.Get(p.Name) != nil && p.OptionBool("run_countdown") { - if p.MoveTicks > 1 { - sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks)) - } else { - sess.WriteLine("Running next tick!") - } - } } else { g.completeMove(sess, p) g.writePrompt(sess) diff --git a/internal/player/player.go b/internal/player/player.go index ef3acb3..6bc9a17 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -127,7 +127,6 @@ var OptionDefs = []OptionDef{ {"show_queued_cmds", OptBool, false, nil, "Show confirmation messages for queued tick actions"}, {"wrap_width", OptInt, 80, nil, "Wrap all output to this many columns (minimum 80)"}, {"unicode", OptBool, true, nil, "Unicode box-drawing characters"}, - {"run_countdown", OptBool, false, nil, "Show countdown messages when fleeing combat"}, {"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"}, {"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"}, {"visual_tick_text", OptString, "Tick", nil, "Text displayed for visual ticks"}, @@ -185,6 +184,10 @@ type Player struct { MoveTarget int `yaml:"-"` MovePendingTrigger *behavior.Trigger `yaml:"-"` MoveUsesEnergy bool `yaml:"-"` + EscapeDir string `yaml:"-"` + EscapeTarget int `yaml:"-"` + EscapeTrigger *behavior.Trigger `yaml:"-"` + EscapeIsWalk bool `yaml:"-"` VisualTickCurrent int `yaml:"-"` AutotriggerMod string `yaml:"-"` QueuedTrigger string `yaml:"-"` @@ -235,6 +238,14 @@ func (p *Player) ClearMoveState() { p.MoveTarget = 0 p.MovePendingTrigger = nil p.MoveUsesEnergy = false + p.ClearEscape() +} + +func (p *Player) ClearEscape() { + p.EscapeDir = "" + p.EscapeTarget = 0 + p.EscapeTrigger = nil + p.EscapeIsWalk = false } func (p *Player) OptionBool(name string) bool { |
