From 0c52a369cb486167cdf1d0874135540fa709f418 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Tue, 31 Mar 2026 14:11:18 +0300 Subject: [PATCH] fix dead screen --- backend/internal/game/engine.go | 12 +++++++++- backend/internal/game/movement.go | 37 ++++++++++++++++++++++++++++++ backend/internal/game/rest_test.go | 19 +++++++++++++++ frontend/src/game/engine.ts | 33 ++++++++++++++++++-------- 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index bc04c8f..7f9923a 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -1565,8 +1565,15 @@ func (e *Engine) processMovementTick(now time.Time) { } for heroID, hm := range e.movements { + if hm == nil { + continue + } + // Do not run movement FSM, AdvanceTick, or encounters for dead heroes. + if hm.skipMovementSimulation() { + continue + } ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil) - if e.heroStore == nil || hm == nil || hm.Hero == nil { + if e.heroStore == nil || hm.Hero == nil { continue } if sig, ok := hm.TownPausePersistDue(); ok { @@ -1701,6 +1708,9 @@ func (e *Engine) processPositionSync(now time.Time) { if hm == nil { continue } + if hm.skipMovementSimulation() { + continue + } if sender != nil && hm.State == model.StateWalking { sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index ece6730..bd7e7f1 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -546,6 +546,9 @@ func (hm *HeroMovement) refreshSpeed(now time.Time) { // AdvanceTick moves the hero along the road for one movement tick. // Returns true if the hero reached the destination town this tick. func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTown bool) { + if hm.skipMovementSimulation() { + return false + } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } @@ -1033,6 +1036,36 @@ func (hm *HeroMovement) Die() { hm.State = model.StateDead } +// canSimulateMovement is true when the hero may advance along roads, towns, rests, and encounters. +// Dead heroes must not run the movement FSM or AdvanceTick. +func (hm *HeroMovement) canSimulateMovement() bool { + if hm == nil || hm.Hero == nil { + return false + } + if hm.State == model.StateDead { + return false + } + if hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { + return false + } + return true +} + +// skipMovementSimulation returns true if the hero must not run movement this step. +// When the model is dead but FSM is not yet StateDead, aligns with Die(). +func (hm *HeroMovement) skipMovementSimulation() bool { + if hm == nil { + return true + } + if hm.canSimulateMovement() { + return false + } + if hm.Hero != nil && (hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead) { + hm.Die() + } + return true +} + // worldPositionAt returns hero world (x,y) matching SyncToHero / hero_move (spine + display offset). func (hm *HeroMovement) worldPositionAt(now time.Time) (x, y float64) { if hm == nil || hm.Hero == nil { @@ -1751,6 +1784,10 @@ func ProcessSingleHeroMovementTick( return } + if hm.skipMovementSimulation() { + return + } + switch hm.State { case model.StateFighting, model.StateDead: return diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go index 810634b..a367310 100644 --- a/backend/internal/game/rest_test.go +++ b/backend/internal/game/rest_test.go @@ -600,3 +600,22 @@ func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) { t.Fatalf("expected no rest kind, got %s", hm.ActiveRestKind) } } + +// TestProcessMovementTick_DeadHeroIgnoresWalkingFSM ensures HP 0 never runs the walking +// tick (no hero_move) even if hm.State was incorrectly left as Walking. +func TestProcessMovementTick_DeadHeroIgnoresWalkingFSM(t *testing.T) { + graph := testGraph() + hero := testHeroOnRoad(1, 0, 1000) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + if hm.State != model.StateDead { + t.Fatalf("NewHeroMovement should set dead for HP=0, got %s", hm.State) + } + hm.State = model.StateWalking // simulate FSM / snapshot desync + + ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil) + + if hm.State != model.StateDead { + t.Fatalf("expected StateDead after tick, got %s", hm.State) + } +} diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index d70c441..25e3554 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -294,8 +294,12 @@ export class GameEngine { this._gameState.hero.position.y = y; } - // Sync phase from server state string - if (state === 'walking' && this._gameState.phase !== GamePhase.Fighting) { + // Sync phase from server state string (never clear Death or active combat) + if ( + state === 'walking' && + this._gameState.phase !== GamePhase.Fighting && + this._gameState.phase !== GamePhase.Dead + ) { this._gameState = { ...this._gameState, phase: GamePhase.Walking }; this._thoughtText = null; } @@ -312,9 +316,11 @@ export class GameEngine { this._routeWaypoints = waypoints; this._heroSpeed = speed; this._syncWorldTerrainContext(); + const hp = this._gameState.hero?.hp ?? 1; + const phase = hp <= 0 ? GamePhase.Dead : GamePhase.Walking; this._gameState = { ...this._gameState, - phase: GamePhase.Walking, + phase, routeWaypoints: waypoints.length >= 2 ? waypoints.map((p) => ({ x: p.x, y: p.y })) : null, }; this._thoughtText = null; @@ -371,16 +377,23 @@ export class GameEngine { const newX = hero.position.x || 0; const newY = hero.position.y || 0; + const activity = hero.serverActivityState?.toLowerCase(); + const isDead = hero.hp <= 0 || activity === 'dead'; + this._gameState = { ...this._gameState, hero, }; - const activity = hero.serverActivityState?.toLowerCase(); - if ( - this._gameState.phase !== GamePhase.Fighting && - this._gameState.phase !== GamePhase.Dead - ) { + if (isDead) { + this._gameState = { + ...this._gameState, + phase: GamePhase.Dead, + enemy: null, + enemyOnScreenRight: undefined, + }; + this._thoughtText = null; + } else if (this._gameState.phase !== GamePhase.Fighting) { if (activity === 'resting') { this._gameState = { ...this._gameState, phase: GamePhase.Resting }; if (!this._thoughtText) this._showThought(); @@ -507,9 +520,11 @@ export class GameEngine { * Transitions back to walking phase. */ applyCombatEnd(): void { + const hp = this._gameState.hero?.hp ?? 0; + const phase = hp <= 0 ? GamePhase.Dead : GamePhase.Walking; this._gameState = { ...this._gameState, - phase: GamePhase.Walking, + phase, enemy: null, enemyOnScreenRight: undefined, };