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 ticks, usesEnergy := g.moveTicks(p, false) if ticks != 0 { t.Errorf("Cape of Agility should give 0 ticks, got %d", ticks) } if usesEnergy { t.Error("Cape of Agility should not use energy") } } 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") } } // TestPlayerActionDisplayNoPreparingToFlee verifies the // "preparing to flee" display branch has been removed. func TestPlayerActionDisplayNoPreparingToFlee(t *testing.T) { g := escapeTestGame() p := player.New("fleer") p.EscapeDir = "north" display := g.playerActionDisplay(p) if display == "preparing to flee" { t.Errorf("the stale 'preparing to flee' branch should be removed; got %q", display) } } // TestEscapeReliefFiresOnThirdHit verifies the soft-lock relief: the third // consecutive escape-canceling mob hit in the same combat triggers the flee // as if the mob had missed, instead of cancelling. func TestEscapeReliefFiresOnThirdHit(t *testing.T) { g := escapeTestGame() p := player.New("relief") p.HP = 100 p.EscapeDir = "north" p.EscapeTarget = 11 mob := &world.MobInstance{ InstanceID: "relief_mob", DefID: "relief_def", Name: "a goblin", } g.Combat.Enter(p.Name, "relief_mob") sess := escapeTestSession(p) for i := 0; i < 3; i++ { p.EscapeDir = "north" p.EscapeTarget = 11 g.applyMobHit(sess, p, mob, "slash", 1) } if p.EscapeFailCount != 0 { t.Errorf("after relief fired, EscapeFailCount should reset, got %d", p.EscapeFailCount) } if p.EscapeDir != "" { t.Errorf("relief should have cleared EscapeDir, got %q", p.EscapeDir) } if p.MoveDirection != "north" { t.Errorf("relief should set MoveDirection=north, got %q", p.MoveDirection) } if p.MoveTicks == 0 { t.Error("relief should set MoveTicks > 0 to actually flee") } } // TestEscapeReliefDoesNotFireBeforeThirdHit verifies the relief waits until // the 3rd hit; the 1st and 2nd should still cancel normally. func TestEscapeReliefDoesNotFireBeforeThirdHit(t *testing.T) { g := escapeTestGame() p := player.New("patient") p.HP = 100 p.EscapeDir = "north" p.EscapeTarget = 11 mob := &world.MobInstance{ InstanceID: "relief_mob2", DefID: "relief_def2", Name: "a goblin", } g.Combat.Enter(p.Name, "relief_mob2") sess := escapeTestSession(p) // First hit: cancels normally. g.applyMobHit(sess, p, mob, "slash", 1) if p.EscapeFailCount != 1 { t.Errorf("after 1st hit, EscapeFailCount=%d want 1", p.EscapeFailCount) } if p.EscapeDir != "" { t.Error("1st hit should cancel escape (ClearEscape)") } if p.MoveTicks != 0 { t.Error("1st hit should not start movement") } // Second hit: cancels again. p.EscapeDir = "north" p.EscapeTarget = 11 g.applyMobHit(sess, p, mob, "slash", 1) if p.EscapeFailCount != 2 { t.Errorf("after 2nd hit, EscapeFailCount=%d want 2", p.EscapeFailCount) } if p.EscapeDir != "" { t.Error("2nd hit should cancel escape (ClearEscape)") } if p.MoveTicks != 0 { t.Error("2nd hit should not start movement") } } // TestEscapeFailCountResetsOnSuccessfulMissFlee verifies the counter resets // after a successful flee path (miss) — so the next flee attempt in the same // combat starts fresh at 0. func TestEscapeFailCountResetsOnSuccessfulMissFlee(t *testing.T) { g := escapeTestGame() p := player.New("reclucky") p.HP = 100 g.Combat.Enter(p.Name, "relief_mob3") mob := &world.MobInstance{ InstanceID: "relief_mob3", DefID: "relief_def3", Name: "a goblin", } sess := escapeTestSession(p) // Two escape-canceling hits. p.EscapeDir = "north" p.EscapeTarget = 11 g.applyMobHit(sess, p, mob, "slash", 1) p.EscapeDir = "north" p.EscapeTarget = 11 g.applyMobHit(sess, p, mob, "slash", 1) if p.EscapeFailCount != 2 { t.Fatalf("expected EscapeFailCount=2 after 2 hits, got %d", p.EscapeFailCount) } // Then a miss triggers a successful flee — counter must reset. p.EscapeDir = "north" p.EscapeTarget = 11 g.applyMobMiss(sess, p, mob) if p.EscapeFailCount != 0 { t.Errorf("EscapeFailCount should reset on successful miss-flee, got %d", p.EscapeFailCount) } if p.MoveTicks == 0 { t.Error("miss should have fired beginEscapeMove (MoveTicks > 0)") } } // TestEscapeFailCountResetsOnCombatEntry verifies the counter resets when a // new combat starts (startCombat), so a fresh engagement begins at 0. func TestEscapeFailCountResetsOnCombatEntry(t *testing.T) { g := escapeTestGame() p := player.New("reset") p.HP = 100 p.EscapeFailCount = 7 // carryover from prior engagement mob := &world.MobInstance{ InstanceID: "fresh_mob", DefID: "fresh_def", Name: "a goblin", HP: 10, } sess := escapeTestSession(p) g.startCombat(sess, p, mob) if p.EscapeFailCount != 0 { t.Errorf("startCombat should reset EscapeFailCount, got %d", p.EscapeFailCount) } } // TestEscapeReliefSuppressedOnKillingBlow verifies that when the 3rd hit // would kill the player, the relief is suppressed. func TestEscapeReliefSuppressedOnKillingBlow(t *testing.T) { g := escapeTestGame() p := player.New("doomed") p.HP = 3 // exactly enough to absorb three maxHit==1 (deterministic) hits mob := &world.MobInstance{ InstanceID: "lethal_mob", DefID: "lethal_def", Name: "a goblin", } g.Combat.Enter(p.Name, "lethal_mob") sess := escapeTestSession(p) // maxHit=1 → RollDamage returns exactly 1 each call; deterministic. for i := 0; i < 2; i++ { p.EscapeDir = "north" p.EscapeTarget = 11 g.applyMobHit(sess, p, mob, "slash", 1) } // Before the 3rd hit: HP=1. After damage: HP=0 → relief must NOT fire. p.EscapeDir = "north" p.EscapeTarget = 11 g.applyMobHit(sess, p, mob, "slash", 1) if p.EscapeDir != "" { t.Error("killing blow should still ClearEscape (EscapeDir)") } if p.MoveTicks != 0 { t.Error("killing blow should NOT start movement (relief must be suppressed)") } if p.EscapeFailCount != 3 { t.Errorf("EscapeFailCount after killing blow should be 3, got %d", p.EscapeFailCount) } } 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") } }