aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-17 18:36:31 -0400
committerhistoria <[not public]>2026-07-17 18:36:31 -0400
commite6d0d46ac9977e77c7f19f41121ec466a3c53e56 (patch)
tree86063e0dfda2206ccdda1b8e1070dc4da7dea9d9 /internal/game
parent6e6c4a157564100249bb58f412485055bb2d10cf (diff)
downloadthehouseoficarus-e6d0d46ac9977e77c7f19f41121ec466a3c53e56.tar.gz
feat: update mob flee mechanics with new energy system
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_state.go3
-rw-r--r--internal/game/cmd_move.go31
-rw-r--r--internal/game/cmd_walk.go3
-rw-r--r--internal/game/combat_attack.go5
-rw-r--r--internal/game/combat_mob.go38
-rw-r--r--internal/game/sys_escape_test.go303
-rw-r--r--internal/game/tick.go7
7 files changed, 370 insertions, 20 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)