@ -24,7 +24,7 @@ const heroSelectQuery = `
h . id , h . telegram_id , h . name ,
h . hp , h . max_hp , h . attack , h . defense , h . speed ,
h . strength , h . constitution , h . agility , h . luck ,
h . state , h . weapon_id , h . armor_id ,
h . state ,
h . gold , h . xp , h . level ,
h . revive_count , h . subscription_active , h . subscription_expires_at ,
h . buff_free_charges_remaining , h . buff_quota_period_end , h . buff_charges ,
@ -32,6 +32,7 @@ const heroSelectQuery = `
h . total_kills , h . elite_kills , h . total_deaths , h . kills_since_death , h . legendary_drops ,
h . current_town_id , h . destination_town_id , h . move_state , h . town_pause ,
h . last_online_at , h . changelog_ack_version ,
h . ws_disconnected_at ,
h . created_at , h . updated_at
FROM heroes h
`
@ -71,7 +72,7 @@ func (s *HeroStore) GearStore() *GearStore {
return s . gearStore
}
// GetByTelegramID loads a hero by Telegram user ID , including weapon and armor via LEFT JOIN .
// GetByTelegramID loads a hero by Telegram user ID .
// Returns (nil, nil) if no hero is found.
func ( s * HeroStore ) GetByTelegramID ( ctx context . Context , telegramID int64 ) ( * model . Hero , error ) {
query := heroSelectQuery + ` WHERE h.telegram_id = $1 `
@ -236,7 +237,7 @@ func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error {
return nil
}
// GetByID loads a hero by its primary key , including weapon and armor .
// GetByID loads a hero by its primary key .
// Returns (nil, nil) if not found.
func ( s * HeroStore ) GetByID ( ctx context . Context , id int64 ) ( * model . Hero , error ) {
query := heroSelectQuery + ` WHERE h.id = $1 `
@ -259,14 +260,9 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error)
}
// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
// Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables.
func ( s * HeroStore ) insertNewHeroRow ( ctx context . Context , hero * model . Hero ) error {
now := time . Now ( )
var weaponID int64 = 1
var armorID int64 = 1
hero . WeaponID = & weaponID
hero . ArmorID = & armorID
hero . CreatedAt = now
hero . UpdatedAt = now
@ -281,7 +277,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
telegram_id , name ,
hp , max_hp , attack , defense , speed ,
strength , constitution , agility , luck ,
state , weapon_id , armor_id ,
state ,
gold , xp , level ,
revive_count , subscription_active , subscription_expires_at ,
buff_free_charges_remaining , buff_quota_period_end , buff_charges ,
@ -294,15 +290,15 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
$ 1 , $ 2 ,
$ 3 , $ 4 , $ 5 , $ 6 , $ 7 ,
$ 8 , $ 9 , $ 10 , $ 11 ,
$ 12 , $ 13 , $ 14 ,
$ 1 5, $ 16 , $ 17 ,
$ 1 8, $ 19 , $ 20 ,
$ 21, $ 22 , $ 23 ,
$ 2 4, $ 25 , $ 26 ,
$ 2 7, $ 28 , $ 29 , $ 30 , $ 31 ,
$ 3 2 ,
$ 3 3, $ 34 ,
$ 3 5, $ 36 , $ 37
$ 12 ,
$ 1 3, $ 14 , $ 15 ,
$ 1 6, $ 17 , $ 18 ,
$ 19, $ 20 , $ 21 ,
$ 2 2, $ 23 , $ 24 ,
$ 2 5, $ 26 , $ 2 7, $ 28 , $ 29 ,
$ 3 0 ,
$ 3 1, $ 32 ,
$ 3 3, $ 34 , $ 35
) RETURNING id
`
@ -310,7 +306,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero . TelegramID , hero . Name ,
hero . HP , hero . MaxHP , hero . Attack , hero . Defense , hero . Speed ,
hero . Strength , hero . Constitution , hero . Agility , hero . Luck ,
string ( hero . State ) , hero . WeaponID , hero . ArmorID ,
string ( hero . State ) ,
hero . Gold , hero . XP , hero . Level ,
hero . ReviveCount , hero . SubscriptionActive , hero . SubscriptionExpiresAt ,
hero . BuffFreeChargesRemaining , hero . BuffQuotaPeriodEnd , buffChargesJSON ,
@ -565,20 +561,20 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hp = $ 1 , max_hp = $ 2 ,
attack = $ 3 , defense = $ 4 , speed = $ 5 ,
strength = $ 6 , constitution = $ 7 , agility = $ 8 , luck = $ 9 ,
state = $ 10 , weapon_id = $ 11 , armor_id = $ 12 ,
gold = $ 1 3, xp = $ 14 , level = $ 15 ,
revive_count = $ 1 6, subscription_active = $ 17 , subscription_expires_at = $ 18 ,
buff_free_charges_remaining = $ 1 9, buff_quota_period_end = $ 20 , buff_charges = $ 2 1,
position_x = $ 2 2, position_y = $ 23 , potions = $ 24 ,
total_kills = $ 2 5, elite_kills = $ 26 , total_deaths = $ 27 ,
kills_since_death = $ 2 8, legendary_drops = $ 29 ,
last_online_at = $ 30 ,
updated_at = $ 31 ,
destination_town_id = $ 3 2 ,
current_town_id = $ 3 3 ,
move_state = $ 3 4 ,
town_pause = $ 3 5
WHERE id = $ 3 6
state = $ 10 ,
gold = $ 1 1, xp = $ 12 , level = $ 13 ,
revive_count = $ 1 4, subscription_active = $ 15 , subscription_expires_at = $ 16 ,
buff_free_charges_remaining = $ 1 7, buff_quota_period_end = $ 18 , buff_charges = $ 19 ,
position_x = $ 2 0, position_y = $ 21 , potions = $ 22 ,
total_kills = $ 2 3, elite_kills = $ 24 , total_deaths = $ 25 ,
kills_since_death = $ 2 6, legendary_drops = $ 27 ,
last_online_at = $ 28 ,
updated_at = $ 29 ,
destination_town_id = $ 3 0 ,
current_town_id = $ 3 1 ,
move_state = $ 3 2 ,
town_pause = $ 3 3
WHERE id = $ 3 4
`
townPauseJSON := marshalTownPause ( hero . TownPause )
@ -586,7 +582,7 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hero . HP , hero . MaxHP ,
hero . Attack , hero . Defense , hero . Speed ,
hero . Strength , hero . Constitution , hero . Agility , hero . Luck ,
string ( hero . State ) , hero . WeaponID , hero . ArmorID ,
string ( hero . State ) ,
hero . Gold , hero . XP , hero . Level ,
hero . ReviveCount , hero . SubscriptionActive , hero . SubscriptionExpiresAt ,
hero . BuffFreeChargesRemaining , hero . BuffQuotaPeriodEnd , buffChargesJSON ,
@ -647,6 +643,24 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
return nil
}
// SetWsDisconnectedAt records when the player's last WebSocket session ended.
func ( s * HeroStore ) SetWsDisconnectedAt ( ctx context . Context , heroID int64 , t time . Time ) error {
_ , err := s . pool . Exec ( ctx , ` UPDATE heroes SET ws_disconnected_at = $1, updated_at = now() WHERE id = $2 ` , t , heroID )
if err != nil {
return fmt . Errorf ( "set ws_disconnected_at: %w" , err )
}
return nil
}
// ClearWsDisconnectedAt clears the offline marker after the client has synced (e.g. hero/init).
func ( s * HeroStore ) ClearWsDisconnectedAt ( ctx context . Context , heroID int64 ) error {
_ , err := s . pool . Exec ( ctx , ` UPDATE heroes SET ws_disconnected_at = NULL, updated_at = now() WHERE id = $1 ` , heroID )
if err != nil {
return fmt . Errorf ( "clear ws_disconnected_at: %w" , err )
}
return nil
}
// ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map,
// or heroes resting / in town whose DB row has not been updated recently (offline town timers).
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
@ -711,7 +725,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
& h . ID , & h . TelegramID , & h . Name ,
& h . HP , & h . MaxHP , & h . Attack , & h . Defense , & h . Speed ,
& h . Strength , & h . Constitution , & h . Agility , & h . Luck ,
& state , & h . WeaponID , & h . ArmorID ,
& state ,
& h . Gold , & h . XP , & h . Level ,
& h . ReviveCount , & h . SubscriptionActive , & h . SubscriptionExpiresAt ,
& h . BuffFreeChargesRemaining , & h . BuffQuotaPeriodEnd , & buffChargesRaw ,
@ -719,6 +733,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
& h . TotalKills , & h . EliteKills , & h . TotalDeaths , & h . KillsSinceDeath , & h . LegendaryDrops ,
& h . CurrentTownID , & h . DestinationTownID , & h . MoveState , & townPauseRaw ,
& h . LastOnlineAt , & h . ChangelogAckVersion ,
& h . WsDisconnectedAt ,
& h . CreatedAt , & h . UpdatedAt ,
)
if err != nil {
@ -745,7 +760,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
& h . ID , & h . TelegramID , & h . Name ,
& h . HP , & h . MaxHP , & h . Attack , & h . Defense , & h . Speed ,
& h . Strength , & h . Constitution , & h . Agility , & h . Luck ,
& state , & h . WeaponID , & h . ArmorID ,
& state ,
& h . Gold , & h . XP , & h . Level ,
& h . ReviveCount , & h . SubscriptionActive , & h . SubscriptionExpiresAt ,
& h . BuffFreeChargesRemaining , & h . BuffQuotaPeriodEnd , & buffChargesRaw ,
@ -753,6 +768,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
& h . TotalKills , & h . EliteKills , & h . TotalDeaths , & h . KillsSinceDeath , & h . LegendaryDrops ,
& h . CurrentTownID , & h . DestinationTownID , & h . MoveState , & townPauseRaw ,
& h . LastOnlineAt , & h . ChangelogAckVersion ,
& h . WsDisconnectedAt ,
& h . CreatedAt , & h . UpdatedAt ,
)
if err != nil {