@ -99,21 +99,18 @@ type Engine struct {
// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
const offlineDisconnectedFullSaveInterval = 30 * time . Second
const offlineDisconnectedFullSaveInterval = 30 * time . Second
// restHealPersistInterval is how often we persist the full hero row while resting with active HP regen.
const restHealPersistInterval = 5 * time . Second
// NewEngine creates a new game engine with the given tick rate.
// NewEngine creates a new game engine with the given tick rate.
func NewEngine ( tickRate time . Duration , eventCh chan model . CombatEvent , logger * slog . Logger ) * Engine {
func NewEngine ( tickRate time . Duration , eventCh chan model . CombatEvent , logger * slog . Logger ) * Engine {
e := & Engine {
e := & Engine {
tickRate : tickRate ,
tickRate : tickRate ,
combats : make ( map [ int64 ] * model . CombatState ) ,
combats : make ( map [ int64 ] * model . CombatState ) ,
queue : make ( model . AttackQueue , 0 ) ,
queue : make ( model . AttackQueue , 0 ) ,
movements : make ( map [ int64 ] * HeroMovement ) ,
movements : make ( map [ int64 ] * HeroMovement ) ,
incomingCh : make ( chan IncomingMessage , 256 ) ,
incomingCh : make ( chan IncomingMessage , 256 ) ,
eventCh : eventCh ,
eventCh : eventCh ,
logger : logger ,
logger : logger ,
lastDisconnectedFullSave : make ( map [ int64 ] time . Time ) ,
lastDisconnectedFullSave : make ( map [ int64 ] time . Time ) ,
merchantStock : make ( map [ int64 ] * merchantOfferSession ) ,
merchantStock : make ( map [ int64 ] * merchantOfferSession ) ,
}
}
heap . Init ( & e . queue )
heap . Init ( & e . queue )
return e
return e
@ -560,6 +557,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
}
}
}
// handleRevive processes the revive client command.
func ( e * Engine ) handleNPCAlmsAccept ( msg IncomingMessage ) {
func ( e * Engine ) handleNPCAlmsAccept ( msg IncomingMessage ) {
e . mu . RLock ( )
e . mu . RLock ( )
h := e . npcAlmsHandler
h := e . npcAlmsHandler
@ -589,7 +587,6 @@ func (e *Engine) handleNPCAlmsDecline(msg IncomingMessage) {
}
}
}
}
// handleRevive processes the revive client command (same rules as POST /api/v1/hero/revive).
func ( e * Engine ) handleRevive ( msg IncomingMessage ) {
func ( e * Engine ) handleRevive ( msg IncomingMessage ) {
e . mu . Lock ( )
e . mu . Lock ( )
defer e . mu . Unlock ( )
defer e . mu . Unlock ( )
@ -601,17 +598,25 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
}
}
hero := hm . Hero
hero := hm . Hero
if ! IsEffectivelyDead ( hero ) {
if hero . HP > 0 && hm . State != model . StateDead {
e . sendError ( msg . HeroID , "not_dead" , "hero is not dead" )
e . sendError ( msg . HeroID , "not_dead" , "hero is not dead" )
return
return
}
}
if err := CheckPlayerReviveQuota ( hero ) ; err != nil {
e . sendError ( msg . HeroID , "revive_quota" , "free revive limit reached (subscribe for unlimited revives)" )
hero . HP = int ( float64 ( hero . MaxHP ) * tuning . Get ( ) . ReviveHpPercent )
return
if hero . HP < 1 {
hero . HP = 1
}
}
hero . State = model . StateWalking
hero . Debuffs = nil
hero . ReviveCount ++
hm . State = model . StateWalking
hm . LastMoveTick = time . Now ( )
hm . refreshSpeed ( time . Now ( ) )
ApplyHeroReviveMechanical ( hero )
// Remove any active combat.
ApplyPlayerReviveProgressCounters ( hero )
delete ( e . combats , msg . HeroID )
// Persist revive to DB immediately so disconnect doesn't revert it.
// Persist revive to DB immediately so disconnect doesn't revert it.
if e . heroStore != nil {
if e . heroStore != nil {
@ -620,11 +625,12 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
}
}
}
}
if e . adventureLog != nil {
if e . sender != nil {
e . adventureLog ( hero . ID , model . AdventureLogLine { Event : & model . AdventureLogEvent { Code : model . LogPhraseHeroRevived } } )
hero . EnsureGearMap ( )
hero . RefreshDerivedCombatStats ( time . Now ( ) )
e . sender . SendToHero ( msg . HeroID , "hero_state" , hero )
e . sender . SendToHero ( msg . HeroID , "hero_revived" , model . HeroRevivedPayload { HP : hero . HP } )
}
}
e . applyResidentReviveSyncLocked ( hero )
}
}
// sendError sends an error envelope to a hero.
// sendError sends an error envelope to a hero.
@ -946,98 +952,6 @@ func (e *Engine) ApplyAdminStopRest(heroID int64) (*model.Hero, bool) {
return h , true
return h , true
}
}
// ApplyAdminStopAnyRest ends whichever rest or town pause applies: first roadside/adventure-inline
// (must not use LeaveTown — that would corrupt excursion state), otherwise town rest or in-town flow.
func ( e * Engine ) ApplyAdminStopAnyRest ( heroID int64 ) ( * model . Hero , bool ) {
e . mu . Lock ( )
defer e . mu . Unlock ( )
hm , ok := e . movements [ heroID ]
if ! ok || e . roadGraph == nil {
return nil , false
}
now := time . Now ( )
if hm . AdminStopRest ( now ) {
hm . SyncToHero ( )
h := hm . Hero
if e . sender != nil {
h . EnsureGearMap ( )
h . RefreshDerivedCombatStats ( now )
e . sender . SendToHero ( heroID , "hero_state" , h )
e . sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
if route := hm . RoutePayload ( ) ; route != nil {
e . sender . SendToHero ( heroID , "route_assigned" , route )
}
}
return h , true
}
if hm . State != model . StateResting && hm . State != model . StateInTown {
return nil , false
}
hm . LeaveTown ( e . roadGraph , now )
hm . SyncToHero ( )
h := hm . Hero
if e . sender != nil {
h . EnsureGearMap ( )
h . RefreshDerivedCombatStats ( now )
e . sender . SendToHero ( heroID , "hero_state" , h )
e . sender . SendToHero ( heroID , "town_exit" , model . TownExitPayload { } )
if route := hm . RoutePayload ( ) ; route != nil {
e . sender . SendToHero ( heroID , "route_assigned" , route )
}
}
if e . heroStore != nil {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
if err := e . heroStore . Save ( ctx , h ) ; err != nil && e . logger != nil {
e . logger . Error ( "persist hero after admin stop any rest (leave town)" , "hero_id" , heroID , "error" , err )
}
}
return h , true
}
// ApplyAdminLethalEnemyKill applies a killing blow from the hero and runs the normal victory path (rewards, WS, persist).
func ( e * Engine ) ApplyAdminLethalEnemyKill ( heroID int64 ) ( * model . Hero , bool ) {
e . mu . Lock ( )
defer e . mu . Unlock ( )
cs , ok := e . combats [ heroID ]
if ! ok || cs == nil || cs . Hero == nil || ! cs . Enemy . IsAlive ( ) {
return nil , false
}
now := time . Now ( )
dmg := cs . Enemy . HP
cs . Enemy . HP = 0
combatEvt := model . CombatEvent {
Type : "attack" ,
HeroID : heroID ,
Damage : dmg ,
Source : "hero" ,
Outcome : attackOutcomeHit ,
HeroHP : cs . Hero . HP ,
EnemyHP : 0 ,
Timestamp : now ,
}
e . emitEvent ( combatEvt )
e . logCombatAttack ( cs , combatEvt )
if e . sender != nil {
e . sender . SendToHero ( heroID , "attack" , model . AttackPayload {
Source : combatEvt . Source ,
Damage : combatEvt . Damage ,
IsCrit : false ,
Outcome : combatEvt . Outcome ,
HeroHP : combatEvt . HeroHP ,
EnemyHP : 0 ,
} )
}
e . handleEnemyDeath ( cs , now )
if hm , ok := e . movements [ heroID ] ; ok && hm . Hero != nil {
return hm . Hero , true
}
if cs . Hero != nil {
return cs . Hero , true
}
return nil , false
}
// ApplyAdminStartExcursion forces an online hero into a mini-adventure session on the current road.
// ApplyAdminStartExcursion forces an online hero into a mini-adventure session on the current road.
func ( e * Engine ) ApplyAdminStartExcursion ( heroID int64 ) ( * model . Hero , bool ) {
func ( e * Engine ) ApplyAdminStartExcursion ( heroID int64 ) ( * model . Hero , bool ) {
e . mu . Lock ( )
e . mu . Lock ( )
@ -1140,6 +1054,8 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
if hm , ok := e . movements [ hero . ID ] ; ok && hm . Hero != nil {
if hm , ok := e . movements [ hero . ID ] ; ok && hm . Hero != nil {
ox , oy := hm . displayOffset ( now )
ox , oy := hm . displayOffset ( now )
wx , wy = hm . CurrentX + ox , hm . CurrentY + oy
wx , wy = hm . CurrentX + ox , hm . CurrentY + oy
} else if hero != nil {
wx , wy = hero . PositionX , hero . PositionY
}
}
if e . roadGraph . HeroInTownAt ( wx , wy ) {
if e . roadGraph . HeroInTownAt ( wx , wy ) {
e . logger . Debug ( "skip combat start: hero inside town radius" , "hero_id" , hero . ID )
e . logger . Debug ( "skip combat start: hero inside town radius" , "hero_id" , hero . ID )
@ -1156,19 +1072,6 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
StartedAt : now ,
StartedAt : now ,
LastTickAt : now ,
LastTickAt : now ,
}
}
if tmpl , ok := model . EnemyBySlug ( enemy . Slug ) ; ok {
baseScaled , afterGlobal := EnemyEncounterStatStages ( tmpl , enemy . Level )
cs . EnemyStatsBasePreEncounterMult = & model . EncounterCombatStatsSnapshot {
MaxHP : baseScaled . MaxHP ,
Attack : baseScaled . Attack ,
Defense : baseScaled . Defense ,
}
cs . EnemyStatsAfterGlobalEncounterMult = & model . EncounterCombatStatsSnapshot {
MaxHP : afterGlobal . MaxHP ,
Attack : afterGlobal . Attack ,
Defense : afterGlobal . Defense ,
}
}
e . combats [ hero . ID ] = cs
e . combats [ hero . ID ] = cs
hero . State = model . StateFighting
hero . State = model . StateFighting
@ -1190,6 +1093,16 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
CombatID : hero . ID ,
CombatID : hero . ID ,
} )
} )
// Legacy event channel (for backward compat bridge).
e . emitEvent ( model . CombatEvent {
Type : "combat_start" ,
HeroID : hero . ID ,
Source : "system" ,
HeroHP : hero . HP ,
EnemyHP : enemy . HP ,
Timestamp : now ,
} )
// New: send typed combat_start envelope.
// New: send typed combat_start envelope.
if e . sender != nil {
if e . sender != nil {
e . sender . SendToHero ( hero . ID , "combat_start" , model . CombatStartPayload {
e . sender . SendToHero ( hero . ID , "combat_start" , model . CombatStartPayload {
@ -1206,28 +1119,10 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
} )
} )
}
}
if e . logger != nil {
e . logger . Info ( "combat started" ,
mult := EnemyEncounterMultiplierBreakdownForHero ( hero )
"hero_id" , hero . ID ,
e . logger . Info ( "combat started" ,
"enemy" , enemy . Name ,
"hero_id" , hero . ID ,
)
"hero_level" , hero . Level ,
"enemy_slug" , enemy . Slug ,
"enemy_name" , enemy . Name ,
"enemy_level" , enemy . Level ,
"enemy_hp" , enemy . HP ,
"enemy_max_hp" , enemy . MaxHP ,
"enemy_attack" , enemy . Attack ,
"enemy_defense" , enemy . Defense ,
"enemy_speed" , enemy . Speed ,
"enemy_crit_chance" , enemy . CritChance ,
"enemy_is_elite" , enemy . IsElite ,
"enemy_xp_reward" , enemy . XPReward ,
"enemy_gold_reward" , enemy . GoldReward ,
"mult_global_encounter" , mult . GlobalEncounterStatMultiplier ,
"mult_unequipped_config" , mult . UnequippedHeroStatMultiplier ,
"mult_unequipped_applied" , mult . UnequippedScalingApplied ,
)
}
}
}
// StopCombat removes a combat session.
// StopCombat removes a combat session.
@ -1339,12 +1234,15 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
e . ApplyPersistedHeroSnapshot ( hero )
e . ApplyPersistedHeroSnapshot ( hero )
}
}
// applyResidentReviveSyncLocked clears combat, merges a persisted hero into the live session,
// ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted
// and pushes hero_state + hero_revived. Caller must hold e.mu.
// the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online),
func ( e * Engine ) applyResidentReviveSyncLocked ( hero * model . Hero ) {
// restores movement/route when needed, and pushes WS events so the client matches the DB.
func ( e * Engine ) ApplyAdminHeroRevive ( hero * model . Hero ) {
if hero == nil {
if hero == nil {
return
return
}
}
e . mu . Lock ( )
defer e . mu . Unlock ( )
delete ( e . combats , hero . ID )
delete ( e . combats , hero . ID )
@ -1386,18 +1284,6 @@ func (e *Engine) applyResidentReviveSyncLocked(hero *model.Hero) {
}
}
}
}
// ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted
// the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online),
// restores movement/route when needed, and pushes WS events so the client matches the DB.
func ( e * Engine ) ApplyAdminHeroRevive ( hero * model . Hero ) {
if hero == nil {
return
}
e . mu . Lock ( )
defer e . mu . Unlock ( )
e . applyResidentReviveSyncLocked ( hero )
}
// ApplyAdminHeroDeath merges a persisted dead hero after POST /admin/.../force-death, clears combat,
// ApplyAdminHeroDeath merges a persisted dead hero after POST /admin/.../force-death, clears combat,
// updates live movement (if any), and pushes hero_state; optionally hero_died for clients.
// updates live movement (if any), and pushes hero_state; optionally hero_died for clients.
func ( e * Engine ) ApplyAdminHeroDeath ( hero * model . Hero , sendDiedEvent bool ) {
func ( e * Engine ) ApplyAdminHeroDeath ( hero * model . Hero , sendDiedEvent bool ) {
@ -1845,7 +1731,12 @@ func (e *Engine) processAutoReviveLocked(now time.Time) {
if now . Sub ( h . UpdatedAt ) <= gap {
if now . Sub ( h . UpdatedAt ) <= gap {
continue
continue
}
}
ApplyHeroReviveMechanical ( h )
h . HP = int ( float64 ( h . MaxHP ) * tuning . Get ( ) . ReviveHpPercent )
if h . HP < 1 {
h . HP = 1
}
h . State = model . StateWalking
h . Debuffs = nil
hm . State = model . StateWalking
hm . State = model . StateWalking
hm . SyncToHero ( )
hm . SyncToHero ( )
dctx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Second )
dctx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Second )
@ -1864,7 +1755,6 @@ func (e *Engine) processAutoReviveLocked(now time.Time) {
e . logger . Error ( "persist hero after auto-revive" , "hero_id" , heroID , "error" , err )
e . logger . Error ( "persist hero after auto-revive" , "hero_id" , heroID , "error" , err )
}
}
cancelSave ( )
cancelSave ( )
e . applyResidentReviveSyncLocked ( h )
}
}
}
}
@ -1892,10 +1782,7 @@ func (e *Engine) processMovementTick(now time.Time) {
if hm . skipMovementSimulation ( ) {
if hm . skipMovementSimulation ( ) {
continue
continue
}
}
ProcessSingleHeroMovementTick ( heroID , hm , e . roadGraph , now , e . sender , startCombat , nil , e . adventureLog , e . persistHeroAfterTownEnter , nil , e . logger )
ProcessSingleHeroMovementTick ( heroID , hm , e . roadGraph , now , e . sender , startCombat , nil , e . adventureLog , e . persistHeroAfterTownEnter , nil )
if hm . State != model . StateResting {
hm . lastRestHealPersistAt = time . Time { }
}
if e . heroStore == nil || hm . Hero == nil {
if e . heroStore == nil || hm . Hero == nil {
continue
continue
}
}
@ -1911,27 +1798,8 @@ func (e *Engine) processMovementTick(now time.Time) {
continue
continue
}
}
hm . MarkTownPausePersisted ( sig )
hm . MarkTownPausePersisted ( sig )
if hm . State == model . StateResting {
hm . lastRestHealPersistAt = now
}
e . syncTownSessionRedis ( heroID , hm )
e . syncTownSessionRedis ( heroID , hm )
}
}
if hm . State == model . StateResting && hm . restHPRegenActive ( ) {
if hm . lastRestHealPersistAt . IsZero ( ) || now . Sub ( hm . lastRestHealPersistAt ) >= restHealPersistInterval {
hm . SyncToHero ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
err := e . heroStore . Save ( ctx , hm . Hero )
cancel ( )
if err != nil {
if e . logger != nil {
e . logger . Error ( "persist hero during rest heal" , "hero_id" , heroID , "error" , err )
}
} else {
hm . lastRestHealPersistAt = now
e . syncTownSessionRedis ( heroID , hm )
}
}
}
if e . heroStore != nil && e . heroSubscriber != nil && hm . Hero != nil && ! e . heroSubscriber ( heroID ) {
if e . heroStore != nil && e . heroSubscriber != nil && hm . Hero != nil && ! e . heroSubscriber ( heroID ) {
last := e . lastDisconnectedFullSave [ heroID ]
last := e . lastDisconnectedFullSave [ heroID ]
if last . IsZero ( ) || now . Sub ( last ) >= offlineDisconnectedFullSaveInterval {
if last . IsZero ( ) || now . Sub ( last ) >= offlineDisconnectedFullSaveInterval {