@ -110,10 +110,24 @@ type adminHeroDetailResponse struct {
HeroMovement * game . HeroMovement ` json:"heroMovement,omitempty" `
}
// adminCombatLiveJSON is the active engine combat session for admin live WS (enemy is full runtime instance + tuning breakdown).
type adminCombatLiveJSON struct {
Enemy model . Enemy ` json:"enemy" `
// EnemyStatsBasePreEncounterMult: level-scaled MaxHP/Attack/Defense before encounter multipliers.
EnemyStatsBasePreEncounterMult * model . EncounterCombatStatsSnapshot ` json:"enemyStatsBasePreEncounterMult,omitempty" `
// EnemyStatsAfterGlobalEncounterMult: same after global encounter mult only (before unequipped scaling).
EnemyStatsAfterGlobalEncounterMult * model . EncounterCombatStatsSnapshot ` json:"enemyStatsAfterGlobalEncounterMult,omitempty" `
Multipliers game . EnemyEncounterMultiplierBreakdown ` json:"multipliers" `
HeroNextAttack time . Time ` json:"heroNextAttack" `
EnemyNextAttack time . Time ` json:"enemyNextAttack" `
StartedAt time . Time ` json:"startedAt" `
}
// adminWSSnapshot is the admin live WebSocket payload: hero detail + last hero_move (client WS) sample.
type adminWSSnapshot struct {
Hero adminHeroDetailResponse ` json:"hero" `
HeroMove * model . HeroMovePayload ` json:"heroMove" `
Combat * adminCombatLiveJSON ` json:"combat,omitempty" `
}
type simulateCombatRequest struct {
@ -268,7 +282,25 @@ func (h *AdminHandler) buildAdminWSSnapshot(ctx context.Context, heroID int64) (
p := hm . MovePayload ( now )
move = & p
}
return adminWSSnapshot { Hero : detail , HeroMove : move } , nil
var combat * adminCombatLiveJSON
if h . engine != nil {
if cs , ok := h . engine . GetCombat ( heroID ) ; ok {
multHero := cs . Hero
if multHero == nil {
multHero = & detail . Hero
}
combat = & adminCombatLiveJSON {
Enemy : cs . Enemy ,
EnemyStatsBasePreEncounterMult : cs . EnemyStatsBasePreEncounterMult ,
EnemyStatsAfterGlobalEncounterMult : cs . EnemyStatsAfterGlobalEncounterMult ,
Multipliers : game . EnemyEncounterMultiplierBreakdownForHero ( multHero ) ,
HeroNextAttack : cs . HeroNextAttack ,
EnemyNextAttack : cs . EnemyNextAttack ,
StartedAt : cs . StartedAt ,
}
}
}
return adminWSSnapshot { Hero : detail , HeroMove : move , Combat : combat } , nil
}
// ListHeroes returns a paginated list of all heroes.
@ -1233,7 +1265,7 @@ func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) {
writeHeroJSON ( w , http . StatusOK , hero )
}
// ReviveHero force-revives a hero to full HP regardless of current state .
// ReviveHero applies the same revive rules as the in-game revive button (partial HP, quota counters) .
// POST /admin/heroes/{heroId}/revive
func ( h * AdminHandler ) ReviveHero ( w http . ResponseWriter , r * http . Request ) {
heroID , err := parseHeroID ( r )
@ -1259,10 +1291,15 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
hero . HP = hero . MaxHP
hero . State = model . StateWalking
hero . Buffs = nil
hero . Debuffs = nil
if ! game . IsEffectivelyDead ( hero ) {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "hero is not dead" ,
} )
return
}
game . ApplyHeroReviveMechanical ( hero )
game . ApplyPlayerReviveProgressCounters ( hero )
if err := h . store . Save ( r . Context ( ) , hero ) ; err != nil {
h . logger . Error ( "admin: save hero after revive" , "hero_id" , heroID , "error" , err )
@ -2103,68 +2140,10 @@ func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Reques
h . writeAdminHeroDetail ( w , heroAfter )
}
// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online .
// ForceLeaveTown is an alias for the unified stop-rest flow (see StopHeroRest) .
// POST /admin/heroes/{heroId}/leave-town
func ( h * AdminHandler ) ForceLeaveTown ( w http . ResponseWriter , r * http . Request ) {
heroID , err := parseHeroID ( r )
if err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "invalid heroId: " + err . Error ( ) ,
} )
return
}
if h . isHeroInCombat ( w , heroID ) {
return
}
hero , err := h . store . GetByID ( r . Context ( ) , heroID )
if err != nil {
h . logger . Error ( "admin: get hero for leave-town" , "hero_id" , heroID , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string {
"error" : "failed to load hero" ,
} )
return
}
if hero == nil {
writeJSON ( w , http . StatusNotFound , map [ string ] string {
"error" : "hero not found" ,
} )
return
}
if hero . State != model . StateResting && hero . State != model . StateInTown {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "hero is not resting or in town" ,
} )
return
}
if hm := h . engine . GetMovements ( heroID ) ; hm != nil {
out , ok := h . engine . ApplyAdminForceLeaveTown ( heroID )
if ! ok || out == nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "cannot leave town (movement state changed?)" ,
} )
return
}
out . RefreshDerivedCombatStats ( time . Now ( ) )
h . logger . Info ( "admin: force leave town" , "hero_id" , heroID )
writeJSON ( w , http . StatusOK , out )
return
}
hero2 , err := h . adminMovementOffline ( r . Context ( ) , hero , func ( hm * game . HeroMovement , rg * game . RoadGraph , now time . Time ) error {
if hm . State != model . StateResting && hm . State != model . StateInTown {
return fmt . Errorf ( "hero is not resting or in town" )
}
hm . LeaveTown ( rg , now )
return nil
} )
if err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : err . Error ( ) } )
return
}
h . logger . Info ( "admin: force leave town (offline)" , "hero_id" , heroID )
writeJSON ( w , http . StatusOK , hero2 )
h . stopHeroRestOrLeaveTown ( w , r , "leave-town" )
}
// StartHeroRoadsideRest forces a hero into roadside rest at the current road position.
@ -2226,9 +2205,14 @@ func (h *AdminHandler) StartHeroRoadsideRest(w http.ResponseWriter, r *http.Requ
h . writeAdminHeroDetail ( w , hero2 )
}
// StopHeroRest e xits a hero from non-town rest (roadside or adventure-inline) back to walking .
// StopHeroRest e nds any rest or in-town pause the engine recognizes (roadside, adventure-inline, town rest, town tour) .
// POST /admin/heroes/{heroId}/stop-rest
func ( h * AdminHandler ) StopHeroRest ( w http . ResponseWriter , r * http . Request ) {
h . stopHeroRestOrLeaveTown ( w , r , "stop-rest" )
}
// stopHeroRestOrLeaveTown implements unified “stop resting / leave town” for admin (one semantic; two routes for compatibility).
func ( h * AdminHandler ) stopHeroRestOrLeaveTown ( w http . ResponseWriter , r * http . Request , logLabel string ) {
heroID , err := parseHeroID ( r )
if err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
@ -2236,10 +2220,13 @@ func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
} )
return
}
if h . isHeroInCombat ( w , heroID ) {
return
}
hero , err := h . store . GetByID ( r . Context ( ) , heroID )
if err != nil {
h . logger . Error ( "admin: get hero for stop-rest" , "hero_id" , heroID , "error" , err )
h . logger . Error ( "admin: get hero for "+ logLabel , "hero_id" , heroID , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to load hero" } )
return
}
@ -2248,33 +2235,50 @@ func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
return
}
if h . engine != nil {
if hm := h . engine . GetMovements ( heroID ) ; hm != nil {
out , ok := h . engine . ApplyAdminStop Rest( heroID )
out , ok := h . engine . ApplyAdminStop Any Rest( heroID )
if ! ok || out == nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "hero is not in roadside/adventure rest" } )
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "hero is not in a rest or town state that can be stopped" ,
} )
return
}
out . RefreshDerivedCombatStats ( time . Now ( ) )
if err := h . store . Save ( r . Context ( ) , out ) ; err != nil {
h . logger . Error ( "admin: save after stop-rest" , "hero_id" , heroID , "error" , err )
h . logger . Error ( "admin: save after "+ logLabel , "hero_id" , heroID , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to save hero" } )
return
}
h . logger . Info ( "admin: stop rest" , "hero_id" , heroID )
h . logger . Info ( "admin: " + logLabel + " (online)" , "hero_id" , heroID )
if logLabel == "leave-town" {
writeJSON ( w , http . StatusOK , out )
return
}
h . writeAdminHeroDetail ( w , out )
return
}
}
hero2 , err := h . adminMovementOffline ( r . Context ( ) , hero , func ( hm * game . HeroMovement , rg * game . RoadGraph , now time . Time ) error {
if ! hm . AdminStopRest ( now ) {
return fmt . Errorf ( "hero is not in roadside/adventure rest" )
if hm . AdminStopRest ( now ) {
return nil
}
if hm . State == model . StateResting || hm . State == model . StateInTown {
hm . LeaveTown ( rg , now )
return nil
}
return fmt . Errorf ( "hero is not in a rest or town state that can be stopped" )
} )
if err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : err . Error ( ) } )
return
}
h . logger . Info ( "admin: stop rest (offline)" , "hero_id" , heroID )
h . logger . Info ( "admin: " + logLabel + " (offline)" , "hero_id" , heroID )
if logLabel == "leave-town" {
writeJSON ( w , http . StatusOK , hero2 )
return
}
h . writeAdminHeroDetail ( w , hero2 )
}
@ -2413,6 +2417,42 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req
h . writeAdminHeroDetail ( w , hm . Hero )
}
// KillCurrentEnemy applies a lethal hero hit and completes combat like a normal victory (rewards, combat_end, persist).
// Requires active engine combat (same as random encounter). POST /admin/heroes/{heroId}/kill-current-enemy
func ( h * AdminHandler ) KillCurrentEnemy ( w http . ResponseWriter , r * http . Request ) {
heroID , err := parseHeroID ( r )
if err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid heroId: " + err . Error ( ) } )
return
}
if h . engine == nil {
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "engine not available" } )
return
}
if _ , active := h . engine . GetCombat ( heroID ) ; ! active {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "hero is not in combat" } )
return
}
if h . engine . GetMovements ( heroID ) == nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "hero has no active engine session — connect the game client (WebSocket)" ,
} )
return
}
out , ok := h . engine . ApplyAdminLethalEnemyKill ( heroID )
if ! ok || out == nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "cannot kill current enemy" } )
return
}
if err := h . store . Save ( r . Context ( ) , out ) ; err != nil {
h . logger . Error ( "admin: save after kill-current-enemy" , "hero_id" , heroID , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to save hero" } )
return
}
h . logger . Info ( "admin: kill current enemy" , "hero_id" , heroID )
h . writeAdminHeroDetail ( w , out )
}
// StopHeroExcursion forces the excursion into the return leg (walk back to road / start point).
// POST /admin/heroes/{heroId}/stop-adventure
func ( h * AdminHandler ) StopHeroExcursion ( w http . ResponseWriter , r * http . Request ) {
@ -2555,10 +2595,10 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
var enemy model . Enemy
if req . EnemyLevel > 0 {
enemy = game . BuildEnemyInstanceForLevel ( tmpl , req . EnemyLevel )
enemy = game . BuildEnemyInstanceForLevel ( tmpl , req . EnemyLevel , nil )
} else {
// Same level roll as live encounters (variance + hero band), not "enemy level = hero level".
enemy = game . BuildEnemyInstanceForEncounter ( tmpl , baseHero .Level , nil )
enemy = game . BuildEnemyInstanceForEncounter ( tmpl , baseHero , nil )
}
game . ApplyEnemyEncounterHeroScaling ( baseHero , & enemy )
combatStart := game . CombatSimDeterministicStart