Compare commits

..

33 Commits

Author SHA1 Message Date
Denis Ranneft 220418c4c6 missing tests and sql 1 month ago
Denis Ranneft 988ac55d92 admin update 1 month ago
Denis Ranneft f5213d05c7 quests update 1 month ago
Denis Ranneft 8d2cb97614 admin update 1 month ago
Denis Ranneft 81b22db006 admin update 1 month ago
Denis Ranneft 409cad4031 admin update 1 month ago
Denis Ranneft 907c192577 admin update 1 month ago
Denis Ranneft 94d8a0cda8 fix regen 1 month ago
Denis Ranneft 2d336bfdcd combat ui reworked 1 month ago
Denis Ranneft dc5fc9b82e debuffs 1 month ago
Denis Ranneft 7f3b04b424 another combat rework 1 month ago
Denis Ranneft c58bd80f58 fix monsters in town 1 month ago
Denis Ranneft 39ed3382fc fix reset on reconnect in town 1 month ago
Denis Ranneft 1487031748 quests reworked 1 month ago
Denis Ranneft 41e246b2f1 update npc interaction 1 month ago
Denis Ranneft bd1a636086 update shop costs 1 month ago
Denis Ranneft b11b9bc437 fix town npc moves 1 month ago
Denis Ranneft 0a72101c8a fix healer 1 month ago
Denis Ranneft dd1d09e87d new town logic 1 month ago
Denis Ranneft 406cfab102 towns update 1 month ago
Denis Ranneft 9f2a7d6cd7 rest fixes 1 month ago
Denis Ranneft 8f6feaa6b2 adjustments for the rest and encounters in return phase 1 month ago
Denis Ranneft cbab3dbe3b buffs fix + adventure update 1 month ago
Denis Ranneft cae397a7d8 admin fixes 1 month ago
Denis Ranneft b5544c04f4 new admin rest methods + art bible 1 month ago
Denis Ranneft d2d7cc88ab new admin rest methods 1 month ago
Denis Ranneft ca9aff89f3 roadside rest 1 month ago
Denis Ranneft 97d29f7c2a new fields, session state, base movement and advance ticks 1 month ago
Denis Ranneft 61d617154f remove and add some stuff 1 month ago
Denis Ranneft 08111d846e remove and add some stuff 1 month ago
Denis Ranneft 3907eacb30 other graph stuff 1 month ago
Denis Ranneft 016cb41263 refactor graph 1 month ago
Denis Ranneft 8ecaf3895a fix 2 months ago

@ -0,0 +1,89 @@
# =========================
# Node / TypeScript
# =========================
node_modules/
.pnpm-store/
.npm/
.yarn/
.yarn-cache/
.yarn/unplugged/
# Build outputs
dist/
build/
out/
.next/
.nuxt/
.svelte-kit/
coverage/
*.tsbuildinfo
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
# Env files
.env
.env.*
!.env.example
# =========================
# Go
# =========================
# Binaries
bin/
*.exe
*.out
*.test
# Build artifacts
build/
dist/
# Go workspace / modules
go.work.sum
# Vendor (optional — include if large)
vendor/
# Coverage
coverage.out
*.coverprofile
# =========================
# General / Shared
# =========================
# OS
.DS_Store
Thumbs.db
# Editors / IDE
.vscode/
.idea/
*.swp
*.swo
# Cache / temp
.cache/
tmp/
temp/
*.tmp
*.temp
# Docker
*.pid
*.seed
# Archives / large files
*.zip
*.tar
*.gz
*.rar
*.7z
# Misc
*.bak
*.log

@ -0,0 +1,9 @@
FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because it is too large Load Diff

@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri /index.html;
}
location /admin-api/ {
proxy_pass http://backend:8080/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin-ws/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

@ -64,6 +64,7 @@ func main() {
// Stores (created before hub callbacks which reference them).
heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool)
questStore := storage.NewQuestStore(pgPool)
runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool)
if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil {
logger.Error("failed to load runtime config", "error", err)
@ -103,6 +104,8 @@ func main() {
engine.SetSender(hub) // Hub implements game.MessageSender
engine.SetRoadGraph(roadGraph)
engine.SetHeroStore(heroStore)
engine.SetTownSessionStore(storage.NewTownSessionStore(redisClient))
engine.SetQuestStore(questStore)
engine.SetAdventureLog(func(heroID int64, msg string) {
logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@ -172,7 +175,7 @@ func main() {
// Record server start time for catch-up gap calculation.
serverStartedAt := time.Now()
offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger, func() bool {
offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool {
return engine.IsTimePaused()
}, engine.HeroHasActiveMovement)
go func() {

Binary file not shown.

@ -0,0 +1,32 @@
package main
import (
"fmt"
"math"
)
func main() {
const n = 12
a := 1400.0
b := 4200.0
theta0 := 0.38
dtheta := 0.52
pts := make([]struct{ x, y, r float64 }, n)
for i := 0; i < n; i++ {
th := theta0 + float64(i)*dtheta
r := a + b*th
pts[i].x = r * math.Cos(th)
pts[i].y = r * math.Sin(th)
pts[i].r = r
fmt.Printf("%d: %d, %d (r=%.0f)\n", i, int(math.Round(pts[i].x)), int(math.Round(pts[i].y)), r)
}
fmt.Println("--- distances ---")
var sum float64
for i := 0; i < n; i++ {
j := (i + 1) % n
d := math.Hypot(pts[j].x-pts[i].x, pts[j].y-pts[i].y)
sum += d
fmt.Printf("%d->%d: %.0f\n", i, j, d)
}
fmt.Println("avg:", sum/float64(n))
}

@ -8,18 +8,38 @@ import (
"github.com/denisovdennis/autohero/internal/tuning"
)
const (
attackOutcomeHit = "hit"
attackOutcomeDodge = "dodge"
attackOutcomeBlock = "block"
attackOutcomeStun = "stun"
)
type DamageBreakdown struct {
RawDamage int
FinalDamage int
IsCrit bool
}
// CalculateDamage computes the final damage dealt from attacker stats to a defender,
// applying defense and critical hits.
func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) {
atk := float64(baseAttack)
breakdown := calculateDamageBreakdown(baseAttack, defense, critChance)
return breakdown.FinalDamage, breakdown.IsCrit
}
func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) DamageBreakdown {
atk := float64(baseAttack) * damageRollMultiplier()
// Defense reduces damage (simple formula: damage = atk - def, min 1).
dmg := atk - float64(defense)
if dmg < 1 {
dmg = 1
}
raw := int(dmg)
// Critical hit check.
isCrit := false
if critChance > 0 && rand.Float64() < critChance {
dmg *= 2
isCrit = true
@ -30,11 +50,31 @@ func CalculateDamage(baseAttack int, defense int, critChance float64) (damage in
dmg = 1
}
return int(dmg), isCrit
return DamageBreakdown{
RawDamage: raw,
FinalDamage: int(dmg),
IsCrit: isCrit,
}
}
func damageRollMultiplier() float64 {
cfg := tuning.Get()
minRoll := cfg.CombatDamageRollMin
maxRoll := cfg.CombatDamageRollMax
if minRoll <= 0 || maxRoll <= 0 {
return 1.0
}
if maxRoll < minRoll {
minRoll, maxRoll = maxRoll, minRoll
}
if maxRoll == minRoll {
return maxRoll
}
return minRoll + rand.Float64()*(maxRoll-minRoll)
}
// CalculateIncomingDamage applies shield buff reduction to incoming damage.
func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, now time.Time) int {
// CalculateIncomingDamage applies shield buff and weaken debuff reduction to incoming damage.
func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int {
dmg := float64(rawDamage)
for _, ab := range buffs {
@ -45,6 +85,14 @@ func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, now time.T
dmg *= (1 - ab.Buff.Magnitude)
}
}
for _, ad := range debuffs {
if ad.IsExpired(now) {
continue
}
if ad.Debuff.Type == model.DebuffWeaken {
dmg *= (1 - ad.Debuff.Magnitude)
}
}
if dmg < 1 {
dmg = 1
@ -62,16 +110,14 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID,
Damage: 0,
Source: "hero",
Outcome: attackOutcomeStun,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
}
}
critChance := 0.0
if weapon := hero.Gear[model.SlotMainHand]; weapon != nil {
critChance = weapon.CritChance
}
critChance := heroCritChance(hero, now)
// Check enemy dodge ability.
if enemy.HasAbility(model.AbilityDodge) {
@ -81,6 +127,7 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID,
Damage: 0,
Source: "hero",
Outcome: attackOutcomeDodge,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
@ -99,6 +146,7 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
HeroID: hero.ID,
Damage: dmg,
Source: "hero",
Outcome: attackOutcomeHit,
IsCrit: isCrit,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
@ -133,6 +181,7 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
if enemy.HasAbility(model.AbilityCritical) && critChance < tuning.Get().EnemyCriticalMinChance {
critChance = tuning.Get().EnemyCriticalMinChance
}
critChance = capChance(critChance, tuning.Get().EnemyCritChanceCap)
rawDmg, isCrit := CalculateDamage(enemy.Attack, hero.EffectiveDefenseAt(now), critChance)
@ -142,7 +191,20 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
rawDmg = int(float64(rawDmg) * burstMult)
}
dmg := CalculateIncomingDamage(rawDmg, hero.Buffs, now)
if rand.Float64() < hero.EffectiveBlockChance(now) {
return model.CombatEvent{
Type: "attack",
HeroID: hero.ID,
Damage: 0,
Source: "enemy",
Outcome: attackOutcomeBlock,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
}
}
dmg := CalculateIncomingDamage(rawDmg, hero.Buffs, hero.Debuffs, now)
hero.HP -= dmg
if hero.HP < 0 {
@ -156,6 +218,7 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
HeroID: hero.ID,
Damage: dmg,
Source: "enemy",
Outcome: attackOutcomeHit,
IsCrit: isCrit,
DebuffApplied: debuffApplied,
HeroHP: hero.HP,
@ -164,6 +227,25 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
}
}
func heroCritChance(hero *model.Hero, now time.Time) float64 {
_ = now
critChance := 0.0
if weapon := hero.Gear[model.SlotMainHand]; weapon != nil {
critChance = weapon.CritChance
}
return capChance(critChance, tuning.Get().HeroCritChanceCap)
}
func capChance(chance float64, cap float64) float64 {
if cap > 0 && chance > cap {
return cap
}
if chance < 0 {
return 0
}
return chance
}
// tryApplyDebuff checks enemy abilities and rolls to apply debuffs to the hero.
// Returns the debuff type string if one was applied, empty string otherwise.
func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string {
@ -268,8 +350,8 @@ func ProcessDebuffDamage(hero *model.Hero, tickDuration time.Duration, now time.
}
// ProcessEnemyRegen handles HP regeneration for enemies with the regen ability.
// Should be called each combat tick.
func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration) int {
// Should be called each combat tick. Uses remainder to avoid per-tick rounding spikes.
func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration, remainder *float64) int {
if !enemy.HasAbility(model.AbilityRegen) {
return 0
}
@ -286,16 +368,27 @@ func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration) int {
regenRate = cfg.EnemyRegenBattleLizard
}
healed := int(float64(enemy.MaxHP) * regenRate * tickDuration.Seconds())
if healed < 1 {
healed = 1
healFloat := float64(enemy.MaxHP) * regenRate * tickDuration.Seconds()
if remainder != nil {
healFloat += *remainder
}
healed := int(healFloat)
if remainder != nil {
*remainder = healFloat - float64(healed)
}
if healed <= 0 {
return 0
}
before := enemy.HP
enemy.HP += healed
if enemy.HP > enemy.MaxHP {
enemy.HP = enemy.MaxHP
}
return healed
if enemy.HP <= before {
return 0
}
return enemy.HP - before
}
// CheckDeath checks if the hero is dead and attempts resurrection if a buff is active.
@ -378,7 +471,7 @@ func HasLuckBuff(hero *model.Hero, now time.Time) bool {
return false
}
// LuckMultiplier returns the loot multiplier from the Luck buff (x2.5 per spec §7.1).
// LuckMultiplier returns the loot multiplier when the Luck buff is active (tuning.LuckBuffMultiplier).
func LuckMultiplier(hero *model.Hero, now time.Time) float64 {
if HasLuckBuff(hero, now) {
return tuning.Get().LuckBuffMultiplier

@ -1,10 +1,12 @@
package game
import (
"math/rand"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) {
@ -158,8 +160,8 @@ func TestLuckMultiplierWithBuff(t *testing.T) {
}
mult := LuckMultiplier(hero, now)
if mult != 2.5 {
t.Fatalf("expected luck multiplier 2.5, got %.1f", mult)
if mult != 1.75 {
t.Fatalf("expected luck multiplier 1.75, got %.2f", mult)
}
}
@ -221,6 +223,67 @@ func TestDodgeAbilityCanAvoidDamage(t *testing.T) {
}
}
func TestCritChanceCapsApply(t *testing.T) {
orig := tuning.Get()
t.Cleanup(func() {
tuning.Set(orig)
})
cfg := tuning.DefaultValues()
cfg.HeroCritChanceCap = 0.10
cfg.EnemyCritChanceCap = 0.20
cfg.EnemyCriticalMinChance = 0.0
cfg.HeroBlockChancePerDefense = 0.0
cfg.HeroBlockChanceCap = 0.0
tuning.Set(cfg)
hero := &model.Hero{
ID: 1, HP: 100, MaxHP: 100,
Attack: 20, Defense: 0, Speed: 1.0,
Strength: 5, Agility: 5,
Gear: map[model.EquipmentSlot]*model.GearItem{
model.SlotMainHand: {CritChance: 0.9, StatType: "attack"},
},
}
enemy := &model.Enemy{
MaxHP: 100,
HP: 100,
Attack: 10,
Defense: 0,
Speed: 1.0,
}
rand.Seed(1)
heroEvt := ProcessAttack(hero, enemy, time.Now())
if heroEvt.IsCrit {
t.Fatalf("expected hero crit to be capped off, got crit")
}
rand.Seed(1)
enemy.CritChance = 0.9
enemyEvt := ProcessEnemyAttack(hero, enemy, time.Now())
if enemyEvt.IsCrit {
t.Fatalf("expected enemy crit to be capped off, got crit")
}
}
func TestDamageRollAppliesRange(t *testing.T) {
orig := tuning.Get()
t.Cleanup(func() {
tuning.Set(orig)
})
cfg := tuning.DefaultValues()
cfg.CombatDamageScale = 1.0
cfg.CombatDamageRollMin = 0.5
cfg.CombatDamageRollMax = 0.5
tuning.Set(cfg)
rand.Seed(1)
breakdown := calculateDamageBreakdown(10, 0, 0)
if breakdown.RawDamage != 5 || breakdown.FinalDamage != 5 {
t.Fatalf("expected roll to halve damage to 5, got raw=%d final=%d", breakdown.RawDamage, breakdown.FinalDamage)
}
}
func mustBuffDef(bt model.BuffType) model.Buff {
b, ok := model.BuffDefinition(bt)
if !ok {

@ -61,6 +61,8 @@ type Engine struct {
roadGraph *RoadGraph
sender MessageSender
heroStore *storage.HeroStore
townSession *storage.TownSessionStore
questStore *storage.QuestStore
incomingCh chan IncomingMessage // client commands
mu sync.RWMutex
eventCh chan model.CombatEvent
@ -223,6 +225,20 @@ func (e *Engine) SetHeroStore(hs *storage.HeroStore) {
e.heroStore = hs
}
// SetTownSessionStore sets the Redis-backed mirror for in-town NPC tour state (reconnect recovery).
func (e *Engine) SetTownSessionStore(ts *storage.TownSessionStore) {
e.mu.Lock()
defer e.mu.Unlock()
e.townSession = ts
}
// SetQuestStore sets the quest store used for visit_town progress on town arrival.
func (e *Engine) SetQuestStore(qs *storage.QuestStore) {
e.mu.Lock()
defer e.mu.Unlock()
e.questStore = qs
}
// SetOnEnemyDeath registers a callback for enemy death events (e.g. loot generation).
func (e *Engine) SetOnEnemyDeath(cb EnemyDeathCallback) {
e.mu.Lock()
@ -335,15 +351,45 @@ func (e *Engine) handleActivateBuff(msg IncomingMessage) {
return
}
buffType := model.BuffType(payload.BuffType)
bt, ok := model.ValidBuffType(payload.BuffType)
if !ok {
e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType))
return
}
hero := hm.Hero
now := time.Now()
ab := ApplyBuff(hm.Hero, buffType, now)
hero.RefreshSubscription(now)
hero.EnsureBuffChargesPopulated(now)
if err := hero.ConsumeBuffCharge(bt, now); err != nil {
e.sendError(msg.HeroID, "buff_quota_exhausted", err.Error())
return
}
ab := ApplyBuff(hero, bt, now)
if ab == nil {
hero.RefundBuffCharge(bt)
e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType))
return
}
hero.RefreshDerivedCombatStats(now)
hm.refreshSpeed(now)
if cs, ok := e.combats[msg.HeroID]; ok {
cs.Hero = hero
}
if e.heroStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil {
e.logger.Error("failed to save hero after buff", "hero_id", hero.ID, "error", err)
}
}
if e.sender != nil {
e.sender.SendToHero(msg.HeroID, "hero_state", hero)
e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{
BuffType: payload.BuffType,
Duration: ab.Buff.Duration.Seconds(),
@ -388,6 +434,25 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
hero.HP = hero.MaxHP
}
hm.SyncToHero()
// Keep combat state's hero pointer aligned with movement (authoritative live hero).
if cs, ok := e.combats[msg.HeroID]; ok {
cs.Hero = hm.Hero
}
if e.heroStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil {
e.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)
}
}
if e.adventureLog != nil {
e.adventureLog(msg.HeroID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))
}
// Emit as an attack-like event so the client shows it.
cs, hasCombat := e.combats[msg.HeroID]
enemyHP := 0
@ -402,6 +467,9 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
HeroHP: hero.HP,
EnemyHP: enemyHP,
})
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now())
e.sender.SendToHero(msg.HeroID, "hero_state", hero)
}
}
@ -491,6 +559,11 @@ func (e *Engine) sendError(heroID int64, code, message string) {
// RegisterHeroMovement creates a HeroMovement for an online hero and sends initial state.
// Called when a WS client connects.
func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
if hero == nil {
return
}
e.mergeTownSessionFromRedis(hero)
e.mu.Lock()
defer e.mu.Unlock()
@ -528,6 +601,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
hm := NewHeroMovement(hero, e.roadGraph, now)
e.movements[hero.ID] = hm
hm.MarkTownPausePersisted(hm.townPausePersistSignature())
hm.SyncToHero()
e.logger.Info("hero movement registered",
@ -583,6 +657,7 @@ func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) {
"hero_id", heroID,
"last_connection", lastConnection,
)
e.syncTownSessionRedisFromHero(heroID, heroSnap)
}
}
}
@ -605,29 +680,6 @@ func (e *Engine) Status() EngineStatus {
}
}
// ApplyAdminStartAdventure forces an off-road adventure for an online hero (walking on a road).
func (e *Engine) ApplyAdminStartAdventure(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.StartAdventureForced(now) {
return nil, false
}
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))
}
return h, true
}
// ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival).
func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) {
e.mu.Lock()
@ -649,10 +701,8 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero
e.sender.SendToHero(heroID, "hero_state", h)
town := e.roadGraph.Towns[hm.CurrentTownID]
if town != nil {
npcInfos := make([]model.TownNPCInfo, 0, len(e.roadGraph.TownNPCs[hm.CurrentTownID]))
for _, n := range e.roadGraph.TownNPCs[hm.CurrentTownID] {
npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type})
}
npcInfos := e.roadGraph.TownNPCInfos(hm.CurrentTownID)
buildingInfos := e.roadGraph.TownBuildingInfos(hm.CurrentTownID)
var restMs int64
if hm.State == model.StateResting {
restMs = hm.RestUntil.Sub(now).Milliseconds()
@ -662,45 +712,51 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero
TownName: town.Name,
Biome: town.Biome,
NPCs: npcInfos,
Buildings: buildingInfos,
RestDurationMs: restMs,
})
}
}
e.applyVisitTownQuestProgress(h)
return h, true
}
// ApplyAdminStartRoadsideRest forces roadside rest (walking + road required). Saves and notifies WS.
func (e *Engine) ApplyAdminStartRoadsideRest(heroID int64) (*model.Hero, bool) {
// ApplyAdminForceLeaveTown ends resting or in-town pause, assigns a new road, and notifies the client.
func (e *Engine) ApplyAdminForceLeaveTown(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.AdminStartRoadsideRest(now) {
if hm.State != model.StateResting && hm.State != model.StateInTown {
return nil, false
}
now := time.Now()
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, "hero_move", hm.MovePayload(now))
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 start roadside rest", "hero_id", h.ID, "error", err)
e.logger.Error("persist hero after force leave town", "hero_id", h.ID, "error", err)
}
}
return h, true
}
// ApplyAdminStopRoadsideRest ends roadside rest if active. Returns the hero snapshot; endedRest is false if already not resting (not an error).
func (e *Engine) ApplyAdminStopRoadsideRest(heroID int64) (h *model.Hero, endedRest bool) {
// ApplyAdminStartRest puts an online hero into town-style rest at the current location.
func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
@ -708,75 +764,104 @@ func (e *Engine) ApplyAdminStopRoadsideRest(heroID int64) (h *model.Hero, endedR
return nil, false
}
now := time.Now()
h = hm.Hero
if !hm.roadsideRestInProgress() {
if !hm.AdminStartRest(now, e.roadGraph) {
return nil, false
}
hm.SyncToHero()
h := hm.Hero
if e.sender != nil {
h.EnsureGearMap()
h.RefreshDerivedCombatStats(now)
return h, false
e.sender.SendToHero(heroID, "hero_state", h)
}
return h, true
}
// ApplyAdminStartRoadsideRest puts an online hero into roadside rest at the current road position.
func (e *Engine) ApplyAdminStartRoadsideRest(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
if !ok {
return nil, false
}
now := time.Now()
if !hm.AdminStartRoadsideRest(now) {
return nil, false
}
hm.endRoadsideRest()
hm.SyncToHero()
h = hm.Hero
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 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 stop roadside rest", "hero_id", h.ID, "error", err)
}
}
return h, true
}
// ApplyAdminForceLeaveTown ends resting or in-town pause, assigns a new road, and notifies the client.
func (e *Engine) ApplyAdminForceLeaveTown(heroID int64) (*model.Hero, bool) {
// ApplyAdminStopRest exits a hero from non-town rest (roadside / adventure-inline) back to walking.
func (e *Engine) ApplyAdminStopRest(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
if !ok || e.roadGraph == nil {
if !ok {
return nil, false
}
if hm.State != model.StateResting && hm.State != model.StateInTown {
now := time.Now()
if !hm.AdminStopRest(now) {
return nil, false
}
now := time.Now()
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{})
e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
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 force leave town", "hero_id", h.ID, "error", err)
}
return h, true
}
// ApplyAdminStartExcursion forces an online hero into a mini-adventure session on the current road.
func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
if !ok {
return nil, false
}
now := time.Now()
if !hm.AdminStartExcursion(now) {
return nil, false
}
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, "excursion_start", model.ExcursionStartPayload{
DepthWorldUnits: hm.Excursion.DepthWorldUnits,
})
e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
return h, true
}
// ApplyAdminStartRest puts an online hero into town-style rest at the current location.
func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
// ApplyAdminStopExcursion ends an online hero's excursion immediately.
func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
if !ok || e.roadGraph == nil {
if !ok {
return nil, false
}
now := time.Now()
if !hm.AdminStartRest(now, e.roadGraph) {
if !hm.AdminStopExcursion(now) {
return nil, false
}
hm.SyncToHero()
@ -784,7 +869,12 @@ func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
if e.sender != nil {
h.EnsureGearMap()
h.RefreshDerivedCombatStats(now)
e.sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
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
}
@ -828,6 +918,19 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
return
}
}
if e.roadGraph != nil {
var wx, wy float64
if hm, ok := e.movements[hero.ID]; ok && hm.Hero != nil {
ox, oy := hm.displayOffset(now)
wx, wy = hm.CurrentX+ox, hm.CurrentY+oy
} else if hero != nil {
wx, wy = hero.PositionX, hero.PositionY
}
if e.roadGraph.HeroInTownAt(wx, wy) {
e.logger.Debug("skip combat start: hero inside town radius", "hero_id", hero.ID)
return
}
}
cs := &model.CombatState{
HeroID: hero.ID,
@ -845,6 +948,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
// Update movement state.
if hm, ok := e.movements[hero.ID]; ok {
hm.StartFighting()
hm.SyncToHero()
}
heap.Push(&e.queue, &model.AttackEvent{
@ -932,7 +1036,7 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) {
routeAssigned := false
if e.roadGraph != nil && hm.State == model.StateWalking && hm.Road == nil {
hm.pickDestination(e.roadGraph)
hm.assignRoad(e.roadGraph)
hm.assignRoad(e.roadGraph, false)
routeAssigned = true
}
@ -1007,7 +1111,7 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
routeAssigned := false
if hm.State == model.StateWalking && hm.Road == nil && e.roadGraph != nil {
hm.pickDestination(e.roadGraph)
hm.assignRoad(e.roadGraph)
hm.assignRoad(e.roadGraph, false)
routeAssigned = true
}
@ -1066,9 +1170,15 @@ func (e *Engine) processCombatTick(now time.Time) {
}
ProcessDebuffDamage(cs.Hero, tickDur, now)
ProcessEnemyRegen(&cs.Enemy, tickDur)
regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder)
ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
cs.LastTickAt = now
if regenHealed > 0 && e.sender != nil {
e.sender.SendToHero(heroID, "enemy_regen", model.EnemyRegenPayload{
Amount: regenHealed,
EnemyHP: cs.Enemy.HP,
})
}
if CheckDeath(cs.Hero, now) {
e.emitEvent(model.CombatEvent{
@ -1121,6 +1231,7 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now)
e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
@ -1128,10 +1239,23 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
Source: combatEvt.Source,
Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit,
Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied,
})
if combatEvt.DebuffApplied != "" {
if dt, ok := model.ValidDebuffType(combatEvt.DebuffApplied); ok {
if def, ok := model.DebuffDefinition(dt); ok {
e.sender.SendToHero(cs.HeroID, "debuff_applied", model.DebuffAppliedPayload{
DebuffType: string(dt),
DurationMs: def.Duration.Milliseconds(),
Magnitude: def.Magnitude,
ExpiresAt: now.Add(def.Duration),
})
}
}
}
}
if !cs.Enemy.IsAlive() {
@ -1156,6 +1280,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now)
e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
@ -1163,6 +1288,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
Source: combatEvt.Source,
Damage: combatEvt.Damage,
IsCrit: combatEvt.IsCrit,
Outcome: combatEvt.Outcome,
HeroHP: combatEvt.HeroHP,
EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied,
@ -1205,6 +1331,53 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
})
}
func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
if e.adventureLog == nil || cs == nil {
return
}
enemyName := cs.Enemy.Name
critSuffix := ""
if evt.IsCrit {
critSuffix = " (crit)"
}
var msg string
switch evt.Source {
case "hero":
switch evt.Outcome {
case attackOutcomeStun:
msg = "You are stunned and cannot attack."
case attackOutcomeDodge:
msg = enemyName + " dodged your attack."
default:
msg = "You hit " + enemyName + " for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
case "enemy":
switch evt.Outcome {
case attackOutcomeBlock:
msg = "You block " + enemyName + "'s attack."
default:
msg = enemyName + " hits you for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
}
if evt.DebuffApplied != "" {
msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
}
if msg != "" {
e.adventureLog(cs.HeroID, msg)
}
}
func debuffDisplayName(debuffType string) string {
dt, ok := model.ValidDebuffType(debuffType)
if !ok {
return debuffType
}
if def, ok := model.DebuffDefinition(dt); ok && def.Name != "" {
return def.Name
}
return debuffType
}
func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
hero := cs.Hero
enemy := &cs.Enemy
@ -1226,6 +1399,25 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
leveledUp := hero.Level > oldLevel
delete(e.combats, cs.HeroID)
// Resume walking before hero_state so positions match hero_move (road + forest offset).
if hm, ok := e.movements[cs.HeroID]; ok {
hm.ResumeWalking(now)
hm.SyncToHero()
}
// Persist progression (XP, gold, level/stats after level-up, inventory, world state)
// so a disconnect or crash does not roll back combat rewards.
if e.heroStore != nil && hero != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := e.heroStore.Save(ctx, hero)
cancel()
if err != nil && e.logger != nil {
e.logger.Error("persist hero after combat victory", "hero_id", hero.ID, "error", err)
}
}
// Push typed combat_end envelope.
if e.sender != nil {
e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{
@ -1246,13 +1438,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
e.sender.SendToHero(cs.HeroID, "hero_state", hero)
}
delete(e.combats, cs.HeroID)
// Resume walking.
if hm, ok := e.movements[cs.HeroID]; ok {
hm.ResumeWalking(now)
}
e.logger.Info("enemy defeated",
"hero_id", cs.HeroID,
"enemy", enemy.Name,
@ -1274,7 +1459,96 @@ func (e *Engine) processMovementTick(now time.Time) {
}
for heroID, hm := range e.movements {
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter)
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil)
if e.heroStore == nil || hm == nil || hm.Hero == nil {
continue
}
if sig, ok := hm.TownPausePersistDue(); ok {
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 excursion/rest failed", "hero_id", heroID, "error", err)
}
continue
}
hm.MarkTownPausePersisted(sig)
e.syncTownSessionRedis(heroID, hm)
}
}
}
// mergeTownSessionFromRedis overlays a fresher in-town snapshot when Postgres row is stale (e.g. missed town_pause save).
func (e *Engine) mergeTownSessionFromRedis(hero *model.Hero) {
if e.townSession == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
snap, err := e.townSession.Load(ctx, hero.ID)
if err != nil {
if e.logger != nil {
e.logger.Warn("town session redis load failed", "hero_id", hero.ID, "error", err)
}
return
}
if snap == nil || snap.State != model.StateInTown || snap.TownPause == nil {
return
}
if snap.CurrentTownID > 0 && hero.CurrentTownID != nil && *hero.CurrentTownID != snap.CurrentTownID {
return
}
if snap.SavedAtUnixNano <= hero.UpdatedAt.UnixNano() {
return
}
hero.State = model.StateInTown
hero.MoveState = string(model.StateInTown)
hero.TownPause = snap.TownPause
hero.PositionX = snap.PositionX
hero.PositionY = snap.PositionY
if snap.CurrentTownID > 0 {
tid := snap.CurrentTownID
if hero.CurrentTownID == nil {
hero.CurrentTownID = new(int64)
}
*hero.CurrentTownID = tid
}
}
func (e *Engine) syncTownSessionRedis(heroID int64, hm *HeroMovement) {
if e.townSession == nil || hm == nil || hm.Hero == nil {
return
}
hm.SyncToHero()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if hm.State == model.StateInTown {
if err := e.townSession.Save(ctx, heroID, hm.Hero); err != nil && e.logger != nil {
e.logger.Warn("town session redis save failed", "hero_id", heroID, "error", err)
}
return
}
if err := e.townSession.Delete(ctx, heroID); err != nil && e.logger != nil {
e.logger.Warn("town session redis delete failed", "hero_id", heroID, "error", err)
}
}
func (e *Engine) syncTownSessionRedisFromHero(heroID int64, h *model.Hero) {
if e.townSession == nil || h == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if h.State == model.StateInTown {
if err := e.townSession.Save(ctx, heroID, h); err != nil && e.logger != nil {
e.logger.Warn("town session redis save failed", "hero_id", heroID, "error", err)
}
return
}
if err := e.townSession.Delete(ctx, heroID); err != nil && e.logger != nil {
e.logger.Warn("town session redis delete failed", "hero_id", heroID, "error", err)
}
}
@ -1287,22 +1561,60 @@ func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) {
defer cancel()
if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
e.logger.Error("persist hero after town enter", "hero_id", h.ID, "error", err)
return
}
e.syncTownSessionRedisFromHero(h.ID, h)
e.applyVisitTownQuestProgress(h)
}
// applyVisitTownQuestProgress advances visit_town quests when the hero is in a town (matches quests.target_town_id).
func (e *Engine) applyVisitTownQuestProgress(h *model.Hero) {
if e.questStore == nil || h == nil || h.CurrentTownID == nil || *h.CurrentTownID <= 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.questStore.IncrementVisitTownProgress(ctx, h.ID, *h.CurrentTownID); err != nil && e.logger != nil {
e.logger.Warn("visit town quest progress failed", "hero_id", h.ID, "town_id", *h.CurrentTownID, "error", err)
}
}
// processPositionSync sends drift-correction position_sync messages.
// Called at 0.1 Hz (every 10s).
// processPositionSync sends drift-correction position_sync messages and persists world (x,y).
// Called at low cadence (see tuning positionSyncRateMs).
func (e *Engine) processPositionSync(now time.Time) {
type posSnap struct {
id int64
x float64
y float64
}
var snaps []posSnap
e.mu.RLock()
defer e.mu.RUnlock()
sender := e.sender
for heroID, hm := range e.movements {
if hm == nil {
continue
}
if sender != nil && hm.State == model.StateWalking {
sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
}
shouldPersistPos := hm.State == model.StateWalking || hm.State == model.StateResting || hm.Excursion.Active()
if shouldPersistPos && hm.Hero != nil {
hm.SyncToHero()
snaps = append(snaps, posSnap{id: heroID, x: hm.Hero.PositionX, y: hm.Hero.PositionY})
}
}
heroStore := e.heroStore
e.mu.RUnlock()
if e.sender == nil {
if heroStore == nil || len(snaps) == 0 {
return
}
for heroID, hm := range e.movements {
if hm.State == model.StateWalking {
e.sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, p := range snaps {
if err := heroStore.SavePosition(ctx, p.id, p.x, p.y); err != nil && e.logger != nil {
e.logger.Error("position sync persist failed", "hero_id", p.id, "error", err)
}
}
}

@ -0,0 +1,178 @@
package game
import (
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// Phase 2 FSM: road spine freeze during excursion, HP-based exits, no rest while fighting.
func TestFSM_ExcursionFreezesRoadProgress(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.WaypointIndex = 0
hm.WaypointFraction = 0.5
from := hm.Road.Waypoints[0]
to := hm.Road.Waypoints[1]
hm.CurrentX = from.X + (to.X-from.X)*0.5
hm.CurrentY = from.Y + (to.Y-from.Y)*0.5
hm.LastMoveTick = now
hm.beginExcursion(now)
if hm.Excursion.RoadFreezeWaypoint != 0 || hm.Excursion.RoadFreezeFraction != 0.5 {
t.Fatalf("unexpected freeze snapshot: wp=%d frac=%v", hm.Excursion.RoadFreezeWaypoint, hm.Excursion.RoadFreezeFraction)
}
later := now.Add(30 * time.Second)
reached := hm.AdvanceTick(later, graph)
if reached {
t.Fatal("AdvanceTick should not reach town while excursion is active")
}
if hm.WaypointIndex != 0 || hm.WaypointFraction != 0.5 {
t.Fatalf("waypoint progress should stay frozen during excursion, got idx=%d frac=%v", hm.WaypointIndex, hm.WaypointFraction)
}
ps := hm.PositionSyncPayload(later)
if ps.WaypointIndex != 0 || ps.WaypointFraction != 0.5 {
t.Fatalf("PositionSync should reflect frozen road PB, got idx=%d frac=%v", ps.WaypointIndex, ps.WaypointFraction)
}
}
func TestFSM_NormalWalking_AdvanceTickMovesAlongRoad(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.WaypointIndex = 0
hm.WaypointFraction = 0.5
from := hm.Road.Waypoints[0]
to := hm.Road.Waypoints[1]
hm.CurrentX = from.X + (to.X-from.X)*0.5
hm.CurrentY = from.Y + (to.Y-from.Y)*0.5
hm.LastMoveTick = now
if hm.Excursion.Active() {
t.Fatal("excursion should not be active")
}
later := now.Add(5 * time.Second)
reached := hm.AdvanceTick(later, graph)
if reached {
t.Fatal("should not reach town from mid-segment in 5s")
}
if hm.WaypointIndex == 0 && hm.WaypointFraction == 0.5 {
t.Fatal("expected road progress to advance without active excursion")
}
}
func TestFSM_RoadsideRest_HPExit_ForcesReturnBeforeWildTimer(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
hero := testHeroOnRoad(1, int(float64(maxHP)*cfg.RoadsideRestExitHp), maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
origWildUntil := hm.Excursion.WildUntil
// Skip "out" leg: test HP exit from wild (campfire) phase.
hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.OutUntil = now.Add(-time.Second)
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase)
}
if !tick.Before(origWildUntil) {
t.Fatal("HP exit should force return before original WildUntil timer")
}
}
func TestFSM_AdventureInlineRest_HPExit_ExcursionStillActive(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
targetHP := int(float64(maxHP) * cfg.AdventureRestTargetHp)
hero := testHeroOnRoad(1, targetHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected back to walking after HP target, got %s", hm.State)
}
if !hm.Excursion.Active() {
t.Fatal("excursion session should continue after adventure-inline HP exit")
}
}
func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1
hero := testHeroOnRoad(1, lowHP, maxHP)
hero.State = model.StateFighting
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateFighting {
t.Fatalf("expected StateFighting unchanged, got %s", hm.State)
}
if hm.State == model.StateResting {
t.Fatal("must not enter rest while fighting")
}
}
func TestFSM_AdminStartRoadsideRest_RejectsFighting(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
hero.State = model.StateFighting
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateFighting
if hm.AdminStartRoadsideRest(now) {
t.Fatal("AdminStartRoadsideRest must reject fighting hero")
}
}
func TestFSM_AdminStartExcursion_RejectsFighting(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
hero.State = model.StateFighting
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateFighting
if hm.AdminStartExcursion(now) {
t.Fatal("AdminStartExcursion must reject fighting hero")
}
}
func TestFSM_AdminStopExcursion_RejectsFighting(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.State = model.StateFighting
hm.Hero.State = model.StateFighting
if hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion must reject fighting hero")
}
}

@ -0,0 +1,111 @@
package game
import (
"fmt"
"math"
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// TryAutoEquipInMemory equips the item if the slot is empty or the new item improves combat
// rating by at least the runtime-configured threshold. Mutates hero.Gear. Does not touch the database.
func TryAutoEquipInMemory(hero *model.Hero, item *model.GearItem, now time.Time) bool {
hero.EnsureGearMap()
current := hero.Gear[item.Slot]
if current == nil {
hero.Gear[item.Slot] = item
return true
}
oldRating := hero.CombatRatingAt(now)
hero.Gear[item.Slot] = item
if hero.CombatRatingAt(now) >= oldRating*tuning.Get().AutoEquipThreshold {
return true
}
hero.Gear[item.Slot] = current
return false
}
// TryEquipOrStashOffline runs TryAutoEquipInMemory; if not equipped, appends to inventory
// or invokes onDiscard with an adventure-log message when the backpack is full.
func TryEquipOrStashOffline(hero *model.Hero, item *model.GearItem, now time.Time, onDiscard func(string)) {
hero.EnsureInventorySlice()
if TryAutoEquipInMemory(hero, item, now) {
return
}
if len(hero.Inventory) >= model.MaxInventorySlots {
if onDiscard != nil {
onDiscard(fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
}
return
}
hero.Inventory = append(hero.Inventory, item)
}
// AutoSellRandomInventoryShare sells a random share of inventory items.
// At least minShare (0..1) of current inventory is sold; returns sold count and gold gained.
func AutoSellRandomInventoryShare(hero *model.Hero, minShare float64, rng *rand.Rand) (int, int64) {
if hero == nil {
return 0, 0
}
hero.EnsureInventorySlice()
n := len(hero.Inventory)
if n == 0 {
return 0, 0
}
if minShare < 0 {
minShare = 0
}
if minShare > 1 {
minShare = 1
}
minSell := int(math.Ceil(float64(n) * minShare))
if minSell < 1 {
minSell = 1
}
if minSell > n {
minSell = n
}
var soldCount int
if n == minSell {
soldCount = n
} else if rng == nil {
soldCount = minSell + rand.Intn(n-minSell+1)
} else {
soldCount = minSell + rng.Intn(n-minSell+1)
}
perm := make([]int, n)
for i := 0; i < n; i++ {
perm[i] = i
}
if rng == nil {
rand.Shuffle(n, func(i, j int) { perm[i], perm[j] = perm[j], perm[i] })
} else {
rng.Shuffle(n, func(i, j int) { perm[i], perm[j] = perm[j], perm[i] })
}
sold := make(map[int]struct{}, soldCount)
for i := 0; i < soldCount; i++ {
sold[perm[i]] = struct{}{}
}
kept := make([]*model.GearItem, 0, n-soldCount)
var goldGained int64
for i, item := range hero.Inventory {
if _, ok := sold[i]; ok {
if item != nil {
goldGained += model.AutoSellPrice(item.Rarity)
}
continue
}
kept = append(kept, item)
}
hero.Inventory = kept
hero.Gold += goldGained
return soldCount, goldGained
}

@ -0,0 +1,50 @@
package game
import (
"math"
"math/rand"
"testing"
"github.com/denisovdennis/autohero/internal/model"
)
func TestAutoSellRandomInventoryShare_SellsAtLeastThirtyPercent(t *testing.T) {
hero := &model.Hero{
Gold: 0,
Inventory: []*model.GearItem{
{Rarity: model.RarityCommon},
{Rarity: model.RarityUncommon},
{Rarity: model.RarityRare},
{Rarity: model.RarityEpic},
{Rarity: model.RarityLegendary},
{Rarity: model.RarityCommon},
{Rarity: model.RarityUncommon},
{Rarity: model.RarityRare},
{Rarity: model.RarityEpic},
{Rarity: model.RarityLegendary},
},
}
startN := len(hero.Inventory)
startGold := hero.Gold
rng := rand.New(rand.NewSource(7))
soldCount, goldGained := AutoSellRandomInventoryShare(hero, 0.30, rng)
minExpected := int(math.Ceil(float64(startN) * 0.30))
if soldCount < minExpected {
t.Fatalf("soldCount=%d, want >= %d", soldCount, minExpected)
}
if soldCount > startN {
t.Fatalf("soldCount=%d, inventory=%d", soldCount, startN)
}
if len(hero.Inventory) != startN-soldCount {
t.Fatalf("inventory len=%d, want %d", len(hero.Inventory), startN-soldCount)
}
if goldGained <= 0 {
t.Fatalf("goldGained=%d, want > 0", goldGained)
}
if hero.Gold != startGold+goldGained {
t.Fatalf("hero gold=%d, want %d", hero.Gold, startGold+goldGained)
}
}

File diff suppressed because it is too large Load Diff

@ -17,9 +17,10 @@ import (
// advancing movement the same way as the online engine (without WebSocket payloads)
// and resolving random encounters with SimulateOneFight.
type OfflineSimulator struct {
store *storage.HeroStore
logStore *storage.LogStore
graph *RoadGraph
store *storage.HeroStore
logStore *storage.LogStore
questStore *storage.QuestStore
graph *RoadGraph
interval time.Duration
logger *slog.Logger
// isPaused, when set, skips simulation ticks while global server time is frozen.
@ -32,10 +33,11 @@ type OfflineSimulator struct {
// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
// isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause).
// skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick.
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator {
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, questStore *storage.QuestStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator {
return &OfflineSimulator{
store: store,
logStore: logStore,
questStore: questStore,
graph: graph,
interval: 30 * time.Second,
logger: logger,
@ -148,6 +150,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
const maxOfflineMovementSteps = 200000
step := 0
offlineNPC := s.offlineTownNPCInteractHook(ctx)
for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps {
step++
next := hm.LastMoveTick.Add(movementTickRate())
@ -165,7 +168,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
adventureLog := func(heroID int64, msg string) {
s.addLog(ctx, heroID, msg)
}
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break
}
@ -184,6 +187,103 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
return nil
}
func (s *OfflineSimulator) offlineTownNPCInteractHook(ctx context.Context) TownNPCOfflineInteractHook {
return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
return s.applyOfflineTownNPCVisit(ctx, heroID, hm, graph, npc, now, al)
}
}
// applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI).
func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
_ = graph
_ = now
cfg := tuning.Get()
inter := cfg.TownNPCInteractChance
if inter <= 0 {
inter = tuning.DefaultValues().TownNPCInteractChance
}
if inter > 1 {
inter = 1
}
if rand.Float64() >= inter {
return false
}
h := hm.Hero
if h == nil {
return false
}
switch npc.Type {
case "merchant":
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
if soldItems > 0 && al != nil {
al(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
}
potionCost, _ := tuning.EffectiveNPCShopCosts()
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 {
h.Gold -= potionCost
h.Potions++
if al != nil {
al(heroID, fmt.Sprintf("Purchased a Healing Potion from %s.", npc.Name))
}
}
case "healer":
_, healCost := tuning.EffectiveNPCShopCosts()
if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost {
h.Gold -= healCost
h.HP = h.MaxHP
if al != nil {
al(heroID, fmt.Sprintf("Paid %s to restore full health.", npc.Name))
}
}
case "quest_giver":
if s.questStore == nil {
return true
}
hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
if err != nil {
s.logger.Warn("offline town npc: list hero quests", "error", err)
return true
}
taken := make(map[int64]struct{}, len(hqs))
for _, hq := range hqs {
taken[hq.QuestID] = struct{}{}
}
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, h.Level)
if err != nil {
s.logger.Warn("offline town npc: list quests by npc", "error", err)
return true
}
var candidates []model.Quest
for _, q := range offered {
if _, ok := taken[q.ID]; !ok {
candidates = append(candidates, q)
}
}
if len(candidates) == 0 {
if al != nil {
al(heroID, fmt.Sprintf("Checked in with %s — nothing new.", npc.Name))
}
return true
}
pick := candidates[rand.Intn(len(candidates))]
ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID)
if err != nil {
s.logger.Warn("offline town npc: try accept quest", "error", err)
return true
}
if ok && al != nil {
al(heroID, fmt.Sprintf("Accepted quest: %s", pick.Title))
}
default:
// Other NPC types: treat as a social stop only.
}
return true
}
// addLog is a fire-and-forget helper that writes an adventure log entry.
func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message string) {
logCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
@ -211,19 +311,48 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY)
heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense
if heroDmgPerHit < 1 {
heroDmgPerHit = 1
}
enemyDmgPerHit := enemy.Attack - hero.EffectiveDefenseAt(now)
if enemyDmgPerHit < 1 {
enemyDmgPerHit = 1
}
combatStart := now
lastTick := now
var regenRemainder float64
heroNext := now.Add(attackInterval(hero.EffectiveSpeedAt(now)))
enemyNext := now.Add(attackInterval(enemy.Speed))
const maxCombatSteps = 100000
for step := 0; step < maxCombatSteps && hero.IsAlive() && enemy.IsAlive(); step++ {
nextTime := heroNext
isHero := true
if enemyNext.Before(heroNext) {
nextTime = enemyNext
isHero = false
}
if !nextTime.After(lastTick) {
nextTime = lastTick.Add(time.Millisecond)
}
hitsToKill := (enemy.MaxHP + heroDmgPerHit - 1) / heroDmgPerHit
dmgTaken := enemyDmgPerHit * hitsToKill
tickDur := nextTime.Sub(lastTick)
if tickDur > 0 {
ProcessDebuffDamage(hero, tickDur, nextTime)
ProcessEnemyRegen(&enemy, tickDur, &regenRemainder)
ProcessSummonDamage(hero, &enemy, combatStart, lastTick, nextTime)
}
lastTick = nextTime
if CheckDeath(hero, nextTime) {
break
}
hero.HP -= dmgTaken
if isHero {
ProcessAttack(hero, &enemy, nextTime)
if !enemy.IsAlive() {
break
}
heroNext = nextTime.Add(attackInterval(hero.EffectiveSpeedAt(nextTime)))
} else {
ProcessEnemyAttack(hero, &enemy, nextTime)
if CheckDeath(hero, nextTime) {
break
}
enemyNext = nextTime.Add(attackInterval(enemy.Speed))
}
}
// Use potion if HP drops below 30% and hero has potions.
if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 {

@ -0,0 +1,602 @@
package game
import (
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// testGraph builds a minimal two-town road graph for movement tests.
func testGraph() *RoadGraph {
townA := &model.Town{ID: 1, Name: "TownA", WorldX: 0, WorldY: 0, Radius: 0.5}
townB := &model.Town{ID: 2, Name: "TownB", WorldX: 100, WorldY: 0, Radius: 0.5}
road := &Road{
ID: 1, FromTownID: 1, ToTownID: 2,
Waypoints: []Point{{0, 0}, {50, 0}, {100, 0}},
Distance: 100,
}
roadBack := &Road{
ID: 2, FromTownID: 2, ToTownID: 1,
Waypoints: []Point{{100, 0}, {50, 0}, {0, 0}},
Distance: 100,
}
return &RoadGraph{
Roads: map[int64]*Road{1: road, 2: roadBack},
TownRoads: map[int64][]*Road{1: {road}, 2: {roadBack}},
Towns: map[int64]*model.Town{1: townA, 2: townB},
TownOrder: []int64{1, 2},
TownNPCs: map[int64][]TownNPC{},
NPCByID: map[int64]TownNPC{},
TownBuildings: map[int64][]TownBuilding{},
}
}
func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero {
townID := int64(1)
destID := int64(2)
return &model.Hero{
ID: id, Level: 5,
MaxHP: maxHP, HP: hp,
Attack: 50, Defense: 30, Speed: 1.0,
Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
State: model.StateWalking,
CurrentTownID: &townID,
DestinationTownID: &destID,
PositionX: 50, PositionY: 0,
}
}
// --- Roadside rest ---
func TestRoadsideRest_TriggersOnLowHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
threshold := cfg.LowHpThreshold
maxHP := 1000
lowHP := int(float64(maxHP)*threshold) - 1
hero := testHeroOnRoad(1, lowHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {
t.Fatalf("expected StateResting, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected RestKindRoadside, got %s", hm.ActiveRestKind)
}
if hm.RestUntil.IsZero() {
t.Fatal("expected RestUntil to be set for roadside rest")
}
}
func TestRoadsideRest_DoesNotTriggerAboveThreshold(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
safeHP := int(float64(maxHP)*cfg.LowHpThreshold) + 10
hero := testHeroOnRoad(1, safeHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.LastEncounterAt = now
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
t.Fatal("should not trigger roadside rest above threshold")
}
}
func TestRoadsideRest_HealsHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 10000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Hero.HP <= hpBefore {
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
}
expectedGain := int(float64(maxHP) * cfg.RoadsideRestHpPerS * 10)
actualGain := hm.Hero.HP - hpBefore
if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
}
}
func TestRoadsideRest_ExitsByTimer(t *testing.T) {
graph := testGraph()
maxHP := 10000
hero := testHeroOnRoad(1, 1, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
pastTimer := hm.RestUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase)
}
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
}
}
func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
hero := testHeroOnRoad(1, int(float64(maxHP)*cfg.RoadsideRestExitHp), maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
// Tick past the Out phase so the hero is in Wild phase where HP threshold is checked.
tick := hm.Excursion.OutUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected excursion Return phase after HP threshold exit, got %s", hm.Excursion.Phase)
}
}
func TestRoadsideRest_DisplayOffset(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
// Check offset partway through the Out phase (smoothstep should be non-zero).
outMid := hm.Excursion.StartedAt.Add(hm.Excursion.OutUntil.Sub(hm.Excursion.StartedAt) / 2)
ox, oy := hm.displayOffset(outMid)
if ox == 0 && oy == 0 {
t.Fatal("expected non-zero display offset during roadside rest out phase")
}
}
// --- Adventure inline rest ---
func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1
hero := testHeroOnRoad(1, lowHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.beginExcursion(now)
tick := hm.Excursion.OutUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {
t.Fatalf("expected StateResting, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindAdventureInline {
t.Fatalf("expected RestKindAdventureInline, got %s", hm.ActiveRestKind)
}
if !hm.Excursion.Active() {
t.Fatal("excursion should remain active during inline rest")
}
}
func TestAdventureInlineRest_HealsHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 10000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Hero.HP <= hpBefore {
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
}
expectedGain := int(float64(maxHP) * cfg.AdventureRestHpPerS * 10)
actualGain := hm.Hero.HP - hpBefore
if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
}
}
func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
targetHP := int(float64(maxHP) * cfg.AdventureRestTargetHp)
hero := testHeroOnRoad(1, targetHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after HP target, got %s", hm.State)
}
if !hm.Excursion.Active() {
t.Fatal("excursion should still be active after inline rest exits by HP")
}
}
func TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) {
graph := testGraph()
maxHP := 10000
hero := testHeroOnRoad(1, 1, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after excursion end, got %s", hm.State)
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after return phase ended")
}
}
func TestAdventureInlineRest_NoTimerFieldSet(t *testing.T) {
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
graph := testGraph()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
if !hm.RestUntil.IsZero() {
t.Fatal("adventure inline rest should not set RestUntil (HP-based only)")
}
}
// --- Persistence ---
func TestRoadsideRest_Persistence(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hm.SyncToHero()
if hero.RestKind != model.RestKindRoadside {
t.Fatalf("expected hero.RestKind = roadside, got %s", hero.RestKind)
}
if hero.TownPause == nil {
t.Fatal("expected TownPause to be set")
}
if hero.TownPause.RestKind != model.RestKindRoadside {
t.Fatalf("expected TownPause.RestKind = roadside, got %s", hero.TownPause.RestKind)
}
if hero.TownPause.RestUntil == nil || hero.TownPause.RestUntil.IsZero() {
t.Fatal("expected TownPause.RestUntil to be set for roadside rest")
}
hero2 := *hero
hm2 := NewHeroMovement(&hero2, graph, now)
if hm2.State != model.StateResting {
t.Fatalf("expected restored state = resting, got %s", hm2.State)
}
if hm2.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected restored rest kind = roadside, got %s", hm2.ActiveRestKind)
}
if hm2.RestUntil.IsZero() {
t.Fatal("expected restored RestUntil to be non-zero")
}
if hm2.Road == nil {
t.Fatal("expected road to be assigned for roadside rest reconnect")
}
}
func TestAdventureInlineRest_Persistence(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
hm.SyncToHero()
if hero.RestKind != model.RestKindAdventureInline {
t.Fatalf("expected hero.RestKind = adventure_inline, got %s", hero.RestKind)
}
if hero.TownPause == nil {
t.Fatal("expected TownPause to be set")
}
if hero.TownPause.RestKind != model.RestKindAdventureInline {
t.Fatalf("expected TownPause.RestKind = adventure_inline, got %s", hero.TownPause.RestKind)
}
if hero.TownPause.Excursion == nil {
t.Fatal("expected TownPause.Excursion to be set")
}
hero2 := *hero
hm2 := NewHeroMovement(&hero2, graph, now)
if hm2.State != model.StateResting {
t.Fatalf("expected restored state = resting, got %s", hm2.State)
}
if hm2.ActiveRestKind != model.RestKindAdventureInline {
t.Fatalf("expected restored rest kind = adventure_inline, got %s", hm2.ActiveRestKind)
}
if !hm2.Excursion.Active() {
t.Fatal("expected excursion to be restored")
}
if hm2.Road == nil {
t.Fatal("expected road to be assigned for adventure inline rest reconnect")
}
}
// --- Admin ---
func TestAdminStartRoadsideRest(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if !hm.AdminStartRoadsideRest(now) {
t.Fatal("AdminStartRoadsideRest should succeed")
}
if hm.State != model.StateResting {
t.Fatalf("expected resting, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected roadside, got %s", hm.ActiveRestKind)
}
}
func TestAdminStartRoadsideRest_RejectsDead(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 0, 1000)
hero.State = model.StateDead
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.AdminStartRoadsideRest(now) {
t.Fatal("AdminStartRoadsideRest should reject dead hero")
}
}
func TestAdminStopRest_Roadside(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
if !hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should succeed for roadside rest")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindNone {
t.Fatalf("expected rest kind none, got %s", hm.ActiveRestKind)
}
}
func TestAdminStopRest_AdventureInline(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
if !hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should succeed for adventure inline rest")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after admin stop rest")
}
}
func TestAdminStopRest_RejectsTownRest(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.AdminStartRest(now, graph)
if hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should reject town rest")
}
}
func TestAdminStopRest_RejectsWalking(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should reject walking hero")
}
}
func TestAdminStartExcursion(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if !hm.AdminStartExcursion(now) {
t.Fatal("AdminStartExcursion should succeed")
}
if !hm.Excursion.Active() {
t.Fatal("excursion should be active")
}
if hm.Excursion.Phase != model.ExcursionOut {
t.Fatalf("expected phase out, got %s", hm.Excursion.Phase)
}
}
func TestAdminStartExcursion_RejectsWhenAlreadyActive(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
if hm.AdminStartExcursion(now) {
t.Fatal("AdminStartExcursion should reject when excursion already active")
}
}
func TestAdminStartExcursion_RejectsNotWalking(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
if hm.AdminStartExcursion(now) {
t.Fatal("AdminStartExcursion should reject when not walking")
}
}
func TestAdminStopExcursion_WhileWalking(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed")
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
}
func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed from inline rest")
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
}
func TestAdminStopExcursion_RejectsNone(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should reject when no excursion")
}
}
// --- FSM: road freeze + no rest in combat ---
// TestExcursion_FreezesRoadWaypointDuringSession asserts AdvanceTick does not advance the spine
// while an excursion is active (waypoint index/fraction stay at freeze snapshot).
func TestExcursion_FreezesRoadWaypointDuringSession(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.beginExcursion(now)
freezeIdx := hm.Excursion.RoadFreezeWaypoint
freezeFr := hm.Excursion.RoadFreezeFraction
// Mid wild phase: several movement ticks should not move along the road polyline.
wildMid := hm.Excursion.OutUntil.Add(hm.Excursion.WildUntil.Sub(hm.Excursion.OutUntil) / 2)
for i := 0; i < 5; i++ {
tick := wildMid.Add(time.Duration(i) * time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase == model.ExcursionNone {
t.Fatalf("excursion ended unexpectedly at tick %v", tick)
}
if hm.WaypointIndex != freezeIdx || hm.WaypointFraction != freezeFr {
t.Fatalf("road spine should stay frozen: want idx=%d fr=%v, got idx=%d fr=%v",
freezeIdx, freezeFr, hm.WaypointIndex, hm.WaypointFraction)
}
}
}
// TestLowHP_DoesNotStartRestWhileFighting ensures ProcessSingleHeroMovementTick does not
// transition to roadside or inline rest when the hero is in combat state.
func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1
hero := testHeroOnRoad(1, lowHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateFighting
hm.Hero.State = model.StateFighting
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateFighting {
t.Fatalf("expected StateFighting unchanged, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindNone {
t.Fatalf("expected no rest kind, got %s", hm.ActiveRestKind)
}
}

@ -28,31 +28,48 @@ type Road struct {
// TownNPC is a quest/shop NPC placed in a town (from npcs table).
type TownNPC struct {
ID int64
Name string
Type string
ID int64
Name string
Type string
BuildingID *int64
OffsetX float64
OffsetY float64
}
// TownBuilding is a building placed in a town (from town_buildings table).
type TownBuilding struct {
ID int64
TownID int64
BuildingType string
OffsetX float64
OffsetY float64
Facing string
FootprintW float64
FootprintH float64
}
// RoadGraph is an immutable in-memory graph of all roads and towns,
// loaded once at startup.
type RoadGraph struct {
Roads map[int64]*Road // road ID -> road
TownRoads map[int64][]*Road // town ID -> outgoing roads
Towns map[int64]*model.Town // town ID -> town
TownOrder []int64 // ordered town IDs for sequential traversal
TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order)
NPCByID map[int64]TownNPC // NPC id -> row
Roads map[int64]*Road // road ID -> road
TownRoads map[int64][]*Road // town ID -> outgoing roads
Towns map[int64]*model.Town // town ID -> town
TownOrder []int64 // ordered town IDs for sequential traversal
TownNPCs map[int64][]TownNPC // town ID -> NPCs (stable order)
NPCByID map[int64]TownNPC // NPC id -> row
TownBuildings map[int64][]TownBuilding // town ID -> buildings
}
// LoadRoadGraph reads roads and towns from the database, generates waypoints
// deterministically, and returns an immutable RoadGraph.
func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) {
g := &RoadGraph{
Roads: make(map[int64]*Road),
TownRoads: make(map[int64][]*Road),
Towns: make(map[int64]*model.Town),
TownNPCs: make(map[int64][]TownNPC),
NPCByID: make(map[int64]TownNPC),
Roads: make(map[int64]*Road),
TownRoads: make(map[int64][]*Road),
Towns: make(map[int64]*model.Town),
TownNPCs: make(map[int64][]TownNPC),
NPCByID: make(map[int64]TownNPC),
TownBuildings: make(map[int64][]TownBuilding),
}
// Load towns.
@ -74,7 +91,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return nil, fmt.Errorf("iterate towns: %w", err)
}
npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type FROM npcs ORDER BY town_id, id`)
npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id, COALESCE(offset_x,0), COALESCE(offset_y,0) FROM npcs ORDER BY town_id, id`)
if err != nil {
return nil, fmt.Errorf("load npcs: %w", err)
}
@ -82,7 +99,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
for npcRows.Next() {
var n TownNPC
var townID int64
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type); err != nil {
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil {
return nil, fmt.Errorf("scan npc: %w", err)
}
g.NPCByID[n.ID] = n
@ -92,6 +109,23 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return nil, fmt.Errorf("iterate npcs: %w", err)
}
// Load buildings.
buildingRows, err := pool.Query(ctx, `SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h FROM town_buildings ORDER BY town_id, id`)
if err != nil {
return nil, fmt.Errorf("load buildings: %w", err)
}
defer buildingRows.Close()
for buildingRows.Next() {
var b TownBuilding
if err := buildingRows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
return nil, fmt.Errorf("scan building: %w", err)
}
g.TownBuildings[b.TownID] = append(g.TownBuildings[b.TownID], b)
}
if err := buildingRows.Err(); err != nil {
return nil, fmt.Errorf("iterate buildings: %w", err)
}
// Load roads.
roadRows, err := pool.Query(ctx, `SELECT id, from_town_id, to_town_id, distance FROM roads`)
if err != nil {
@ -126,6 +160,62 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return g, nil
}
// TownBuildingInfos returns building payloads for the given town, with absolute world coordinates.
func (g *RoadGraph) TownBuildingInfos(townID int64) []model.TownBuildingInfo {
town := g.Towns[townID]
if town == nil {
return nil
}
buildings := g.TownBuildings[townID]
infos := make([]model.TownBuildingInfo, 0, len(buildings))
for _, b := range buildings {
infos = append(infos, model.TownBuildingInfo{
ID: b.ID,
BuildingType: b.BuildingType,
WorldX: town.WorldX + b.OffsetX,
WorldY: town.WorldY + b.OffsetY,
Facing: b.Facing,
FootprintW: b.FootprintW,
FootprintH: b.FootprintH,
})
}
return infos
}
// TownNPCInfos returns NPC payloads for the given town, including building IDs and world positions.
func (g *RoadGraph) TownNPCInfos(townID int64) []model.TownNPCInfo {
town := g.Towns[townID]
npcs := g.TownNPCs[townID]
infos := make([]model.TownNPCInfo, 0, len(npcs))
for _, n := range npcs {
info := model.TownNPCInfo{
ID: n.ID,
Name: n.Name,
Type: n.Type,
BuildingID: n.BuildingID,
}
if town != nil {
info.WorldX = town.WorldX + n.OffsetX
info.WorldY = town.WorldY + n.OffsetY
}
infos = append(infos, info)
}
return infos
}
// NPCWorldPos returns the absolute world position of an NPC using its town center + offset.
func (g *RoadGraph) NPCWorldPos(npcID, townID int64) (worldX, worldY float64, ok bool) {
npc, found := g.NPCByID[npcID]
if !found {
return 0, 0, false
}
town := g.Towns[townID]
if town == nil {
return 0, 0, false
}
return town.WorldX + npc.OffsetX, town.WorldY + npc.OffsetY, true
}
// TownNPCIDs returns NPC ids for a town in stable DB order (for visit queues).
func (g *RoadGraph) TownNPCIDs(townID int64) []int64 {
list := g.TownNPCs[townID]

@ -2,6 +2,7 @@ package handler
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@ -33,10 +34,12 @@ type AdminHandler struct {
hub *Hub
pool *pgxpool.Pool
logger *slog.Logger
adminUser string
adminPass string
}
// NewAdminHandler creates a new AdminHandler with all required dependencies.
func NewAdminHandler(store *storage.HeroStore, gearStore *storage.GearStore, questStore *storage.QuestStore, engine *game.Engine, hub *Hub, pool *pgxpool.Pool, logger *slog.Logger) *AdminHandler {
func NewAdminHandler(store *storage.HeroStore, gearStore *storage.GearStore, questStore *storage.QuestStore, engine *game.Engine, hub *Hub, pool *pgxpool.Pool, logger *slog.Logger, adminUser, adminPass string) *AdminHandler {
return &AdminHandler{
store: store,
gearStore: gearStore,
@ -45,6 +48,8 @@ func NewAdminHandler(store *storage.HeroStore, gearStore *storage.GearStore, que
hub: hub,
pool: pool,
logger: logger,
adminUser: adminUser,
adminPass: adminPass,
}
}
@ -62,6 +67,128 @@ type heroSummary struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct {
Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
CurrentTownID int64 `json:"currentTownId,omitempty"`
DestinationTownID int64 `json:"destinationTownId,omitempty"`
WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
ExcursionPhase string `json:"excursionPhase,omitempty"`
ExcursionWildUntil *time.Time `json:"excursionWildUntil,omitempty"`
ExcursionReturnUntil *time.Time `json:"excursionReturnUntil,omitempty"`
}
// adminHeroDetailResponse is the full admin JSON for one hero: base hero + persisted town_pause + live movement snapshot.
type adminHeroDetailResponse struct {
model.Hero
TownPause *model.TownPausePersisted `json:"townPause,omitempty"`
AdminLiveMovement *adminLiveMovementJSON `json:"adminLiveMovement,omitempty"`
HeroMovement *game.HeroMovement `json:"heroMovement,omitempty"`
}
// 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"`
}
func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
if hm == nil {
return nil
}
s := &adminLiveMovementJSON{
Online: true,
MoveState: string(hm.State),
}
if !hm.RestUntil.IsZero() {
t := hm.RestUntil
s.RestUntil = &t
}
if !hm.TownLeaveAt.IsZero() {
t := hm.TownLeaveAt
s.TownLeaveAt = &t
}
if !hm.NextTownNPCRollAt.IsZero() {
t := hm.NextTownNPCRollAt
s.NextTownNPCRollAt = &t
}
if hm.CurrentTownID != 0 {
s.CurrentTownID = hm.CurrentTownID
}
if hm.DestinationTownID != 0 {
s.DestinationTownID = hm.DestinationTownID
}
if !hm.WanderingMerchantDeadline.IsZero() {
t := hm.WanderingMerchantDeadline
s.WanderingMerchantDeadline = &t
}
if hm.Excursion.Active() {
s.ExcursionPhase = string(hm.Excursion.Phase)
if !hm.Excursion.WildUntil.IsZero() {
t := hm.Excursion.WildUntil
s.ExcursionWildUntil = &t
}
if !hm.Excursion.ReturnUntil.IsZero() {
t := hm.Excursion.ReturnUntil
s.ExcursionReturnUntil = &t
}
}
return s
}
func (h *AdminHandler) writeAdminHeroDetail(w http.ResponseWriter, hero *model.Hero) {
out, err := h.buildAdminHeroDetail(hero)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, out)
}
func (h *AdminHandler) buildAdminHeroDetail(hero *model.Hero) (adminHeroDetailResponse, error) {
if hero == nil {
return adminHeroDetailResponse{}, fmt.Errorf("nil hero")
}
now := time.Now()
hero.RefreshDerivedCombatStats(now)
out := adminHeroDetailResponse{Hero: *hero, TownPause: hero.TownPause}
if hm := h.engine.GetMovements(hero.ID); hm != nil && hm.Hero != nil {
out.Hero = *hm.Hero
out.Hero.RefreshDerivedCombatStats(now)
out.TownPause = hm.Hero.TownPause
out.AdminLiveMovement = buildAdminLiveMovementSnap(hm)
out.HeroMovement = hm
} else if rg := h.engine.RoadGraph(); rg != nil {
out.HeroMovement = game.NewHeroMovement(hero, rg, now)
}
return out, nil
}
func (h *AdminHandler) buildAdminWSSnapshot(ctx context.Context, heroID int64) (adminWSSnapshot, error) {
hero, err := h.store.GetByID(ctx, heroID)
if err != nil {
return adminWSSnapshot{}, err
}
if hero == nil {
return adminWSSnapshot{}, fmt.Errorf("hero not found")
}
detail, err := h.buildAdminHeroDetail(hero)
if err != nil {
return adminWSSnapshot{}, err
}
now := time.Now()
var move *model.HeroMovePayload
if hm := h.engine.GetMovements(heroID); hm != nil {
p := hm.MovePayload(now)
move = &p
}
return adminWSSnapshot{Hero: detail, HeroMove: move}, nil
}
// ListHeroes returns a paginated list of all heroes.
// GET /admin/heroes?limit=20&offset=0
func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) {
@ -447,7 +574,7 @@ func (h *AdminHandler) ClaimHeroQuest(w http.ResponseWriter, r *http.Request) {
}
// AbandonHeroQuest removes quest from hero log.
// DELETE /admin/heroes/{heroId}/quests/{questId}
// DELETE /admin/heroes/{heroId}/quests/{questId} — questId is hero_quests.id (log row).
func (h *AdminHandler) AbandonHeroQuest(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
@ -749,13 +876,7 @@ func (h *AdminHandler) GetHero(w http.ResponseWriter, r *http.Request) {
return
}
hero.RefreshDerivedCombatStats(time.Now())
// Prefer live movement hero when online; otherwise return DB hero (GetMovements is nil offline).
if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil {
writeJSON(w, http.StatusOK, hm.Hero)
return
}
writeJSON(w, http.StatusOK, hero)
h.writeAdminHeroDetail(w, hero)
}
type setLevelRequest struct {
@ -1278,79 +1399,6 @@ func (h *AdminHandler) ListTowns(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"towns": out})
}
// StartHeroAdventure forces off-road adventure for a hero (online or offline).
// POST /admin/heroes/{heroId}/start-adventure
func (h *AdminHandler) StartHeroAdventure(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 start-adventure", "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
}
var hm = h.engine.GetMovements(heroID)
hero = hm.Hero
if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "hero must be alive and not in combat",
"hero": hero,
})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStartAdventure(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "cannot start adventure (hero must be walking on a road)",
})
return
}
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: start adventure", "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.StartAdventureForced(now) {
return fmt.Errorf("cannot start adventure (hero must be walking on a road)")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.logger.Info("admin: start adventure (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2)
}
// TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest).
// POST /admin/heroes/{heroId}/teleport-town
func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) {
@ -1483,9 +1531,8 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
})
return
}
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: start rest", "hero_id", heroID)
writeJSON(w, http.StatusOK, out)
h.writeAdminHeroDetail(w, out)
return
}
@ -1500,7 +1547,7 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
return
}
h.logger.Info("admin: start rest (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2)
h.writeAdminHeroDetail(w, hero2)
}
// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
@ -1567,8 +1614,9 @@ func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, hero2)
}
// StartRoadsideRest forces roadside rest (must be walking on a road). POST /admin/heroes/{heroId}/start-roadside-rest
func (h *AdminHandler) StartRoadsideRest(w http.ResponseWriter, r *http.Request) {
// StartHeroRoadsideRest forces a hero into roadside rest at the current road position.
// POST /admin/heroes/{heroId}/start-roadside-rest
func (h *AdminHandler) StartHeroRoadsideRest(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
@ -1583,41 +1631,37 @@ func (h *AdminHandler) StartRoadsideRest(w http.ResponseWriter, r *http.Request)
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for start-roadside-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
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",
})
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "hero must be alive and not in combat",
})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be alive and not in combat"})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStartRoadsideRest(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "cannot start roadside rest (hero must be walking with an assigned road)",
})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start roadside rest"})
return
}
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after start-roadside-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: start roadside rest", "hero_id", heroID)
writeJSON(w, http.StatusOK, out)
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.AdminStartRoadsideRest(now) {
return fmt.Errorf("cannot start roadside rest (hero must be walking with an assigned road)")
return fmt.Errorf("cannot start roadside rest")
}
return nil
})
@ -1625,13 +1669,13 @@ func (h *AdminHandler) StartRoadsideRest(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
hero2.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: start roadside rest (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2)
h.writeAdminHeroDetail(w, hero2)
}
// StopRoadsideRest ends roadside rest if active; if already not resting, returns current hero (200). POST /admin/heroes/{heroId}/stop-rest
func (h *AdminHandler) StopRoadsideRest(w http.ResponseWriter, r *http.Request) {
// StopHeroRest exits a hero from non-town rest (roadside or adventure-inline) back to walking.
// POST /admin/heroes/{heroId}/stop-rest
func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
@ -1639,92 +1683,114 @@ func (h *AdminHandler) StopRoadsideRest(w http.ResponseWriter, r *http.Request)
})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
hero, heroErr := h.store.GetByID(r.Context(), heroID)
if heroErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
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)
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",
})
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
isRoadsideRest := hero.State == model.StateResting &&
hero.TownPause != nil &&
(hero.TownPause.RestKind == "roadside" || hero.TownPause.RoadsideRestActive)
if (hero.State == model.StateResting && !isRoadsideRest) || hero.State == model.StateInTown {
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 stop town rest (movement state changed?)",
})
return
}
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop rest (town, online)", "hero_id", heroID)
writeJSON(w, http.StatusOK, out)
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStopRest(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in roadside/adventure rest"})
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(),
})
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after stop-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
hero2.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop rest (town, offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2)
h.logger.Info("admin: stop rest", "hero_id", heroID)
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")
}
return nil
})
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.writeAdminHeroDetail(w, hero2)
}
// StartHeroExcursion forces a walking hero on a road into a mini-adventure (excursion) session.
// POST /admin/heroes/{heroId}/start-adventure
func (h *AdminHandler) StartHeroExcursion(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 start-adventure", "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.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be alive and not in combat"})
return
}
if hero.State != model.StateWalking {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be walking on the road to start an excursion"})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, _ := h.engine.ApplyAdminStopRoadsideRest(heroID)
if out == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "movement session unavailable",
})
out, ok := h.engine.ApplyAdminStartExcursion(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start excursion (need active road segment, or excursion already active)"})
return
}
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop roadside rest", "hero_id", heroID)
writeJSON(w, http.StatusOK, out)
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: start excursion", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
_ = rg
_ = now
hm.EndRoadsideRest()
if !hm.AdminStartExcursion(now) {
return fmt.Errorf("cannot start excursion (need active road segment, or excursion already active)")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
hero2.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop roadside rest (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2)
h.logger.Info("admin: start excursion (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
}
// StopHeroRoadsideRest ends only roadside pull-over rest (live movement session).
// Does not end town / inn rest (use stop-rest or leave-town). POST /admin/heroes/{heroId}/stop-roadside-rest
func (h *AdminHandler) StopHeroRoadsideRest(w http.ResponseWriter, r *http.Request) {
// StopHeroExcursion ends the hero's mini-adventure session immediately.
// POST /admin/heroes/{heroId}/stop-adventure
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
@ -1736,47 +1802,45 @@ func (h *AdminHandler) StopHeroRoadsideRest(w http.ResponseWriter, r *http.Reque
return
}
hero, heroErr := h.store.GetByID(r.Context(), heroID)
if heroErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for stop-adventure", "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",
})
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if hm := h.engine.GetMovements(heroID); hm != nil {
out, _ := h.engine.ApplyAdminStopRoadsideRest(heroID)
if out == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "movement session unavailable",
})
out, ok := h.engine.ApplyAdminStopExcursion(heroID)
if !ok || out == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero has no active excursion"})
return
}
out.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop roadside rest only", "hero_id", heroID)
writeJSON(w, http.StatusOK, out)
if err := h.store.Save(r.Context(), out); err != nil {
h.logger.Error("admin: save after stop-adventure", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: stop excursion", "hero_id", heroID)
h.writeAdminHeroDetail(w, out)
return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
_ = rg
_ = now
hm.EndRoadsideRest()
if !hm.AdminStopExcursion(now) {
return fmt.Errorf("hero has no active excursion")
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
hero2.RefreshDerivedCombatStats(time.Now())
h.logger.Info("admin: stop roadside rest only (offline)", "hero_id", heroID)
writeJSON(w, http.StatusOK, hero2)
h.logger.Info("admin: stop excursion (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
}
// PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls.
@ -1835,6 +1899,71 @@ func (h *AdminHandler) WSConnections(w http.ResponseWriter, r *http.Request) {
})
}
// AdminHeroSnapshotWS streams hero detail + movement snapshot for admin UI.
// GET /admin-ws/hero/{heroId}?auth=BASE64(user:pass)
func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Request) {
if !h.adminWSAuthorized(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="Admin", charset="UTF-8"`)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId"})
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
h.logger.Error("admin ws upgrade failed", "error", err)
return
}
defer conn.Close()
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
sendSnapshot := func() error {
snap, err := h.buildAdminWSSnapshot(r.Context(), heroID)
if err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(writeWait))
return conn.WriteJSON(snap)
}
if err := sendSnapshot(); err != nil {
_ = conn.WriteJSON(map[string]string{"error": err.Error()})
return
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-r.Context().Done():
return
case <-ticker.C:
// Align admin live stream with engine: no periodic snapshots while global time is paused.
if h.engine != nil && h.engine.IsTimePaused() {
continue
}
if err := sendSnapshot(); err != nil {
_ = conn.WriteJSON(map[string]string{"error": err.Error()})
return
}
}
}
}
// ── Server Info ─────────────────────────────────────────────────────
// ServerInfo returns general server diagnostics.
@ -2024,6 +2153,26 @@ func parseHeroID(r *http.Request) (int64, error) {
return strconv.ParseInt(chi.URLParam(r, "heroId"), 10, 64)
}
func (h *AdminHandler) adminWSAuthorized(r *http.Request) bool {
if user, pass, ok := r.BasicAuth(); ok {
if basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass) {
return true
}
}
q := r.URL.Query()
if raw := strings.TrimSpace(q.Get("auth")); raw != "" {
if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil {
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 && basicAuthCredentialsMatch(parts[0], parts[1], h.adminUser, h.adminPass) {
return true
}
}
}
user := q.Get("user")
pass := q.Get("pass")
return basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass)
}
func parseQuestID(r *http.Request) (int64, error) {
return strconv.ParseInt(chi.URLParam(r, "questId"), 10, 64)
}
@ -2104,7 +2253,6 @@ func resetHeroToLevel1(hero *model.Hero) {
hero.State = model.StateWalking
hero.Buffs = nil
hero.Debuffs = nil
hero.BuffCharges = nil
hero.BuffFreeChargesRemaining = model.FreeBuffActivationsPerPeriodRuntime()
hero.BuffQuotaPeriodEnd = nil
now := time.Now()
hero.ResetBuffCharges(nil, now)
}

@ -0,0 +1,28 @@
package handler
import (
"net/http"
"github.com/denisovdennis/autohero/internal/game"
)
// APITimePausedMiddleware blocks mutating /api/v1 requests while global simulation time is frozen.
// GET/HEAD/OPTIONS still work so clients can read state.
func APITimePausedMiddleware(engine *game.Engine) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
if engine != nil && engine.IsTimePaused() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "server time is paused",
})
return
}
next.ServeHTTP(w, r)
})
}
}

@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
@ -66,21 +67,26 @@ func (h *AuthHandler) TelegramAuth(w http.ResponseWriter, r *http.Request) {
return
}
hero, err := h.store.GetOrCreate(r.Context(), telegramID, "Hero")
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil {
h.logger.Error("failed to get or create hero", "telegram_id", telegramID, "error", err)
h.logger.Error("failed to load hero", "telegram_id", telegramID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
return
}
h.logger.Info("telegram auth success", "telegram_id", telegramID, "hero_id", hero.ID)
resp := map[string]any{"heroId": int64(0), "hero": nil}
if hero != nil {
hero.XPToNext = model.XPToNextLevel(hero.Level)
resp["heroId"] = hero.ID
resp["hero"] = hero
h.logger.Info("telegram auth success", "telegram_id", telegramID, "hero_id", hero.ID)
} else {
h.logger.Info("telegram auth success, no hero row yet", "telegram_id", telegramID)
}
writeJSON(w, http.StatusOK, map[string]any{
"heroId": hero.ID,
"hero": hero,
})
writeJSON(w, http.StatusOK, resp)
}
// TelegramAuthMiddleware validates the Telegram initData on every request

@ -6,20 +6,14 @@ import (
"github.com/denisovdennis/autohero/internal/model"
)
// consumeFreeBuffCharge attempts to consume a per-buff-type free charge.
// consumeFreeBuffCharge consumes one per-buff-type charge (F2P and subscribers).
// Returns an error if no charges remain for the given buff type.
func consumeFreeBuffCharge(hero *model.Hero, bt model.BuffType, now time.Time) error {
if hero.SubscriptionActive {
return nil
}
hero.EnsureBuffChargesPopulated(now)
return hero.ConsumeBuffCharge(bt, now)
}
// refundFreeBuffCharge restores a charge for the specific buff type after a failed activation.
// refundFreeBuffCharge restores a charge after a failed activation.
func refundFreeBuffCharge(hero *model.Hero, bt model.BuffType) {
if hero.SubscriptionActive {
return
}
hero.RefundBuffCharge(bt)
}

@ -7,14 +7,22 @@ import (
"github.com/denisovdennis/autohero/internal/model"
)
func TestConsumeFreeBuffCharge_SubscriptionSkipsQuota(t *testing.T) {
h := &model.Hero{SubscriptionActive: true, BuffFreeChargesRemaining: 0}
func TestConsumeFreeBuffCharge_SubscriberConsumesCharge(t *testing.T) {
now := time.Now()
pe := now.Add(time.Hour)
h := &model.Hero{
SubscriptionActive: true,
BuffFreeChargesRemaining: 10,
BuffCharges: map[string]model.BuffChargeState{
string(model.BuffRush): {Remaining: 2, PeriodEnd: &pe},
},
}
if err := consumeFreeBuffCharge(h, model.BuffRush, now); err != nil {
t.Fatal(err)
}
if h.BuffFreeChargesRemaining != 0 {
t.Fatalf("expected no charge mutation for subscriber, got %d", h.BuffFreeChargesRemaining)
st := h.BuffCharges[string(model.BuffRush)]
if st.Remaining != 1 {
t.Fatalf("subscriber should consume charge, want remaining 1, got %d", st.Remaining)
}
}

@ -365,22 +365,16 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
now := time.Now()
hero.EnsureBuffChargesPopulated(now)
consumed := false
if !hero.SubscriptionActive {
if err := consumeFreeBuffCharge(hero, bt, now); err != nil {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": err.Error(),
})
return
}
consumed = true
if err := consumeFreeBuffCharge(hero, bt, now); err != nil {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": err.Error(),
})
return
}
ab := game.ApplyBuff(hero, bt, now)
if ab == nil {
if consumed {
refundFreeBuffCharge(hero, bt)
}
refundFreeBuffCharge(hero, bt)
h.logger.Error("ApplyBuff returned nil", "hero_id", hero.ID, "buff_type", bt)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to apply buff",
@ -1002,7 +996,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
return
}
hero, err := h.store.GetOrCreate(r.Context(), telegramID, "Hero")
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil {
h.logger.Error("failed to init hero", "telegram_id", telegramID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -1011,6 +1005,23 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
return
}
if hero == nil {
townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts()
writeJSON(w, http.StatusOK, map[string]any{
"hero": nil,
"needsName": true,
"offlineReport": nil,
"mapRef": h.world.RefForLevel(1),
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
})
return
}
hero.XPToNext = model.XPToNextLevel(hero.Level)
now := time.Now()
simFrozen := h.engine != nil && h.engine.IsTimePaused()
if !simFrozen {
@ -1061,6 +1072,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
// Build towns with NPCs for the frontend map.
townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts()
writeJSON(w, http.StatusOK, map[string]any{
"hero": hero,
@ -1068,6 +1080,8 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"offlineReport": report,
"mapRef": h.world.RefForLevel(hero.Level),
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
})
}
@ -1184,9 +1198,31 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "hero not found",
})
hero, err = h.store.CreateHeroWithSpawn(r.Context(), telegramID, req.Name)
if err != nil {
errStr := err.Error()
if containsUniqueViolation(errStr) {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "HERO_NAME_TAKEN",
})
return
}
h.logger.Error("failed to create hero", "telegram_id", telegramID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to create hero",
})
return
}
now := time.Now()
chargesInit := hero.EnsureBuffChargesPopulated(now)
if chargesInit {
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Warn("failed to persist buff charges after create", "hero_id", hero.ID, "error", err)
}
}
hero.RefreshDerivedCombatStats(now)
h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name)
writeJSON(w, http.StatusOK, hero)
return
}

@ -169,7 +169,14 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
switch npc.Type {
case "quest_giver":
quests, err := h.questStore.ListQuestsByNPCForHeroLevel(r.Context(), npc.ID, hero.Level)
refreshHours := tuning.EffectiveQuestOfferRefreshHours()
if refreshHours <= 0 {
refreshHours = 2
}
refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC()
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, hero.Level, limit, timeBucket)
if err != nil {
h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -187,20 +194,20 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
}
case "merchant":
cfg := tuning.Get()
potionCost, _ := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item",
ItemName: "Healing Potion",
ItemCost: cfg.NPCCostPotion,
ItemCost: potionCost,
Description: "Restores health. Always handy in a pinch.",
})
case "healer":
cfg := tuning.Get()
_, healCost := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{
ActionType: "heal",
ItemName: "Full Heal",
ItemCost: cfg.NPCCostHeal,
ItemCost: healCost,
Description: "Restore hero to full HP.",
})
}
@ -593,7 +600,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
}
}
healCost := tuning.Get().NPCCostHeal
_, healCost := tuning.EffectiveNPCShopCosts()
if hero.Gold < healCost {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", healCost, hero.Gold),
@ -643,7 +650,7 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return
}
potionCost := tuning.Get().NPCCostPotion
potionCost, _ := tuning.EffectiveNPCShopCosts()
if hero.Gold < potionCost {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", potionCost, hero.Gold),

@ -0,0 +1,440 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/telegram"
)
// PaymentsHandler handles Telegram Payments invoice creation and webhook callbacks.
type PaymentsHandler struct {
botToken string
paymentProviderToken string
store *storage.HeroStore
logStore *storage.LogStore
logger *slog.Logger
}
// NewPaymentsHandler creates a new PaymentsHandler.
func NewPaymentsHandler(
botToken, paymentProviderToken string,
store *storage.HeroStore,
logStore *storage.LogStore,
logger *slog.Logger,
) *PaymentsHandler {
return &PaymentsHandler{
botToken: botToken,
paymentProviderToken: paymentProviderToken,
store: store,
logStore: logStore,
logger: logger,
}
}
// --- Request / response types ---
type createInvoiceRequest struct {
Type string `json:"type"` // "subscription_weekly", "buff_refill", "resurrection_refill"
BuffType string `json:"buffType"` // required when type == "buff_refill"
}
type createInvoiceResponse struct {
InvoiceURL string `json:"invoiceUrl"`
}
// --- CreateInvoice ---
// CreateInvoice generates a Telegram invoice link for the requested purchase.
// POST /api/v1/payments/create-invoice
func (h *PaymentsHandler) CreateInvoice(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"})
return
}
var req createInvoiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil {
h.logger.Error("create-invoice: load hero failed", "telegram_id", telegramID, "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
}
now := time.Now()
params, err := h.buildInvoiceParams(req, hero.ID, now)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
link, err := telegram.CreateInvoiceLink(h.botToken, params)
if err != nil {
h.logger.Error("create-invoice: telegram API failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create invoice"})
return
}
h.logger.Info("invoice link created",
"hero_id", hero.ID,
"type", req.Type,
"payload", params.Payload,
)
writeJSON(w, http.StatusOK, createInvoiceResponse{InvoiceURL: link})
}
// buildInvoiceParams maps the client request to Telegram invoice parameters.
func (h *PaymentsHandler) buildInvoiceParams(req createInvoiceRequest, heroID int64, now time.Time) (telegram.InvoiceLinkParams, error) {
ts := now.Unix()
switch req.Type {
case "subscription_weekly":
return telegram.InvoiceLinkParams{
Title: "Weekly Subscription",
Description: "7 days of x2 buffs and x2 revives",
Payload: fmt.Sprintf("sub_weekly_%d_%d", heroID, ts),
ProviderToken: h.paymentProviderToken,
Currency: "RUB",
Prices: []telegram.LabeledAmount{
{Label: "Weekly Subscription", Amount: int(model.SubscriptionWeeklyPrice() * 100)}, // rubles -> kopecks
},
}, nil
case "buff_refill":
bt, valid := model.ValidBuffType(req.BuffType)
if !valid {
return telegram.InvoiceLinkParams{}, fmt.Errorf("invalid buff type: %s", req.BuffType)
}
if bt == model.BuffResurrection {
return telegram.InvoiceLinkParams{}, fmt.Errorf("use type \"resurrection_refill\" for resurrection")
}
return telegram.InvoiceLinkParams{
Title: fmt.Sprintf("Buff Refill: %s", strings.Title(req.BuffType)),
Description: fmt.Sprintf("Refill %s buff charges to maximum", req.BuffType),
Payload: fmt.Sprintf("buff_%s_%d_%d", req.BuffType, heroID, ts),
ProviderToken: h.paymentProviderToken,
Currency: "RUB",
Prices: []telegram.LabeledAmount{
{Label: "Buff Refill", Amount: model.BuffRefillPrice() * 100},
},
}, nil
case "resurrection_refill":
return telegram.InvoiceLinkParams{
Title: "Resurrection Refill",
Description: "Refill Resurrection charges",
Payload: fmt.Sprintf("buff_resurrection_%d_%d", heroID, ts),
ProviderToken: h.paymentProviderToken,
Currency: "RUB",
Prices: []telegram.LabeledAmount{
{Label: "Resurrection Refill", Amount: model.ResurrectionRefillPrice() * 100},
},
}, nil
default:
return telegram.InvoiceLinkParams{}, fmt.Errorf("unknown purchase type: %s", req.Type)
}
}
// --- Telegram Webhook ---
// TelegramWebhook handles incoming Telegram Update objects for payment callbacks.
// POST /api/v1/payments/telegram-webhook
func (h *PaymentsHandler) TelegramWebhook(w http.ResponseWriter, r *http.Request) {
var update telegramUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
h.logger.Warn("telegram-webhook: invalid body", "error", err)
// Always return 200 to Telegram so it does not retry garbage.
w.WriteHeader(http.StatusOK)
return
}
// Handle pre_checkout_query — must respond within 10 seconds.
if update.PreCheckoutQuery != nil {
h.handlePreCheckout(update.PreCheckoutQuery)
w.WriteHeader(http.StatusOK)
return
}
// Handle successful_payment inside a message.
if update.Message != nil && update.Message.SuccessfulPayment != nil {
h.handleSuccessfulPayment(r.Context(), update.Message)
w.WriteHeader(http.StatusOK)
return
}
// Unknown update type — acknowledge and ignore.
w.WriteHeader(http.StatusOK)
}
// handlePreCheckout approves a pre-checkout query after basic payload validation.
func (h *PaymentsHandler) handlePreCheckout(q *preCheckoutQuery) {
// Validate payload format: must start with "sub_weekly_" or "buff_".
payload := q.InvoicePayload
valid := strings.HasPrefix(payload, "sub_weekly_") || strings.HasPrefix(payload, "buff_")
if !valid {
h.logger.Warn("pre_checkout: unknown payload format", "payload", payload)
if err := telegram.AnswerPreCheckoutQuery(h.botToken, q.ID, false, "Unknown purchase type"); err != nil {
h.logger.Error("pre_checkout: answer failed", "error", err)
}
return
}
h.logger.Info("pre_checkout: approving", "query_id", q.ID, "payload", payload)
if err := telegram.AnswerPreCheckoutQuery(h.botToken, q.ID, true, ""); err != nil {
h.logger.Error("pre_checkout: answer failed", "error", err)
}
}
// handleSuccessfulPayment processes a completed Telegram payment.
func (h *PaymentsHandler) handleSuccessfulPayment(ctx context.Context, msg *telegramMessage) {
sp := msg.SuccessfulPayment
payload := sp.InvoicePayload
h.logger.Info("successful_payment received",
"payload", payload,
"total_amount", sp.TotalAmount,
"currency", sp.Currency,
"telegram_charge_id", sp.TelegramPaymentChargeID,
"provider_charge_id", sp.ProviderPaymentChargeID,
)
heroID, err := parseHeroIDFromPayload(payload)
if err != nil {
h.logger.Error("successful_payment: parse payload failed", "payload", payload, "error", err)
return
}
hero, err := h.store.GetByID(ctx, heroID)
if err != nil || hero == nil {
h.logger.Error("successful_payment: load hero failed", "hero_id", heroID, "error", err)
return
}
now := time.Now()
switch {
case strings.HasPrefix(payload, "sub_weekly_"):
h.applySubscription(ctx, hero, now, sp)
case strings.HasPrefix(payload, "buff_"):
h.applyBuffRefill(ctx, hero, now, payload, sp)
default:
h.logger.Error("successful_payment: unknown payload prefix", "payload", payload)
}
}
// applySubscription activates a weekly subscription for the hero.
func (h *PaymentsHandler) applySubscription(ctx context.Context, hero *model.Hero, now time.Time, sp *successfulPayment) {
hero.ActivateSubscription(now)
// Upgrade buff charges to subscriber limits.
hero.EnsureBuffChargesPopulated(now)
for bt := range model.BuffFreeChargesPerType {
state := hero.GetBuffCharges(bt, now)
subMax := hero.MaxBuffCharges(bt)
if state.Remaining < subMax {
state.Remaining = subMax
hero.BuffCharges[string(bt)] = state
}
}
payment := &model.Payment{
HeroID: hero.ID,
Type: "subscription_weekly",
AmountRUB: int(model.SubscriptionWeeklyPrice()),
Status: model.PaymentCompleted,
CreatedAt: now,
CompletedAt: &now,
}
if err := h.store.CreatePayment(ctx, payment); err != nil {
h.logger.Error("successful_payment: create payment record failed", "hero_id", hero.ID, "error", err)
}
if err := h.store.Save(ctx, hero); err != nil {
h.logger.Error("successful_payment: save hero failed", "hero_id", hero.ID, "error", err)
return
}
h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPrice()))
h.logger.Info("subscription activated via Telegram Payment",
"hero_id", hero.ID,
"telegram_charge_id", sp.TelegramPaymentChargeID,
"expires_at", hero.SubscriptionExpiresAt,
)
}
// applyBuffRefill resets a specific buff's charges after a successful payment.
func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero, now time.Time, payload string, sp *successfulPayment) {
buffTypeStr, err := parseBuffTypeFromPayload(payload)
if err != nil {
h.logger.Error("successful_payment: parse buff type failed", "payload", payload, "error", err)
return
}
bt, valid := model.ValidBuffType(buffTypeStr)
if !valid {
h.logger.Error("successful_payment: invalid buff type in payload", "buff_type", buffTypeStr)
return
}
priceRUB := model.BuffRefillPrice()
paymentType := model.PaymentBuffReplenish
if bt == model.BuffResurrection {
priceRUB = model.ResurrectionRefillPrice()
paymentType = model.PaymentResurrectionReplenish
}
hero.ResetBuffCharges(&bt, now)
payment := &model.Payment{
HeroID: hero.ID,
Type: paymentType,
BuffType: string(bt),
AmountRUB: priceRUB,
Status: model.PaymentCompleted,
CreatedAt: now,
CompletedAt: &now,
}
if err := h.store.CreatePayment(ctx, payment); err != nil {
h.logger.Error("successful_payment: create payment record failed", "hero_id", hero.ID, "error", err)
}
if err := h.store.Save(ctx, hero); err != nil {
h.logger.Error("successful_payment: save hero failed", "hero_id", hero.ID, "error", err)
return
}
h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s (%d₽)", bt, priceRUB))
h.logger.Info("buff refill via Telegram Payment",
"hero_id", hero.ID,
"buff_type", bt,
"price_rub", priceRUB,
"telegram_charge_id", sp.TelegramPaymentChargeID,
)
}
// addLog writes an adventure log entry for the hero.
func (h *PaymentsHandler) addLog(heroID int64, message string) {
if h.logStore == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := h.logStore.Add(ctx, heroID, message); err != nil {
h.logger.Warn("payments: failed to write adventure log", "hero_id", heroID, "error", err)
}
}
// --- SetWebhook ---
// SetWebhook registers the Telegram webhook URL for payment callbacks.
// POST /admin/payments/set-webhook
func (h *PaymentsHandler) SetWebhook(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provide a non-empty url"})
return
}
if err := telegram.SetWebhook(h.botToken, req.URL); err != nil {
h.logger.Error("set-webhook failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
h.logger.Info("telegram webhook set", "url", req.URL)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "url": req.URL})
}
// --- Telegram Update types (subset needed for payments) ---
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
PreCheckoutQuery *preCheckoutQuery `json:"pre_checkout_query,omitempty"`
Message *telegramMessage `json:"message,omitempty"`
}
type preCheckoutQuery struct {
ID string `json:"id"`
From tgUser `json:"from"`
Currency string `json:"currency"`
TotalAmount int `json:"total_amount"`
InvoicePayload string `json:"invoice_payload"`
}
type telegramMessage struct {
MessageID int `json:"message_id"`
From *tgUser `json:"from,omitempty"`
SuccessfulPayment *successfulPayment `json:"successful_payment,omitempty"`
}
type successfulPayment struct {
Currency string `json:"currency"`
TotalAmount int `json:"total_amount"`
InvoicePayload string `json:"invoice_payload"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
ProviderPaymentChargeID string `json:"provider_payment_charge_id"`
}
type tgUser struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
}
// --- Payload parsing helpers ---
// parseHeroIDFromPayload extracts the hero ID from a payment payload string.
// Payload formats:
//
// "sub_weekly_{heroID}_{timestamp}"
// "buff_{buffType}_{heroID}_{timestamp}"
func parseHeroIDFromPayload(payload string) (int64, error) {
parts := strings.Split(payload, "_")
switch {
case strings.HasPrefix(payload, "sub_weekly_") && len(parts) >= 4:
// sub_weekly_{heroID}_{ts}
return strconv.ParseInt(parts[2], 10, 64)
case strings.HasPrefix(payload, "buff_") && len(parts) >= 4:
// buff_{type}_{heroID}_{ts}
return strconv.ParseInt(parts[2], 10, 64)
default:
return 0, fmt.Errorf("unrecognized payload format: %s", payload)
}
}
// parseBuffTypeFromPayload extracts the buff type string from a buff refill payload.
// "buff_{type}_{heroID}_{ts}" -> "{type}"
func parseBuffTypeFromPayload(payload string) (string, error) {
parts := strings.Split(payload, "_")
if len(parts) < 4 || parts[0] != "buff" {
return "", fmt.Errorf("invalid buff payload format: %s", payload)
}
return parts[1], nil
}

@ -4,10 +4,13 @@ import (
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// QuestHandler serves quest system API endpoints.
@ -65,8 +68,61 @@ func (h *QuestHandler) ListNPCsByTown(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, npcs)
}
// ListQuestsByNPC returns all quests offered by an NPC.
// ListBuildingsByTown returns all buildings in a town.
// GET /api/v1/towns/{townId}/buildings
func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Request) {
townIDStr := chi.URLParam(r, "townId")
townID, err := strconv.ParseInt(townIDStr, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid townId",
})
return
}
town, err := h.questStore.GetTown(r.Context(), townID)
if err != nil {
h.logger.Error("failed to get town for buildings", "town_id", townID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load town",
})
return
}
if town == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "town not found",
})
return
}
buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
if err != nil {
h.logger.Error("failed to list buildings", "town_id", townID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to list buildings",
})
return
}
views := make([]model.BuildingView, 0, len(buildings))
for _, b := range buildings {
views = append(views, model.BuildingView{
ID: b.ID,
BuildingType: b.BuildingType,
WorldX: town.WorldX + b.OffsetX,
WorldY: town.WorldY + b.OffsetY,
Facing: b.Facing,
FootprintW: b.FootprintW,
FootprintH: b.FootprintH,
})
}
writeJSON(w, http.StatusOK, views)
}
// ListQuestsByNPC returns quests offered by an NPC.
// GET /api/v1/npcs/{npcId}/quests
// With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, and rotated on a configured cadence — same rules as npc-interact.
// Without telegramId, returns all templates for that NPC (catalog / tools).
func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
npcIDStr := chi.URLParam(r, "npcId")
npcID, err := strconv.ParseInt(npcIDStr, 10, 64)
@ -77,6 +133,47 @@ func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
return
}
if tgStr := r.URL.Query().Get("telegramId"); tgStr != "" {
tgID, err := strconv.ParseInt(tgStr, 10, 64)
if err != nil || tgID == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid telegramId",
})
return
}
hero, err := h.heroStore.GetByTelegramID(r.Context(), tgID)
if err != nil {
h.logger.Error("failed to get hero for npc quests", "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
}
refreshHours := tuning.EffectiveQuestOfferRefreshHours()
if refreshHours <= 0 {
refreshHours = 2
}
refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC()
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, timeBucket)
if err != nil {
h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to list quests",
})
return
}
writeJSON(w, http.StatusOK, quests)
return
}
quests, err := h.questStore.ListQuestsByNPC(r.Context(), npcID)
if err != nil {
h.logger.Error("failed to list quests", "npc_id", npcID, "error", err)
@ -124,13 +221,20 @@ func (h *QuestHandler) AcceptQuest(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.questStore.AcceptQuest(r.Context(), hero.ID, req.QuestID); err != nil {
inserted, err := h.questStore.TryAcceptQuest(r.Context(), hero.ID, req.QuestID)
if err != nil {
h.logger.Error("failed to accept quest", "hero_id", hero.ID, "quest_id", req.QuestID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to accept quest",
})
return
}
if !inserted {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "quest already in log",
})
return
}
h.logger.Info("quest accepted", "hero_id", hero.ID, "quest_id", req.QuestID)
writeJSON(w, http.StatusOK, map[string]string{
@ -176,7 +280,7 @@ func (h *QuestHandler) ListHeroQuests(w http.ResponseWriter, r *http.Request) {
}
// ClaimQuestReward claims a completed quest's reward.
// POST /api/v1/hero/quests/{questId}/claim
// POST /api/v1/hero/quests/{questId}/claim — questId is hero_quests.id (log row).
func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
@ -244,7 +348,7 @@ func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request)
}
// AbandonQuest removes a quest from the hero's quest log.
// DELETE /api/v1/hero/quests/{questId}
// DELETE /api/v1/hero/quests/{questId} — questId is hero_quests.id (log row), not the template id.
func (h *QuestHandler) AbandonQuest(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {

@ -7,10 +7,10 @@ import "time"
type BuffType string
const (
BuffRush BuffType = "rush" // +attack speed
BuffRush BuffType = "rush" // +movement speed
BuffRage BuffType = "rage" // +damage
BuffShield BuffType = "shield" // -incoming damage
BuffLuck BuffType = "luck" // x2.5 loot
BuffLuck BuffType = "luck" // loot mult from tuning.LuckBuffMultiplier when active
BuffResurrection BuffType = "resurrection" // revive on death
BuffHeal BuffType = "heal" // +50% HP instantly
BuffPowerPotion BuffType = "power_potion" // +150% damage
@ -64,7 +64,7 @@ const (
DebuffBurn DebuffType = "burn" // -3% HP/sec
DebuffStun DebuffType = "stun" // no attacks for 2 sec
DebuffSlow DebuffType = "slow" // -40% movement speed (not attack speed)
DebuffWeaken DebuffType = "weaken" // -30% hero outgoing damage
DebuffWeaken DebuffType = "weaken" // -30% hero incoming damage
DebuffIceSlow DebuffType = "ice_slow" // -20% attack speed (Ice Guardian per spec §4.2)
)

@ -0,0 +1,270 @@
package model
import (
"context"
"encoding/json"
"log/slog"
"sync/atomic"
"time"
)
// BuffJSON is DB/admin JSON for one buff type (durations in ms).
type BuffJSON struct {
Name string `json:"name"`
DurationMs int64 `json:"durationMs"`
Magnitude float64 `json:"magnitude"`
CooldownMs int64 `json:"cooldownMs"`
}
// DebuffJSON is DB/admin JSON for one debuff type (duration in ms).
type DebuffJSON struct {
Name string `json:"name"`
DurationMs int64 `json:"durationMs"`
Magnitude float64 `json:"magnitude"`
}
type buffDebuffPayload struct {
Buffs map[string]BuffJSON `json:"buffs"`
Debuffs map[string]DebuffJSON `json:"debuffs"`
}
type buffDebuffCatalogData struct {
buffs map[BuffType]Buff
debuffs map[DebuffType]Debuff
}
var buffDebuffCatalog atomic.Value
func init() {
buffDebuffCatalog.Store(&buffDebuffCatalogData{
buffs: seedBuffMap(),
debuffs: seedDebuffMap(),
})
}
func seedBuffMap() map[BuffType]Buff {
return map[BuffType]Buff{
BuffRush: {
Type: BuffRush, Name: "Rush",
Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 15 * time.Minute,
},
BuffRage: {
Type: BuffRage, Name: "Rage",
Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute,
},
BuffShield: {
Type: BuffShield, Name: "Shield",
Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 12 * time.Minute,
},
BuffLuck: {
Type: BuffLuck, Name: "Luck",
Duration: 30 * time.Minute, Magnitude: 1.0,
CooldownDuration: 2 * time.Hour,
},
BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection",
Duration: 10 * time.Minute, Magnitude: 0.50,
CooldownDuration: 30 * time.Minute,
},
BuffHeal: {
Type: BuffHeal, Name: "Heal",
Duration: 1 * time.Second, Magnitude: 0.50,
CooldownDuration: 5 * time.Minute,
},
BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion",
Duration: 5 * time.Minute, Magnitude: 1.50,
CooldownDuration: 20 * time.Minute,
},
BuffWarCry: {
Type: BuffWarCry, Name: "War Cry",
Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute,
},
}
}
func seedDebuffMap() map[DebuffType]Debuff {
return map[DebuffType]Debuff{
DebuffPoison: {
Type: DebuffPoison, Name: "Poison",
Duration: 5 * time.Second, Magnitude: 0.02,
},
DebuffFreeze: {
Type: DebuffFreeze, Name: "Freeze",
Duration: 3 * time.Second, Magnitude: 0.50,
},
DebuffBurn: {
Type: DebuffBurn, Name: "Burn",
Duration: 4 * time.Second, Magnitude: 0.03,
},
DebuffStun: {
Type: DebuffStun, Name: "Stun",
Duration: 2 * time.Second, Magnitude: 1.0,
},
DebuffSlow: {
Type: DebuffSlow, Name: "Slow",
Duration: 4 * time.Second, Magnitude: 0.40,
},
DebuffWeaken: {
Type: DebuffWeaken, Name: "Weaken",
Duration: 5 * time.Second, Magnitude: 0.30,
},
DebuffIceSlow: {
Type: DebuffIceSlow, Name: "Ice Slow",
Duration: 4 * time.Second, Magnitude: 0.20,
},
}
}
func buffFromStrictJSON(bt BuffType, j BuffJSON) Buff {
return Buff{
Type: bt,
Name: j.Name,
Duration: time.Duration(j.DurationMs) * time.Millisecond,
Magnitude: j.Magnitude,
CooldownDuration: time.Duration(j.CooldownMs) * time.Millisecond,
}
}
func debuffFromStrictJSON(dt DebuffType, j DebuffJSON) Debuff {
return Debuff{
Type: dt,
Name: j.Name,
Duration: time.Duration(j.DurationMs) * time.Millisecond,
Magnitude: j.Magnitude,
}
}
func cloneBuffMap(src map[BuffType]Buff) map[BuffType]Buff {
out := make(map[BuffType]Buff, len(src))
for k, v := range src {
out[k] = v
}
return out
}
func cloneDebuffMap(src map[DebuffType]Debuff) map[DebuffType]Debuff {
out := make(map[DebuffType]Debuff, len(src))
for k, v := range src {
out[k] = v
}
return out
}
// BuffDebuffPayloadLoader loads raw JSON from persistence.
type BuffDebuffPayloadLoader interface {
LoadBuffDebuffConfigPayload(ctx context.Context) ([]byte, error)
}
// ReloadBuffDebuffCatalog merges DB payload into built-in seeds (same pattern as tuning).
func ReloadBuffDebuffCatalog(ctx context.Context, logger *slog.Logger, loader BuffDebuffPayloadLoader) error {
payload, err := loader.LoadBuffDebuffConfigPayload(ctx)
if err != nil {
if logger != nil {
logger.Warn("buff/debuff config load failed", "error", err)
}
return err
}
buffs := cloneBuffMap(seedBuffMap())
debuffs := cloneDebuffMap(seedDebuffMap())
if len(payload) > 0 {
var raw buffDebuffPayload
if err := json.Unmarshal(payload, &raw); err != nil {
if logger != nil {
logger.Warn("buff/debuff config parse failed", "error", err)
}
return err
}
// Per-key full replace: payload must include all fields for edited types (admin UI sends full effective maps).
for key, j := range raw.Buffs {
bt := BuffType(key)
if _, ok := buffs[bt]; ok {
buffs[bt] = buffFromStrictJSON(bt, j)
}
}
for key, j := range raw.Debuffs {
dt := DebuffType(key)
if _, ok := debuffs[dt]; ok {
debuffs[dt] = debuffFromStrictJSON(dt, j)
}
}
}
buffDebuffCatalog.Store(&buffDebuffCatalogData{buffs: buffs, debuffs: debuffs})
return nil
}
func catalogData() *buffDebuffCatalogData {
return buffDebuffCatalog.Load().(*buffDebuffCatalogData)
}
// BuffDefinition returns the active buff template (DB + defaults).
func BuffDefinition(bt BuffType) (Buff, bool) {
b, ok := catalogData().buffs[bt]
return b, ok
}
// DebuffDefinition returns the active debuff template (DB + defaults).
func DebuffDefinition(dt DebuffType) (Debuff, bool) {
d, ok := catalogData().debuffs[dt]
return d, ok
}
// BuffCatalogSnapshot returns copies for admin/API.
func BuffCatalogSnapshot() map[BuffType]Buff {
src := catalogData().buffs
out := make(map[BuffType]Buff, len(src))
for k, v := range src {
out[k] = v
}
return out
}
// DebuffCatalogSnapshot returns copies for admin/API.
func DebuffCatalogSnapshot() map[DebuffType]Debuff {
src := catalogData().debuffs
out := make(map[DebuffType]Debuff, len(src))
for k, v := range src {
out[k] = v
}
return out
}
// BuffToJSON converts Buff to BuffJSON (ms).
func BuffToJSON(b Buff) BuffJSON {
return BuffJSON{
Name: b.Name,
DurationMs: b.Duration.Milliseconds(),
Magnitude: b.Magnitude,
CooldownMs: b.CooldownDuration.Milliseconds(),
}
}
// DebuffToJSON converts Debuff to DebuffJSON (ms).
func DebuffToJSON(d Debuff) DebuffJSON {
return DebuffJSON{
Name: d.Name,
DurationMs: d.Duration.Milliseconds(),
Magnitude: d.Magnitude,
}
}
// BuffCatalogEffectiveJSON builds string-keyed maps for admin/API.
func BuffCatalogEffectiveJSON() (map[string]BuffJSON, map[string]DebuffJSON) {
buffs := BuffCatalogSnapshot()
outB := make(map[string]BuffJSON, len(buffs))
for t, b := range buffs {
outB[string(t)] = BuffToJSON(b)
}
debuffs := DebuffCatalogSnapshot()
outD := make(map[string]DebuffJSON, len(debuffs))
for t, d := range debuffs {
outD[string(t)] = DebuffToJSON(d)
}
return outB, outD
}

@ -7,28 +7,28 @@ import (
"github.com/denisovdennis/autohero/internal/tuning"
)
// BuffFreeChargesPerType defines the per-buff free charge limits per 24h window.
// BuffFreeChargesPerType defines per-buff charge limits per BuffChargePeriod (default 24h).
var BuffFreeChargesPerType = map[BuffType]int{
BuffRush: 3,
BuffRage: 2,
BuffShield: 2,
BuffRush: 2,
BuffRage: 1,
BuffShield: 1,
BuffLuck: 1,
BuffResurrection: 1,
BuffHeal: 3,
BuffHeal: 2,
BuffPowerPotion: 1,
BuffWarCry: 2,
BuffWarCry: 1,
}
// BuffSubscriberChargesPerType defines the per-buff charge limits for subscribers (x2).
// BuffSubscriberChargesPerType defines higher per-buff caps for active subscribers (not a flat 2x).
var BuffSubscriberChargesPerType = map[BuffType]int{
BuffRush: 6,
BuffRage: 4,
BuffShield: 4,
BuffRush: 3,
BuffRage: 2,
BuffShield: 2,
BuffLuck: 2,
BuffResurrection: 2,
BuffHeal: 6,
BuffPowerPotion: 2,
BuffWarCry: 4,
BuffHeal: 3,
BuffPowerPotion: 1,
BuffWarCry: 2,
}
func SubscriptionWeeklyPrice() int64 {

@ -38,7 +38,7 @@ func TestApplyBuffQuotaRollover_NoOpWhenSubscribed(t *testing.T) {
}
}
func TestResetBuffCharges_SubscriberGetsDoubleCap(t *testing.T) {
func TestResetBuffCharges_SubscriberGetsSubscriberCap(t *testing.T) {
now := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
bt := BuffRush
h := &Hero{

@ -0,0 +1,34 @@
package model
// TownBuilding represents a persistent structure placed in a town.
type TownBuilding struct {
ID int64 `json:"id"`
TownID int64 `json:"townId"`
BuildingType string `json:"buildingType"` // house.quest_giver, house.merchant, house.healer, decoration.*
OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"`
Facing string `json:"facing"` // north, south, east, west
FootprintW float64 `json:"footprintW"`
FootprintH float64 `json:"footprintH"`
}
// BuildingView is the frontend-friendly view with absolute world coordinates.
type BuildingView struct {
ID int64 `json:"id"`
BuildingType string `json:"buildingType"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
Facing string `json:"facing"`
FootprintW float64 `json:"footprintW"`
FootprintH float64 `json:"footprintH"`
}
// BuildingTypeForNPC returns the expected building type for a given NPC type.
func BuildingTypeForNPC(npcType string) string {
return "house." + npcType
}
// IsHouseBuilding returns true if the building type is a house (not decoration).
func IsHouseBuilding(buildingType string) bool {
return len(buildingType) > 6 && buildingType[:6] == "house."
}

@ -22,6 +22,8 @@ type CombatState struct {
EnemyNextAttack time.Time `json:"enemyNextAttack"`
StartedAt time.Time `json:"startedAt"`
LastTickAt time.Time `json:"-"` // tracks previous tick for periodic effects
// Fractional regen carry between ticks to avoid rounding to 1 HP each tick.
EnemyRegenRemainder float64 `json:"-"`
}
// AttackEvent is a min-heap entry for scheduling attacks by next_attack_at.
@ -57,6 +59,7 @@ type CombatEvent struct {
HeroID int64 `json:"heroId"`
Damage int `json:"damage,omitempty"`
Source string `json:"source"` // "hero" or "enemy"
Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun"
IsCrit bool `json:"isCrit,omitempty"`
DebuffApplied string `json:"debuffApplied,omitempty"` // debuff type applied this event, if any
HeroHP int `json:"heroHp"`

@ -0,0 +1,30 @@
package model
import "time"
// ShiftHeroEffectDeadlines moves buff/debuff expiry and buff quota windows by d so in-game time
// does not advance during a global server pause (wall clock still moves).
func ShiftHeroEffectDeadlines(h *Hero, d time.Duration) {
if h == nil || d <= 0 {
return
}
for i := range h.Buffs {
h.Buffs[i].ExpiresAt = h.Buffs[i].ExpiresAt.Add(d)
h.Buffs[i].AppliedAt = h.Buffs[i].AppliedAt.Add(d)
}
for i := range h.Debuffs {
h.Debuffs[i].ExpiresAt = h.Debuffs[i].ExpiresAt.Add(d)
h.Debuffs[i].AppliedAt = h.Debuffs[i].AppliedAt.Add(d)
}
if h.BuffQuotaPeriodEnd != nil {
t := h.BuffQuotaPeriodEnd.Add(d)
h.BuffQuotaPeriodEnd = &t
}
for k, v := range h.BuffCharges {
if v.PeriodEnd != nil {
t := v.PeriodEnd.Add(d)
v.PeriodEnd = &t
}
h.BuffCharges[k] = v
}
}

@ -0,0 +1,64 @@
package model
import "time"
// ExcursionPhase tracks where the hero is within a mini-adventure session.
// The lifecycle is: Out → Wild → Return → (back to road, phase cleared).
type ExcursionPhase string
const (
ExcursionNone ExcursionPhase = ""
ExcursionOut ExcursionPhase = "out" // moving off-road into the forest
ExcursionWild ExcursionPhase = "wild" // in the wilderness (encounters happen here)
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
)
// RestKind discriminates the context of a StateResting period.
type RestKind string
const (
RestKindNone RestKind = ""
RestKindTown RestKind = "town"
RestKindRoadside RestKind = "roadside"
RestKindAdventureInline RestKind = "adventure_inline"
)
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion).
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
type ExcursionSession struct {
Phase ExcursionPhase
StartedAt time.Time
// OutUntil marks the end of the out phase (hero reached full depth); derived from depth/speed.
OutUntil time.Time
// WildUntil marks the end of the wild phase; once reached the hero begins returning.
WildUntil time.Time
// ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
ReturnUntil time.Time
// DepthWorldUnits is the max perpendicular distance from the road spine for this session.
DepthWorldUnits float64
// RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero
// left the road, so it can be restored exactly when the excursion ends.
RoadFreezeWaypoint int
RoadFreezeFraction float64
}
// Active reports whether an excursion session is in progress.
func (s *ExcursionSession) Active() bool {
return s.Phase != ExcursionNone
}
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the
// heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
type ExcursionPersisted struct {
Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"`
WildUntil *time.Time `json:"wildUntil,omitempty"`
ReturnUntil *time.Time `json:"returnUntil,omitempty"`
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
}

@ -0,0 +1,36 @@
package model
import "testing"
func TestSetGearCatalog_FillsMissingSlotsFromDefaults(t *testing.T) {
originalCatalog := append([]GearFamily(nil), GearCatalog...)
originalBySlot := make(map[EquipmentSlot][]GearFamily, len(gearBySlot))
for slot, families := range gearBySlot {
originalBySlot[slot] = append([]GearFamily(nil), families...)
}
defer func() {
GearCatalog = originalCatalog
gearBySlot = originalBySlot
}()
dbOnlyMainHand := []GearFamily{
{
Slot: SlotMainHand,
FormID: "gear.form.main_hand.test",
Name: "Test Blade",
BasePrimary: 10,
StatType: "attack",
},
}
SetGearCatalog(dbOnlyMainHand)
if got := len(gearBySlot[SlotMainHand]); got != 1 {
t.Fatalf("expected main hand to keep only db families, got %d", got)
}
if gearBySlot[SlotMainHand][0].Name != "Test Blade" {
t.Fatalf("expected db main hand family to be used, got %q", gearBySlot[SlotMainHand][0].Name)
}
if got := len(gearBySlot[SlotHead]); got == 0 {
t.Fatalf("expected missing slot to be filled from defaults, got %d", got)
}
}

@ -63,9 +63,10 @@ type Hero struct {
// Movement state (persisted to DB for reconnect recovery).
CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"`
MoveState string `json:"moveState"`
// RestKind mirrors movement rest context for clients ("town" | "roadside").
RestKind string `json:"restKind,omitempty"`
MoveState string `json:"moveState"`
RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"`
@ -253,26 +254,14 @@ func (h *Hero) EffectiveAttackAt(now time.Time) int {
bonuses := h.activeStatBonuses(now)
effectiveStrength := h.Strength + bonuses.strengthBonus
effectiveAgility := h.Agility + bonuses.agilityBonus
effectiveConstitution := h.Constitution + bonuses.constitutionBonus
if chest := h.Gear[SlotChest]; chest != nil {
effectiveAgility += chest.AgilityBonus
}
atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + effectiveConstitution/8
if weapon := h.Gear[SlotMainHand]; weapon != nil {
atk += weapon.PrimaryStat
}
gearAttack, _ := h.gearPrimaryBonuses()
atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + gearAttack
atkF := float64(atk)
atkF *= bonuses.attackMultiplier
// Apply weaken debuff.
for _, ad := range h.Debuffs {
if ad.IsExpired(now) {
continue
}
if ad.Debuff.Type == DebuffWeaken {
atkF *= (1 - ad.Debuff.Magnitude) // -30% outgoing damage
}
}
if atkF < 1 {
atkF = 1
}
@ -292,10 +281,8 @@ func (h *Hero) EffectiveDefenseAt(now time.Time) int {
effectiveAgility += chest.AgilityBonus
}
def := h.Defense + effectiveConstitution/4 + effectiveAgility/8
if chest := h.Gear[SlotChest]; chest != nil {
def += chest.PrimaryStat
}
_, gearDefense := h.gearPrimaryBonuses()
def := h.Defense + effectiveConstitution + effectiveAgility/4 + gearDefense
def = int(float64(def) * bonuses.defenseMultiplier)
if def < 0 {
def = 0
@ -303,6 +290,44 @@ func (h *Hero) EffectiveDefenseAt(now time.Time) int {
return def
}
// EffectiveBlockChance returns the hero's block chance after buffs and defense scaling.
func (h *Hero) EffectiveBlockChance(now time.Time) float64 {
cfg := tuning.Get()
bonuses := h.activeStatBonuses(now)
chance := float64(h.EffectiveDefenseAt(now))*cfg.HeroBlockChancePerDefense + bonuses.blockChanceBonus
if chance < 0 {
chance = 0
}
if cfg.HeroBlockChanceCap > 0 && chance > cfg.HeroBlockChanceCap {
chance = cfg.HeroBlockChanceCap
}
return chance
}
// gearPrimaryBonuses sums primary stats from all equipped gear by statType.
// Mixed items split their primary stat between attack and defense.
func (h *Hero) gearPrimaryBonuses() (attackBonus int, defenseBonus int) {
if h.Gear == nil {
return 0, 0
}
for _, item := range h.Gear {
if item == nil {
continue
}
switch item.StatType {
case "attack":
attackBonus += item.PrimaryStat
case "defense":
defenseBonus += item.PrimaryStat
case "mixed":
half := item.PrimaryStat / 2
attackBonus += half
defenseBonus += item.PrimaryStat - half
}
}
return attackBonus, defenseBonus
}
// MovementSpeedMultiplier returns the hero's movement speed modifier (1.0 = normal).
// Rush buff and Slow debuff affect movement, not attack speed, per spec §7.
func (h *Hero) MovementSpeedMultiplier(now time.Time) float64 {

@ -21,25 +21,27 @@ func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) {
SlotMainHand: {
PrimaryStat: 5,
SpeedModifier: 1.3,
StatType: "attack",
},
SlotChest: {
PrimaryStat: 4,
SpeedModifier: 0.7,
AgilityBonus: -3,
StatType: "defense",
},
},
}
gotAttack := hero.EffectiveAttackAt(now)
// atk = 10 + 10*2 + 3/4 + 8/8 = 31 + weapon.PrimaryStat(5) = 36
if gotAttack != 36 {
t.Fatalf("expected attack 36, got %d", gotAttack)
// atk = 10 + 10*2 + 3/4 = 30 + weapon.PrimaryStat(5) = 35
if gotAttack != 35 {
t.Fatalf("expected attack 35, got %d", gotAttack)
}
gotDefense := hero.EffectiveDefenseAt(now)
// def = 5 + 8/4 + 3/8 = 7 + chest.PrimaryStat(4) = 11
if gotDefense != 11 {
t.Fatalf("expected defense 11, got %d", gotDefense)
// def = 5 + 8 + 3/4 = 13 + chest.PrimaryStat(4) = 17
if gotDefense != 17 {
t.Fatalf("expected defense 17, got %d", gotDefense)
}
gotSpeed := hero.EffectiveSpeedAt(now)
@ -49,6 +51,31 @@ func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) {
}
}
func TestGearPrimaryBonusesAcrossSlots(t *testing.T) {
now := time.Now()
hero := &Hero{
Attack: 10,
Defense: 5,
Speed: 1.0,
Strength: 2,
Constitution: 3,
Agility: 4,
Gear: map[EquipmentSlot]*GearItem{
SlotMainHand: {PrimaryStat: 6, StatType: "attack"},
SlotHead: {PrimaryStat: 4, StatType: "defense"},
SlotChest: {PrimaryStat: 7, StatType: "defense"},
SlotFinger: {PrimaryStat: 5, StatType: "mixed"},
},
}
if got := hero.EffectiveAttackAt(now); got != 23 {
t.Fatalf("expected attack 23, got %d", got)
}
if got := hero.EffectiveDefenseAt(now); got != 23 {
t.Fatalf("expected defense 23, got %d", got)
}
}
func TestBuffsProvideTemporaryStatEffects(t *testing.T) {
now := time.Now()
hero := &Hero{
@ -146,9 +173,9 @@ func TestRushAffectsMovementSpeed(t *testing.T) {
}}
got := hero.MovementSpeedMultiplier(now)
want := 1.5
want := 1.0 + mustBuffDef(BuffRush).Magnitude
if math.Abs(got-want) > 0.001 {
t.Fatalf("expected Rush to give movement multiplier %.1f, got %.3f", want, got)
t.Fatalf("expected Rush movement multiplier %.3f, got %.3f", want, got)
}
}
@ -261,7 +288,7 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
if h.MaxHP != 103 || h.Attack != 11 || h.Defense != 6 || h.Strength != 1 {
t.Fatalf("L30 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength)
}
if h.EffectiveAttackAt(now) != 13 || h.EffectiveDefenseAt(now) != 6 {
if h.EffectiveAttackAt(now) != 13 || h.EffectiveDefenseAt(now) != 7 {
t.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
}
})
@ -270,7 +297,7 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
if h.MaxHP != 104 || h.Attack != 11 || h.Defense != 6 || h.Strength != 2 {
t.Fatalf("L45 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength)
}
if h.EffectiveAttackAt(now) != 15 || h.EffectiveDefenseAt(now) != 6 {
if h.EffectiveAttackAt(now) != 15 || h.EffectiveDefenseAt(now) != 7 {
t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
}
})

@ -16,12 +16,13 @@ type Town struct {
// NPC represents a non-hostile character living in a town.
type NPC struct {
ID int64 `json:"id"`
TownID int64 `json:"townId"`
Name string `json:"name"`
Type string `json:"type"` // quest_giver, merchant, healer
OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"`
ID int64 `json:"id"`
TownID int64 `json:"townId"`
Name string `json:"name"`
Type string `json:"type"` // quest_giver, merchant, healer
OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"`
BuildingID *int64 `json:"buildingId,omitempty"`
}
// Quest is a template definition offered by a quest-giver NPC.
@ -34,6 +35,7 @@ type Quest struct {
TargetCount int `json:"targetCount"`
TargetEnemyType *string `json:"targetEnemyType"` // NULL = any enemy
TargetTownID *int64 `json:"targetTownId"` // for visit_town quests
TargetTownName string `json:"targetTownName,omitempty"` // set when joined from towns (e.g. hero quest list)
DropChance float64 `json:"dropChance"` // for collect_item
MinLevel int `json:"minLevel"`
MaxLevel int `json:"maxLevel"`
@ -62,25 +64,27 @@ type QuestReward struct {
Potions int `json:"potions"`
}
// TownWithNPCs is a Town annotated with its NPC residents and computed world positions.
// TownWithNPCs is a Town annotated with its NPC residents, buildings and computed world positions.
type TownWithNPCs struct {
ID int64 `json:"id"`
Name string `json:"name"`
Biome string `json:"biome"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
Radius float64 `json:"radius"`
Size string `json:"size"` // S, M, L derived from radius
NPCs []NPCView `json:"npcs"`
ID int64 `json:"id"`
Name string `json:"name"`
Biome string `json:"biome"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
Radius float64 `json:"radius"`
Size string `json:"size"` // S, M, L derived from radius
NPCs []NPCView `json:"npcs"`
Buildings []BuildingView `json:"buildings"`
}
// NPCView is the frontend-friendly view of an NPC with absolute world coordinates.
type NPCView struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
BuildingID *int64 `json:"buildingId,omitempty"`
}
// TownSizeFromRadius derives a size label from the town radius.

@ -0,0 +1,42 @@
package model
import "time"
// TownPausePersisted mirrors HeroMovement fields needed to resume resting, an in-town NPC tour,
// or a mid-adventure excursion after reconnect or during offline catch-up.
// Stored in heroes.town_pause (JSONB).
type TownPausePersisted struct {
RestUntil *time.Time `json:"restUntil,omitempty"`
RestKind RestKind `json:"restKind,omitempty"`
TownRestHealRemainder float64 `json:"townRestHealRemainder,omitempty"`
RestHealRemainder float64 `json:"restHealRemainder,omitempty"`
NPCQueue []int64 `json:"npcQueue,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
TownVisitNPCName string `json:"townVisitNPCName,omitempty"`
TownVisitNPCType string `json:"townVisitNPCType,omitempty"`
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
// Walk-to-NPC: hero is mid-walk toward an NPC inside the town.
NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"`
NPCWalkFromX float64 `json:"npcWalkFromX,omitempty"`
NPCWalkFromY float64 `json:"npcWalkFromY,omitempty"`
NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"`
NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"`
// Plaza: walk to town center after NPC tour, then wait/rest before leaving.
TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
CenterWalkFromX float64 `json:"centerWalkFromX,omitempty"`
CenterWalkFromY float64 `json:"centerWalkFromY,omitempty"`
CenterWalkToX float64 `json:"centerWalkToX,omitempty"`
CenterWalkToY float64 `json:"centerWalkToY,omitempty"`
CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"`
CenterWalkArrive *time.Time `json:"centerWalkArrive,omitempty"`
// Excursion (mini-adventure) session persisted for reconnect / offline resume.
Excursion *ExcursionPersisted `json:"excursion,omitempty"`
}

@ -1,6 +1,9 @@
package model
import "encoding/json"
import (
"encoding/json"
"time"
)
// WSEnvelope is the wire format for all WebSocket messages (both directions).
type WSEnvelope struct {
@ -83,11 +86,18 @@ type AttackPayload struct {
Source string `json:"source"` // "hero" or "enemy"
Damage int `json:"damage"`
IsCrit bool `json:"isCrit,omitempty"`
Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun"
HeroHP int `json:"heroHp"`
EnemyHP int `json:"enemyHp"`
DebuffApplied string `json:"debuffApplied,omitempty"`
}
// EnemyRegenPayload is sent when an enemy regenerates HP during combat.
type EnemyRegenPayload struct {
Amount int `json:"amount"`
EnemyHP int `json:"enemyHp"`
}
// CombatEndPayload is sent when the hero wins a fight.
type CombatEndPayload struct {
XPGained int64 `json:"xpGained"`
@ -116,26 +126,44 @@ type HeroRevivedPayload struct {
// TownNPCInfo describes an NPC in a town (town_enter payload).
type TownNPCInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
BuildingID *int64 `json:"buildingId,omitempty"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
}
// TownBuildingInfo describes a building in a town (town_enter payload).
type TownBuildingInfo struct {
ID int64 `json:"id"`
BuildingType string `json:"buildingType"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
Facing string `json:"facing"`
FootprintW float64 `json:"footprintW"`
FootprintH float64 `json:"footprintH"`
}
// TownEnterPayload is sent when a hero arrives at a town.
type TownEnterPayload struct {
TownID int64 `json:"townId"`
TownName string `json:"townName"`
Biome string `json:"biome"`
NPCs []TownNPCInfo `json:"npcs"`
RestDurationMs int64 `json:"restDurationMs"`
TownID int64 `json:"townId"`
TownName string `json:"townName"`
Biome string `json:"biome"`
NPCs []TownNPCInfo `json:"npcs"`
Buildings []TownBuildingInfo `json:"buildings"`
RestDurationMs int64 `json:"restDurationMs"`
}
// TownNPCVisitPayload is sent when the hero approaches an NPC (quest/shop/healer) during a town stay.
// TownNPCVisitPayload is sent when the hero finishes walking to an NPC visit (quest/shop/healer).
// WorldX/WorldY are the hero's stand position (near the NPC), not the NPC tile center.
type TownNPCVisitPayload struct {
NPCID int64 `json:"npcId"`
Name string `json:"name"`
Type string `json:"type"`
TownID int64 `json:"townId"`
NPCID int64 `json:"npcId"`
Name string `json:"name"`
Type string `json:"type"`
TownID int64 `json:"townId"`
WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"`
}
// AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log.
@ -181,6 +209,14 @@ type BuffAppliedPayload struct {
Magnitude float64 `json:"magnitude"`
}
// DebuffAppliedPayload is sent when a debuff is applied to the hero.
type DebuffAppliedPayload struct {
DebuffType string `json:"debuffType"`
DurationMs int64 `json:"durationMs"`
Magnitude float64 `json:"magnitude,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
// ErrorPayload is sent when a client command fails validation.
type ErrorPayload struct {
Code string `json:"code"`
@ -208,3 +244,16 @@ type ClaimQuestPayload struct {
type NPCInteractPayload struct {
NPCID int64 `json:"npcId"`
}
// ExcursionStartPayload is sent when a mini-adventure begins.
type ExcursionStartPayload struct {
DepthWorldUnits float64 `json:"depthWorldUnits"`
}
// ExcursionPhasePayload is sent when the excursion transitions between phases.
type ExcursionPhasePayload struct {
Phase string `json:"phase"`
}
// ExcursionEndPayload is sent when the mini-adventure completes.
type ExcursionEndPayload struct{}

@ -68,7 +68,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/api/v1/payments/telegram-webhook", paymentsH.TelegramWebhook)
// Admin routes protected with HTTP Basic authentication.
adminH := handler.NewAdminHandler(heroStore, gearStore, questStore, deps.Engine, deps.Hub, deps.PgPool, deps.Logger)
adminH := handler.NewAdminHandler(heroStore, gearStore, questStore, deps.Engine, deps.Hub, deps.PgPool, deps.Logger, deps.AdminBasicAuthUsername, deps.AdminBasicAuthPassword)
r.Route("/admin", func(r chi.Router) {
r.Use(handler.BasicAuthMiddleware(handler.BasicAuthConfig{
Username: deps.AdminBasicAuthUsername,
@ -84,13 +84,13 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/revive", adminH.ReviveHero)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges)
r.Post("/heroes/{heroId}/start-adventure", adminH.StartHeroAdventure)
r.Post("/heroes/{heroId}/teleport-town", adminH.TeleportHeroTown)
r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest)
r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartHeroRoadsideRest)
r.Post("/heroes/{heroId}/start-adventure", adminH.StartHeroExcursion)
r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroExcursion)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartRoadsideRest)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopRoadsideRest)
r.Post("/heroes/{heroId}/stop-roadside-rest", adminH.StopHeroRoadsideRest)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)
@ -128,6 +128,8 @@ func New(deps Deps) *chi.Mux {
r.Get("/payments/{paymentId}", adminH.GetPayment)
r.Post("/payments/set-webhook", paymentsH.SetWebhook)
})
// Admin WebSocket snapshot (auth via query params in handler).
r.Get("/admin-ws/hero/{heroId}", adminH.AdminHeroSnapshotWS)
// API v1 (authenticated routes).
gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore, deps.Hub)
@ -165,6 +167,7 @@ func New(deps Deps) *chi.Mux {
// Quest system routes.
r.Get("/towns", questH.ListTowns)
r.Get("/towns/{townId}/npcs", questH.ListNPCsByTown)
r.Get("/towns/{townId}/buildings", questH.ListBuildingsByTown)
r.Get("/npcs/{npcId}/quests", questH.ListQuestsByNPC)
r.Post("/hero/quests/{questId}/accept", questH.AcceptQuest)
r.Get("/hero/quests", questH.ListHeroQuests)

@ -0,0 +1,37 @@
package storage
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type BuffDebuffConfigStore struct {
pool *pgxpool.Pool
}
func NewBuffDebuffConfigStore(pool *pgxpool.Pool) *BuffDebuffConfigStore {
return &BuffDebuffConfigStore{pool: pool}
}
func (s *BuffDebuffConfigStore) LoadBuffDebuffConfigPayload(ctx context.Context) ([]byte, error) {
var payload []byte
err := s.pool.QueryRow(ctx, `SELECT payload FROM buff_debuff_config WHERE id = TRUE`).Scan(&payload)
if err != nil {
return nil, fmt.Errorf("load buff/debuff config payload: %w", err)
}
return payload, nil
}
func (s *BuffDebuffConfigStore) SaveBuffDebuffConfigPayload(ctx context.Context, payload []byte) error {
_, err := s.pool.Exec(ctx, `
UPDATE buff_debuff_config
SET payload = $1::jsonb, updated_at = now()
WHERE id = TRUE
`, payload)
if err != nil {
return fmt.Errorf("save buff/debuff config payload: %w", err)
}
return nil
}

@ -0,0 +1,176 @@
package storage
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/model"
)
type ContentStore struct {
pool *pgxpool.Pool
}
func NewContentStore(pool *pgxpool.Pool) *ContentStore {
return &ContentStore{pool: pool}
}
func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyType]model.Enemy, error) {
rows, err := s.pool.Query(ctx, `
SELECT type, name, hp, max_hp, attack, defense, speed, crit_chance,
min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite
FROM enemies
`)
if err != nil {
return nil, fmt.Errorf("load enemies from db: %w", err)
}
defer rows.Close()
out := make(map[model.EnemyType]model.Enemy)
for rows.Next() {
var (
t string
e model.Enemy
specialAbilities []string
)
if err := rows.Scan(
&t, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance,
&e.MinLevel, &e.MaxLevel, &e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite,
); err != nil {
return nil, fmt.Errorf("scan enemy row: %w", err)
}
e.Type = model.EnemyType(t)
e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities))
for _, a := range specialAbilities {
e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a))
}
out[e.Type] = e
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("enemy rows: %w", err)
}
return out, nil
}
func normalizeEquipmentSlot(raw string) model.EquipmentSlot {
v := strings.TrimSpace(strings.ToLower(raw))
v = strings.TrimPrefix(v, "gear.slot.")
switch v {
case "weapon", "mainhand", "main_hand":
return model.SlotMainHand
case "armor", "chest":
return model.SlotChest
case "head":
return model.SlotHead
case "feet":
return model.SlotFeet
case "neck":
return model.SlotNeck
case "hands":
return model.SlotHands
case "legs":
return model.SlotLegs
case "cloak":
return model.SlotCloak
case "finger", "ring":
return model.SlotFinger
case "wrist":
return model.SlotWrist
default:
return model.EquipmentSlot(v)
}
}
func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily, error) {
out := make([]model.GearFamily, 0, 128)
weaponRows, err := s.pool.Query(ctx, `
SELECT name, type, damage, speed, crit_chance, special_effect
FROM weapons
`)
if err != nil {
return nil, fmt.Errorf("load weapons from db: %w", err)
}
for weaponRows.Next() {
var name, typ, special string
var damage int
var speed, crit float64
if err := weaponRows.Scan(&name, &typ, &damage, &speed, &crit, &special); err != nil {
weaponRows.Close()
return nil, fmt.Errorf("scan weapon row: %w", err)
}
out = append(out, model.GearFamily{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand." + typ,
Name: name,
Subtype: typ,
BasePrimary: damage,
StatType: "attack",
SpeedModifier: speed,
BaseCrit: crit,
SpecialEffect: special,
})
}
weaponRows.Close()
armorRows, err := s.pool.Query(ctx, `
SELECT name, type, defense, speed_modifier, agility_bonus, set_name, special_effect
FROM armor
`)
if err != nil {
return nil, fmt.Errorf("load armor from db: %w", err)
}
for armorRows.Next() {
var name, typ, setName, special string
var defense, agi int
var speed float64
if err := armorRows.Scan(&name, &typ, &defense, &speed, &agi, &setName, &special); err != nil {
armorRows.Close()
return nil, fmt.Errorf("scan armor row: %w", err)
}
out = append(out, model.GearFamily{
Slot: model.SlotChest,
FormID: "gear.form.chest." + typ,
Name: name,
Subtype: typ,
BasePrimary: defense,
StatType: "defense",
SpeedModifier: speed,
AgilityBonus: agi,
SetName: setName,
SpecialEffect: special,
})
}
armorRows.Close()
eqRows, err := s.pool.Query(ctx, `
SELECT slot, form_id, name, base_primary, stat_type
FROM equipment_items
`)
if err != nil {
return nil, fmt.Errorf("load equipment_items from db: %w", err)
}
for eqRows.Next() {
var slot, formID, name, statType string
var basePrimary int
if err := eqRows.Scan(&slot, &formID, &name, &basePrimary, &statType); err != nil {
eqRows.Close()
return nil, fmt.Errorf("scan equipment_item row: %w", err)
}
out = append(out, model.GearFamily{
Slot: normalizeEquipmentSlot(slot),
FormID: formID,
Name: name,
BasePrimary: basePrimary,
StatType: statType,
SpeedModifier: 1.0,
})
}
eqRows.Close()
return out, nil
}

@ -3,8 +3,10 @@ package storage
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/rand"
"strconv"
"strings"
"time"
@ -51,11 +53,14 @@ func NewHeroStore(pool *pgxpool.Pool, logger *slog.Logger) *HeroStore {
}
// GetHeroIDByTelegramID returns the DB hero ID for a given Telegram user ID.
// Returns 0 if not found.
// Returns (0, nil) if not found.
func (s *HeroStore) GetHeroIDByTelegramID(ctx context.Context, telegramID int64) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return 0, err
}
return id, nil
@ -253,13 +258,11 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error)
return hero, nil
}
// Create inserts a new hero into the database.
// The hero.ID field is populated from the RETURNING clause.
// Default weapon_id=1 (Rusty Dagger) and armor_id=1 (Leather Armor).
func (s *HeroStore) Create(ctx context.Context, hero *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()
// Default equipment IDs.
var weaponID int64 = 1
var armorID int64 = 1
hero.WeaponID = &weaponID
@ -267,6 +270,10 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
hero.CreatedAt = now
hero.UpdatedAt = now
if hero.MoveState == "" {
hero.MoveState = string(model.StateWalking)
}
buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
query := `
@ -281,7 +288,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
position_x, position_y, potions,
total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops,
last_online_at,
created_at, updated_at
created_at, updated_at,
current_town_id, destination_town_id, move_state
) VALUES (
$1, $2,
$3, $4, $5, $6, $7,
@ -293,7 +301,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
$24, $25, $26,
$27, $28, $29, $30, $31,
$32,
$33, $34
$33, $34,
$35, $36, $37
) RETURNING id
`
@ -309,15 +318,173 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops,
hero.LastOnlineAt,
hero.CreatedAt, hero.UpdatedAt,
hero.CurrentTownID, hero.DestinationTownID, hero.MoveState,
).Scan(&hero.ID)
if err != nil {
return fmt.Errorf("insert hero: %w", err)
}
// Create default starter gear and equip it.
return nil
}
// Create inserts a new hero into the database with legacy default starter gear (dagger + leather).
func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
if err := s.insertNewHeroRow(ctx, hero); err != nil {
return err
}
if err := s.createDefaultGear(ctx, hero.ID); err != nil {
return fmt.Errorf("create default gear: %w", err)
}
return nil
}
func (s *HeroStore) randomTownCoords(ctx context.Context) (id int64, wx, wy float64, err error) {
err = s.pool.QueryRow(ctx, `
SELECT id, world_x, world_y FROM towns ORDER BY random() LIMIT 1`,
).Scan(&id, &wx, &wy)
if err != nil {
return 0, 0, 0, fmt.Errorf("random town: %w", err)
}
return id, wx, wy, nil
}
func (s *HeroStore) randomOutgoingTownID(ctx context.Context, fromTownID int64) (int64, error) {
var to int64
err := s.pool.QueryRow(ctx, `
SELECT to_town_id FROM roads WHERE from_town_id = $1 ORDER BY random() LIMIT 1`, fromTownID,
).Scan(&to)
return to, err
}
func (s *HeroStore) pickBirthTownAndDestination(ctx context.Context) (birthID, destID int64, bx, by float64, err error) {
for i := 0; i < 12; i++ {
birthID, bx, by, err = s.randomTownCoords(ctx)
if err != nil {
return 0, 0, 0, 0, err
}
destID, err = s.randomOutgoingTownID(ctx, birthID)
if err == nil && destID != 0 && destID != birthID {
return birthID, destID, bx, by, nil
}
}
err = s.pool.QueryRow(ctx, `
SELECT r.from_town_id, r.to_town_id, tf.world_x, tf.world_y
FROM roads r
JOIN towns tf ON tf.id = r.from_town_id
ORDER BY random() LIMIT 1`,
).Scan(&birthID, &destID, &bx, &by)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("pick spawn on random road: %w", err)
}
return birthID, destID, bx, by, nil
}
// CreateHeroWithSpawn creates a new hero after the player chose a name: random birth town,
// 100 gold, random common ilvl-1 sword and armor, destination a town reachable by road.
func (s *HeroStore) CreateHeroWithSpawn(ctx context.Context, telegramID int64, name string) (*model.Hero, error) {
birthID, destID, bx, by, err := s.pickBirthTownAndDestination(ctx)
if err != nil {
return nil, err
}
birth := birthID
dest := destID
hero := &model.Hero{
TelegramID: telegramID,
Name: name,
HP: 100,
MaxHP: 100,
Attack: 10,
Defense: 5,
Speed: 1.0,
Strength: 1,
Constitution: 1,
Agility: 1,
Luck: 1,
State: model.StateWalking,
Gold: 100,
XP: 0,
Level: 1,
PositionX: bx,
PositionY: by,
CurrentTownID: &birth,
DestinationTownID: &dest,
MoveState: string(model.StateWalking),
BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(),
}
if err := s.insertNewHeroRow(ctx, hero); err != nil {
return nil, err
}
if err := s.createRandomStarterGear(ctx, hero.ID); err != nil {
return nil, fmt.Errorf("create starter gear: %w", err)
}
return s.GetByID(ctx, hero.ID)
}
func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) error {
swords := []struct {
name string
}{
{"Worn Shortsword"},
{"Traveler's Blade"},
{"Notched Sword"},
{"Training Sword"},
}
sw := swords[rand.Intn(len(swords))]
starterWeapon := &model.GearItem{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand.sword",
Name: sw.name,
Subtype: "sword",
Rarity: model.RarityCommon,
Ilvl: 1,
BasePrimary: 4,
PrimaryStat: 4,
StatType: "attack",
SpeedModifier: 1.0,
CritChance: 0.05,
}
if err := s.gearStore.CreateItem(ctx, starterWeapon); err != nil {
return fmt.Errorf("create starter sword: %w", err)
}
if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil {
return fmt.Errorf("equip starter sword: %w", err)
}
armors := []struct {
name string
formID string
subtype string
speed float64
agilityBon int
defense int
}{
{"Worn Leather Jack", "gear.form.chest.leather", "light", 1.05, 2, 3},
{"Rusty Hauberk", "gear.form.chest.mail", "medium", 1.0, 0, 4},
{"Worn Plate", "gear.form.chest.plate", "heavy", 0.7, 0, 5},
}
ar := armors[rand.Intn(len(armors))]
starterArmor := &model.GearItem{
Slot: model.SlotChest,
FormID: ar.formID,
Name: ar.name,
Subtype: ar.subtype,
Rarity: model.RarityCommon,
Ilvl: 1,
BasePrimary: ar.defense,
PrimaryStat: ar.defense,
StatType: "defense",
SpeedModifier: ar.speed,
AgilityBonus: ar.agilityBon,
}
if err := s.gearStore.CreateItem(ctx, starterArmor); err != nil {
return fmt.Errorf("create starter armor: %w", err)
}
if err := s.gearStore.EquipItem(ctx, heroID, model.SlotChest, starterArmor.ID); err != nil {
return fmt.Errorf("equip starter armor: %w", err)
}
return nil
}
@ -449,52 +616,6 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
return nil
}
// GetOrCreate loads a hero by Telegram ID, creating one with default stats if not found.
// This is the main entry point used by auth and hero init flows.
func (s *HeroStore) GetOrCreate(ctx context.Context, telegramID int64, name string) (*model.Hero, error) {
hero, err := s.GetByTelegramID(ctx, telegramID)
if err != nil {
return nil, fmt.Errorf("get or create hero: %w", err)
}
if hero != nil {
hero.XPToNext = model.XPToNextLevel(hero.Level)
return hero, nil
}
// Create a new hero with default stats.
hero = &model.Hero{
TelegramID: telegramID,
Name: name,
HP: 100,
MaxHP: 100,
Attack: 10,
Defense: 5,
Speed: 1.0,
Strength: 1,
Constitution: 1,
Agility: 1,
Luck: 1,
State: model.StateWalking,
Gold: 0,
XP: 0,
Level: 1,
BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(),
}
if err := s.Create(ctx, hero); err != nil {
return nil, fmt.Errorf("get or create hero: %w", err)
}
// Reload to get the gear and buff data.
hero, err = s.GetByID(ctx, hero.ID)
if err != nil {
return nil, fmt.Errorf("get or create hero reload: %w", err)
}
return hero, 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).

@ -0,0 +1,33 @@
package storage
import (
"math/rand/v2"
"slices"
"github.com/denisovdennis/autohero/internal/model"
)
// FilterCapOfferableQuests drops quest templates whose id is in taken, then shuffles the rest
// deterministically from seed and returns at most limit entries. If limit <= 0, returns all offerable quests (still filtered).
func FilterCapOfferableQuests(all []model.Quest, taken map[int64]struct{}, limit int, seed int64) []model.Quest {
var offer []model.Quest
for _, q := range all {
if _, skip := taken[q.ID]; skip {
continue
}
offer = append(offer, q)
}
if len(offer) == 0 {
return offer
}
if limit <= 0 || len(offer) <= limit {
return offer
}
shuffled := slices.Clone(offer)
rng := rand.New(rand.NewPCG(uint64(seed), uint64(seed>>32)^0x9e3779b97f4a7c15))
for i := len(shuffled) - 1; i > 0; i-- {
j := rng.IntN(i + 1)
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
}
return shuffled[:limit]
}

@ -0,0 +1,50 @@
package storage
import (
"testing"
"github.com/denisovdennis/autohero/internal/model"
)
func TestFilterCapOfferableQuests_filtersTaken(t *testing.T) {
all := []model.Quest{
{ID: 1, Title: "a"},
{ID: 2, Title: "b"},
{ID: 3, Title: "c"},
}
taken := map[int64]struct{}{2: {}}
out := FilterCapOfferableQuests(all, taken, 10, 42)
if len(out) != 2 {
t.Fatalf("len=%d want 2", len(out))
}
for _, q := range out {
if q.ID == 2 {
t.Fatal("taken quest should be removed")
}
}
}
func TestFilterCapOfferableQuests_capDeterministic(t *testing.T) {
all := []model.Quest{
{ID: 10, Title: "a"},
{ID: 20, Title: "b"},
{ID: 30, Title: "c"},
}
out1 := FilterCapOfferableQuests(all, nil, 2, 999)
out2 := FilterCapOfferableQuests(all, nil, 2, 999)
if len(out1) != 2 || len(out2) != 2 {
t.Fatalf("cap: len1=%d len2=%d", len(out1), len(out2))
}
if out1[0].ID != out2[0].ID || out1[1].ID != out2[1].ID {
t.Fatalf("same seed should produce same order: %#v vs %#v", out1, out2)
}
_ = FilterCapOfferableQuests(all, nil, 2, 1000)
}
func TestFilterCapOfferableQuests_limitZeroReturnsAll(t *testing.T) {
all := []model.Quest{{ID: 1}, {ID: 2}}
out := FilterCapOfferableQuests(all, nil, 0, 1)
if len(out) != 2 {
t.Fatalf("len=%d want 2", len(out))
}
}

@ -71,7 +71,7 @@ func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, er
// ListNPCsByTown returns all NPCs in the given town.
func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, town_id, name, type, offset_x, offset_y
SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs
WHERE town_id = $1
ORDER BY id ASC
@ -84,7 +84,7 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.
var npcs []model.NPC
for rows.Next() {
var n model.NPC
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY); err != nil {
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
return nil, fmt.Errorf("scan npc: %w", err)
}
npcs = append(npcs, n)
@ -102,9 +102,9 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.
func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) {
var n model.NPC
err := s.pool.QueryRow(ctx, `
SELECT id, town_id, name, type, offset_x, offset_y
SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs WHERE id = $1
`, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY)
`, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
@ -117,7 +117,7 @@ func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, e
// ListAllNPCs returns every NPC across all towns.
func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, town_id, name, type, offset_x, offset_y
SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs
ORDER BY town_id ASC, id ASC
`)
@ -129,7 +129,7 @@ func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
var npcs []model.NPC
for rows.Next() {
var n model.NPC
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY); err != nil {
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
return nil, fmt.Errorf("scan npc: %w", err)
}
npcs = append(npcs, n)
@ -143,6 +143,65 @@ func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
return npcs, nil
}
// ListBuildingsByTown returns all buildings in the given town.
func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h
FROM town_buildings
WHERE town_id = $1
ORDER BY id ASC
`, townID)
if err != nil {
return nil, fmt.Errorf("list buildings by town: %w", err)
}
defer rows.Close()
var buildings []model.TownBuilding
for rows.Next() {
var b model.TownBuilding
if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
return nil, fmt.Errorf("scan building: %w", err)
}
buildings = append(buildings, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list buildings rows: %w", err)
}
if buildings == nil {
buildings = []model.TownBuilding{}
}
return buildings, nil
}
// ListAllBuildings returns every building across all towns (for road_graph preload).
func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h
FROM town_buildings
ORDER BY town_id ASC, id ASC
`)
if err != nil {
return nil, fmt.Errorf("list all buildings: %w", err)
}
defer rows.Close()
var buildings []model.TownBuilding
for rows.Next() {
var b model.TownBuilding
if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil {
return nil, fmt.Errorf("scan building: %w", err)
}
buildings = append(buildings, b)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list all buildings rows: %w", err)
}
if buildings == nil {
buildings = []model.TownBuilding{}
}
return buildings, nil
}
// ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range.
func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, `
@ -179,6 +238,47 @@ func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int6
return quests, nil
}
// HeroTakenQuestTemplateIDs returns quest template ids already present in the hero's log (any status).
func (s *QuestStore) HeroTakenQuestTemplateIDs(ctx context.Context, heroID int64) ([]int64, error) {
rows, err := s.pool.Query(ctx, `SELECT quest_id FROM hero_quests WHERE hero_id = $1`, heroID)
if err != nil {
return nil, fmt.Errorf("hero taken quest ids: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan quest_id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("hero taken quest ids rows: %w", err)
}
return ids, nil
}
// ListOfferableQuestsForNPC returns level-matching NPC quests excluding templates already on the hero's log.
// limit comes from tuning (e.g. questOffersPerNPC). timeBucket is a stable bucket (e.g. unixSeconds/7200) for rotations.
func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcID int64, heroLevel int, limit int, timeBucket int64) ([]model.Quest, error) {
all, err := s.ListQuestsByNPCForHeroLevel(ctx, npcID, heroLevel)
if err != nil {
return nil, err
}
takenIDs, err := s.HeroTakenQuestTemplateIDs(ctx, heroID)
if err != nil {
return nil, err
}
taken := make(map[int64]struct{}, len(takenIDs))
for _, id := range takenIDs {
taken[id] = struct{}{}
}
seed := npcID ^ timeBucket
return FilterCapOfferableQuests(all, taken, limit, seed), nil
}
// ListQuestsByNPC returns all quest templates offered by the given NPC.
func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, `
@ -301,17 +401,29 @@ func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) er
}
// AcceptQuest creates a hero_quests row for the given hero and quest.
// Returns an error if the quest is already accepted/active.
// Returns an error if the quest is already present in the log (any status).
func (s *QuestStore) AcceptQuest(ctx context.Context, heroID int64, questID int64) error {
_, err := s.pool.Exec(ctx, `
inserted, err := s.TryAcceptQuest(ctx, heroID, questID)
if err != nil {
return err
}
if !inserted {
return fmt.Errorf("quest already in log")
}
return nil
}
// TryAcceptQuest inserts an accepted hero_quest row when none exists yet. Returns whether a row was inserted.
func (s *QuestStore) TryAcceptQuest(ctx context.Context, heroID int64, questID int64) (bool, error) {
tag, err := s.pool.Exec(ctx, `
INSERT INTO hero_quests (hero_id, quest_id, status, progress, accepted_at)
VALUES ($1, $2, 'accepted', 0, now())
ON CONFLICT (hero_id, quest_id) DO NOTHING
`, heroID, questID)
if err != nil {
return fmt.Errorf("accept quest: %w", err)
return false, fmt.Errorf("try accept quest: %w", err)
}
return nil
return tag.RowsAffected() > 0, nil
}
// ListHeroQuests returns all quests for the hero with their quest template joined.
@ -320,10 +432,13 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress,
hq.accepted_at, hq.completed_at, hq.claimed_at,
q.id, q.npc_id, q.title, q.description, q.type, q.target_count,
q.target_enemy_type, q.target_town_id, q.drop_chance,
q.target_enemy_type, q.target_town_id,
COALESCE(tt.name, '') AS target_town_name,
q.drop_chance,
q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions
FROM hero_quests hq
JOIN quests q ON hq.quest_id = q.id
LEFT JOIN towns tt ON tt.id = q.target_town_id
WHERE hq.hero_id = $1
ORDER BY hq.accepted_at DESC
`, heroID)
@ -340,7 +455,7 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
&hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress,
&hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt,
&q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetTownID, &q.DropChance,
&q.TargetEnemyType, &q.TargetTownID, &q.TargetTownName, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil {
return nil, fmt.Errorf("scan hero quest: %w", err)
@ -410,8 +525,9 @@ func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, o
}
// ClaimQuestReward marks a completed quest as claimed and returns the rewards.
// heroQuestID is hero_quests.id (quest log row), not the quests template id.
// Returns an error if the quest is not in 'completed' status.
func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, questID int64) (*model.QuestReward, error) {
func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, heroQuestID int64) (*model.QuestReward, error) {
var reward model.QuestReward
err := s.pool.QueryRow(ctx, `
@ -423,7 +539,7 @@ func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, questID
AND hq.id = $2
AND hq.status = 'completed'
RETURNING q.reward_xp, q.reward_gold, q.reward_potions
`, heroID, questID).Scan(&reward.XP, &reward.Gold, &reward.Potions)
`, heroID, heroQuestID).Scan(&reward.XP, &reward.Gold, &reward.Potions)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("quest not found or not in completed status")
@ -434,13 +550,13 @@ func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, questID
return &reward, nil
}
// AbandonQuest removes a hero's quest entry. Only accepted/completed quests
// can be abandoned (not already claimed).
func (s *QuestStore) AbandonQuest(ctx context.Context, heroID int64, questID int64) error {
// AbandonQuest removes a hero's quest log row. heroQuestID is hero_quests.id (same id the client uses for claim).
// Only accepted/completed quests can be abandoned (not already claimed).
func (s *QuestStore) AbandonQuest(ctx context.Context, heroID int64, heroQuestID int64) error {
tag, err := s.pool.Exec(ctx, `
DELETE FROM hero_quests
WHERE hero_id = $1 AND quest_id = $2 AND status != 'claimed'
`, heroID, questID)
WHERE hero_id = $1 AND id = $2 AND status != 'claimed'
`, heroID, heroQuestID)
if err != nil {
return fmt.Errorf("abandon quest: %w", err)
}

@ -0,0 +1,38 @@
package storage
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type RuntimeConfigStore struct {
pool *pgxpool.Pool
}
func NewRuntimeConfigStore(pool *pgxpool.Pool) *RuntimeConfigStore {
return &RuntimeConfigStore{pool: pool}
}
func (s *RuntimeConfigStore) LoadRuntimeConfigPayload(ctx context.Context) ([]byte, error) {
var payload []byte
err := s.pool.QueryRow(ctx, `SELECT payload FROM runtime_config WHERE id = TRUE`).Scan(&payload)
if err != nil {
return nil, fmt.Errorf("load runtime config payload: %w", err)
}
return payload, nil
}
func (s *RuntimeConfigStore) SaveRuntimeConfigPayload(ctx context.Context, payload []byte) error {
_, err := s.pool.Exec(ctx, `
UPDATE runtime_config
SET payload = $1::jsonb, updated_at = now()
WHERE id = TRUE
`, payload)
if err != nil {
return fmt.Errorf("save runtime config payload: %w", err)
}
return nil
}

@ -0,0 +1,96 @@
package storage
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/denisovdennis/autohero/internal/model"
)
const heroTownSessionKeyFmt = "autohero:v1:hero:%d:town_session"
// HeroTownSessionRedis is the last persisted in-town NPC tour snapshot (reconnect / crash recovery).
type HeroTownSessionRedis struct {
SavedAtUnixNano int64 `json:"savedAtUnixNano"`
State model.GameState `json:"state"`
CurrentTownID int64 `json:"currentTownId,omitempty"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
TownPause *model.TownPausePersisted `json:"townPause,omitempty"`
}
// TownSessionStore mirrors in-town hero state to Redis for faster/stale-DB-safe reconnect.
type TownSessionStore struct {
rdb *redis.Client
}
// NewTownSessionStore returns a store backed by Redis, or nil if rdb is nil.
func NewTownSessionStore(rdb *redis.Client) *TownSessionStore {
if rdb == nil {
return nil
}
return &TownSessionStore{rdb: rdb}
}
func (s *TownSessionStore) key(heroID int64) string {
return fmt.Sprintf(heroTownSessionKeyFmt, heroID)
}
// Save stores the hero's in-town session. Caller must set hero.TownPause (e.g. after SyncToHero).
func (s *TownSessionStore) Save(ctx context.Context, heroID int64, h *model.Hero) error {
if s == nil || s.rdb == nil || h == nil {
return nil
}
if h.State != model.StateInTown {
return nil
}
var townID int64
if h.CurrentTownID != nil {
townID = *h.CurrentTownID
}
payload := HeroTownSessionRedis{
SavedAtUnixNano: time.Now().UnixNano(),
State: h.State,
CurrentTownID: townID,
PositionX: h.PositionX,
PositionY: h.PositionY,
TownPause: h.TownPause,
}
b, err := json.Marshal(payload)
if err != nil {
return err
}
return s.rdb.Set(ctx, s.key(heroID), b, 72*time.Hour).Err()
}
// Delete removes the in-town session key (hero left town or state no longer in_town).
func (s *TownSessionStore) Delete(ctx context.Context, heroID int64) error {
if s == nil || s.rdb == nil {
return nil
}
return s.rdb.Del(ctx, s.key(heroID)).Err()
}
// Load returns the stored session, or (nil, nil) if missing.
func (s *TownSessionStore) Load(ctx context.Context, heroID int64) (*HeroTownSessionRedis, error) {
if s == nil || s.rdb == nil {
return nil, nil
}
b, err := s.rdb.Get(ctx, s.key(heroID)).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
return nil, err
}
var p HeroTownSessionRedis
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
return &p, nil
}

@ -0,0 +1,110 @@
package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// httpClient is a shared client with sensible timeouts for Telegram Bot API calls.
var httpClient = &http.Client{
Timeout: 10 * time.Second,
}
// apiResponse is the generic envelope returned by every Bot API method.
type apiResponse struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Description string `json:"description,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
}
// CallBotAPI sends a JSON request to the Telegram Bot API and returns the result field.
func CallBotAPI(botToken, method string, payload any) (json.RawMessage, error) {
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", botToken, method)
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("telegram: marshal payload: %w", err)
}
resp, err := httpClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("telegram: POST %s: %w", method, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("telegram: read response: %w", err)
}
var apiResp apiResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("telegram: unmarshal response: %w", err)
}
if !apiResp.OK {
return nil, fmt.Errorf("telegram: %s failed (%d): %s", method, apiResp.ErrorCode, apiResp.Description)
}
return apiResp.Result, nil
}
// LabeledAmount represents one price component in a Telegram invoice.
type LabeledAmount struct {
Label string `json:"label"`
Amount int `json:"amount"` // smallest currency unit (kopecks for RUB)
}
// InvoiceLinkParams holds the parameters for createInvoiceLink.
type InvoiceLinkParams struct {
Title string `json:"title"`
Description string `json:"description"`
Payload string `json:"payload"`
ProviderToken string `json:"provider_token"`
Currency string `json:"currency"`
Prices []LabeledAmount `json:"prices"`
}
// CreateInvoiceLink calls the Telegram Bot API createInvoiceLink method
// and returns the HTTPS invoice URL the client can pass to openInvoice().
func CreateInvoiceLink(botToken string, params InvoiceLinkParams) (string, error) {
raw, err := CallBotAPI(botToken, "createInvoiceLink", params)
if err != nil {
return "", err
}
var link string
if err := json.Unmarshal(raw, &link); err != nil {
return "", fmt.Errorf("telegram: unmarshal invoice link: %w", err)
}
return link, nil
}
// AnswerPreCheckoutQuery responds to a Telegram pre_checkout_query.
// ok=true approves; ok=false + errorMsg declines.
func AnswerPreCheckoutQuery(botToken, queryID string, ok bool, errorMsg string) error {
payload := map[string]any{
"pre_checkout_query_id": queryID,
"ok": ok,
}
if !ok && errorMsg != "" {
payload["error_message"] = errorMsg
}
_, err := CallBotAPI(botToken, "answerPreCheckoutQuery", payload)
return err
}
// SetWebhook configures the Telegram webhook URL for payment callbacks.
func SetWebhook(botToken, webhookURL string) error {
payload := map[string]string{
"url": webhookURL,
}
_, err := CallBotAPI(botToken, "setWebhook", payload)
return err
}

@ -0,0 +1,455 @@
package tuning
import (
"context"
"encoding/json"
"log/slog"
"sync/atomic"
"time"
)
// Values contains runtime-tunable gameplay knobs loaded from DB.
// Missing JSON fields keep default values.
type Values struct {
EncounterCooldownBaseMs int64 `json:"encounterCooldownBaseMs"`
EncounterActivityBase float64 `json:"encounterActivityBase"`
BaseMoveSpeed float64 `json:"baseMoveSpeed"`
MovementTickRateMs int64 `json:"movementTickRateMs"`
PositionSyncRateMs int64 `json:"positionSyncRateMs"`
TownRestMinMs int64 `json:"townRestMinMs"`
TownRestMaxMs int64 `json:"townRestMaxMs"`
TownRestHPPerS float64 `json:"townRestHpPerSecond"`
TownArrivalRadius float64 `json:"townArrivalRadius"`
TownNPCVisitChance float64 `json:"townNpcVisitChance"`
// TownNPCApproachChance: second roll after a visit timer fires — whether the hero commits to walking
// toward the next queued NPC. 1.0 = same as legacy (only TownNPCVisitChance gates approach).
TownNPCApproachChance float64 `json:"townNpcApproachChance"`
// TownNPCInteractChance: offline only — after reaching an NPC, probability of “using” services
// (buy potion, full heal, accept a quest) instead of walking past.
TownNPCInteractChance float64 `json:"townNpcInteractChance"`
TownNPCRollMinMs int64 `json:"townNpcRollMinMs"`
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
TownNPCRetryMs int64 `json:"townNpcRetryMs"`
TownNPCPauseMs int64 `json:"townNpcPauseMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"`
// TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach).
TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"`
// TownAfterNPCRestChance: after the NPC tour, at town center — probability of a full town rest
// (same duration/regen as towns without NPCs). Otherwise only a short TownNPCPauseMs wait.
TownAfterNPCRestChance float64 `json:"townAfterNpcRestChance"`
WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"`
MerchantCostBase int64 `json:"merchantCostBase"`
MerchantCostPerLevel int64 `json:"merchantCostPerLevel"`
MerchantTownAutoSellShare float64 `json:"merchantTownAutoSellShare"`
MonsterEncounterWeightBase float64 `json:"monsterEncounterWeightBase"`
MonsterEncounterWeightWildBonus float64 `json:"monsterEncounterWeightWildBonus"`
MerchantEncounterWeightBase float64 `json:"merchantEncounterWeightBase"`
MerchantEncounterWeightRoadBonus float64 `json:"merchantEncounterWeightRoadBonus"`
LootChanceCommon float64 `json:"lootChanceCommon"`
LootChanceUncommon float64 `json:"lootChanceUncommon"`
LootChanceRare float64 `json:"lootChanceRare"`
LootChanceEpic float64 `json:"lootChanceEpic"`
LootChanceLegendary float64 `json:"lootChanceLegendary"`
GoldLootScale float64 `json:"goldLootScale"`
PotionDropChance float64 `json:"potionDropChance"`
EquipmentDropBase float64 `json:"equipmentDropBase"`
GoldCommonMin int64 `json:"goldCommonMin"`
GoldCommonMax int64 `json:"goldCommonMax"`
GoldUncommonMin int64 `json:"goldUncommonMin"`
GoldUncommonMax int64 `json:"goldUncommonMax"`
GoldRareMin int64 `json:"goldRareMin"`
GoldRareMax int64 `json:"goldRareMax"`
GoldEpicMin int64 `json:"goldEpicMin"`
GoldEpicMax int64 `json:"goldEpicMax"`
GoldLegendaryMin int64 `json:"goldLegendaryMin"`
GoldLegendaryMax int64 `json:"goldLegendaryMax"`
AutoSellCommon int64 `json:"autoSellCommon"`
AutoSellUncommon int64 `json:"autoSellUncommon"`
AutoSellRare int64 `json:"autoSellRare"`
AutoSellEpic int64 `json:"autoSellEpic"`
AutoSellLegendary int64 `json:"autoSellLegendary"`
RESTEncounterCooldownMs int64 `json:"restEncounterCooldownMs"`
RESTEncounterNPCChance float64 `json:"restEncounterNpcChance"`
NPCCostHeal int64 `json:"npcCostHeal"`
NPCCostPotion int64 `json:"npcCostPotion"`
NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"`
// QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests).
QuestOffersPerNPC int `json:"questOffersPerNPC"`
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
QuestOfferRefreshHours int `json:"questOfferRefreshHours"`
CombatDamageScale float64 `json:"combatDamageScale"`
CombatDamageRollMin float64 `json:"combatDamageRollMin"`
CombatDamageRollMax float64 `json:"combatDamageRollMax"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
HeroCritChanceCap float64 `json:"heroCritChanceCap"`
HeroBlockChancePerDefense float64 `json:"heroBlockChancePerDefense"`
HeroBlockChanceCap float64 `json:"heroBlockChanceCap"`
EnemyBurstEveryN int64 `json:"enemyBurstEveryN"`
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
EnemyChainEveryN int64 `json:"enemyChainEveryN"`
EnemyChainMultiplier float64 `json:"enemyChainMultiplier"`
DebuffProcBurn float64 `json:"debuffProcBurn"`
DebuffProcPoison float64 `json:"debuffProcPoison"`
DebuffProcSlow float64 `json:"debuffProcSlow"`
DebuffProcStun float64 `json:"debuffProcStun"`
DebuffProcFreeze float64 `json:"debuffProcFreeze"`
DebuffProcIceSlow float64 `json:"debuffProcIceSlow"`
EnemyRegenDefault float64 `json:"enemyRegenDefault"`
EnemyRegenSkeletonKing float64 `json:"enemyRegenSkeletonKing"`
EnemyRegenForestWarden float64 `json:"enemyRegenForestWarden"`
EnemyRegenBattleLizard float64 `json:"enemyRegenBattleLizard"`
SummonCycleSeconds int64 `json:"summonCycleSeconds"`
SummonDamageDivisor int64 `json:"summonDamageDivisor"`
LuckBuffMultiplier float64 `json:"luckBuffMultiplier"`
MinAttackIntervalMs int64 `json:"minAttackIntervalMs"`
CombatPaceMultiplier int64 `json:"combatPaceMultiplier"`
PotionHealPercent float64 `json:"potionHealPercent"`
PotionAutoUseThreshold float64 `json:"potionAutoUseThreshold"`
ReviveHpPercent float64 `json:"reviveHpPercent"`
AutoReviveAfterMs int64 `json:"autoReviveAfterMs"`
XPCurveEarlyBase float64 `json:"xpCurveEarlyBase"`
XPCurveEarlyScale float64 `json:"xpCurveEarlyScale"`
XPCurveMidBase float64 `json:"xpCurveMidBase"`
XPCurveMidScale float64 `json:"xpCurveMidScale"`
XPCurveLateBase float64 `json:"xpCurveLateBase"`
XPCurveLateScale float64 `json:"xpCurveLateScale"`
LevelUpHPEvery int64 `json:"levelUpHpEvery"`
LevelUpATKEvery int64 `json:"levelUpAtkEvery"`
LevelUpDEFEvery int64 `json:"levelUpDefEvery"`
LevelUpSTREvery int64 `json:"levelUpStrEvery"`
LevelUpCONEvery int64 `json:"levelUpConEvery"`
LevelUpAGIEvery int64 `json:"levelUpAgiEvery"`
LevelUpLUCKEvery int64 `json:"levelUpLuckEvery"`
AgilityCoef float64 `json:"agilityCoef"`
MaxAttackSpeed float64 `json:"maxAttackSpeed"`
MinAttackSpeed float64 `json:"minAttackSpeed"`
IlvlFactorSlope float64 `json:"ilvlFactorSlope"`
RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"`
RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"`
RarityMultiplierRare float64 `json:"rarityMultiplierRare"`
RarityMultiplierEpic float64 `json:"rarityMultiplierEpic"`
RarityMultiplierLegendary float64 `json:"rarityMultiplierLegendary"`
RollIlvlEliteBaseChance float64 `json:"rollIlvlEliteBaseChance"`
RollIlvlElitePlusOneChance float64 `json:"rollIlvlElitePlusOneChance"`
BuffChargePeriodMs int64 `json:"buffChargePeriodMs"`
FreeBuffActivationsPerPeriod int64 `json:"freeBuffActivationsPerPeriod"`
SubscriptionDurationMs int64 `json:"subscriptionDurationMs"`
SubscriptionWeeklyPriceRUB int64 `json:"subscriptionWeeklyPriceRub"`
BuffRefillPriceRUB int64 `json:"buffRefillPriceRub"`
ResurrectionRefillPriceRUB int64 `json:"resurrectionRefillPriceRub"`
MaxRevivesFree int64 `json:"maxRevivesFree"`
MaxRevivesSubscriber int64 `json:"maxRevivesSubscriber"`
EnemyScaleBandHP float64 `json:"enemyScaleBandHp"`
EnemyScaleOvercapHP float64 `json:"enemyScaleOvercapHp"`
EnemyScaleBandATK float64 `json:"enemyScaleBandAtk"`
EnemyScaleOvercapATK float64 `json:"enemyScaleOvercapAtk"`
EnemyScaleBandDEF float64 `json:"enemyScaleBandDef"`
EnemyScaleOvercapDEF float64 `json:"enemyScaleOvercapDef"`
EnemyScaleBandXP float64 `json:"enemyScaleBandXp"`
EnemyScaleOvercapXP float64 `json:"enemyScaleOvercapXp"`
EnemyScaleBandGold float64 `json:"enemyScaleBandGold"`
EnemyScaleOvercapGold float64 `json:"enemyScaleOvercapGold"`
AutoEquipThreshold float64 `json:"autoEquipThreshold"`
LootHistoryLimit int64 `json:"lootHistoryLimit"`
// --- Adventure / excursion (mini-adventure off-road) ---
// AdventureStartChance is the per-tick probability of starting an adventure while walking.
// With 500ms ticks and ~50% walking uptime, 0.0001 ≈ 3 adventures per 8 h.
AdventureStartChance float64 `json:"adventureStartChance"`
// AdventureCooldownMs is the minimum wall-time between two adventure sessions.
AdventureCooldownMs int64 `json:"adventureCooldownMs"`
// AdventureOutDurationMs is how long the "out" phase lasts (hero moves off-road into forest).
AdventureOutDurationMs int64 `json:"adventureOutDurationMs"`
// AdventureWildMinMs / AdventureWildMaxMs define the random range for the "wild" phase
// (encounters in the forest). Total adventure ≈ out + wild + return.
AdventureWildMinMs int64 `json:"adventureWildMinMs"`
AdventureWildMaxMs int64 `json:"adventureWildMaxMs"`
// AdventureReturnDurationMs is how long the "return" phase lasts (hero walks back to road).
AdventureReturnDurationMs int64 `json:"adventureReturnDurationMs"`
// AdventureDepthWorldUnits is the max perpendicular offset from road during an adventure.
AdventureDepthWorldUnits float64 `json:"adventureDepthWorldUnits"`
// AdventureEncounterCooldownMs is the encounter cooldown while in the wild/return phases.
AdventureEncounterCooldownMs int64 `json:"adventureEncounterCooldownMs"`
// AdventureReturnEncounterEnabled allows encounters during the return phase.
AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"`
// AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return.
AdventureReturnWildnessMin float64 `json:"adventureReturnWildnessMin"`
// --- HP-based rest triggers ---
// LowHpThreshold is the HP/MaxHP fraction below which rest may trigger (0..1).
LowHpThreshold float64 `json:"lowHpThreshold"`
// RoadsideRestExitHp is the HP/MaxHP fraction at which roadside rest ends early (0..1).
RoadsideRestExitHp float64 `json:"roadsideRestExitHp"`
// AdventureRestTargetHp is the HP/MaxHP fraction at which adventure inline rest ends (0..1).
AdventureRestTargetHp float64 `json:"adventureRestTargetHp"`
// RoadsideRestMinMs is the minimum duration for a roadside rest period.
RoadsideRestMinMs int64 `json:"roadsideRestMinMs"`
// RoadsideRestMaxMs is the maximum duration for a roadside rest period.
RoadsideRestMaxMs int64 `json:"roadsideRestMaxMs"`
// RoadsideRestHpPerS is the HP/MaxHP fraction healed per second during roadside rest.
RoadsideRestHpPerS float64 `json:"roadsideRestHpPerSecond"`
// AdventureRestHpPerS is the HP/MaxHP fraction healed per second during adventure inline rest.
AdventureRestHpPerS float64 `json:"adventureRestHpPerSecond"`
// RoadsideRestDepthWorldUnits is the perpendicular offset from road during roadside rest.
RoadsideRestDepthWorldUnits float64 `json:"roadsideRestDepthWorldUnits"`
}
func DefaultValues() Values {
return Values{
EncounterCooldownBaseMs: 12_000,
EncounterActivityBase: 0.035,
BaseMoveSpeed: 2.0,
MovementTickRateMs: 500,
PositionSyncRateMs: 10_000,
TownRestMinMs: 5 * 60 * 1000,
TownRestMaxMs: 20 * 60 * 1000,
TownRestHPPerS: 0.002,
TownArrivalRadius: 0.5,
TownNPCVisitChance: 0.78,
TownNPCApproachChance: 1.0,
TownNPCInteractChance: 0.65,
TownNPCRollMinMs: 800,
TownNPCRollMaxMs: 2600,
TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000,
TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65,
TownAfterNPCRestChance: 0.78,
WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 20,
MerchantCostPerLevel: 5,
MerchantTownAutoSellShare: 0.30,
MonsterEncounterWeightBase: 0.62,
MonsterEncounterWeightWildBonus: 0.18,
MerchantEncounterWeightBase: 0.04,
MerchantEncounterWeightRoadBonus: 0.10,
LootChanceCommon: 0.40,
LootChanceUncommon: 0.10,
LootChanceRare: 0.02,
LootChanceEpic: 0.003,
LootChanceLegendary: 0.0005,
GoldLootScale: 0.5,
PotionDropChance: 0.05,
EquipmentDropBase: 0.15,
GoldCommonMin: 0,
GoldCommonMax: 5,
GoldUncommonMin: 6,
GoldUncommonMax: 20,
GoldRareMin: 21,
GoldRareMax: 50,
GoldEpicMin: 51,
GoldEpicMax: 120,
GoldLegendaryMin: 121,
GoldLegendaryMax: 300,
AutoSellCommon: 3,
AutoSellUncommon: 8,
AutoSellRare: 20,
AutoSellEpic: 60,
AutoSellLegendary: 180,
RESTEncounterCooldownMs: 16_000,
RESTEncounterNPCChance: 0.10,
NPCCostHeal: 100,
NPCCostPotion: 50,
NPCCostNearbyRadius: 3.0,
QuestOffersPerNPC: 2,
QuestOfferRefreshHours: 2,
CombatDamageScale: 0.35,
CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10,
EnemyDodgeChance: 0.20,
EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20,
HeroCritChanceCap: 0.12,
HeroBlockChancePerDefense: 0.0025,
HeroBlockChanceCap: 0.20,
EnemyBurstEveryN: 3,
EnemyBurstMultiplier: 1.5,
EnemyChainEveryN: 6,
EnemyChainMultiplier: 3.0,
DebuffProcBurn: 0.30,
DebuffProcPoison: 0.10,
DebuffProcSlow: 0.25,
DebuffProcStun: 0.25,
DebuffProcFreeze: 0.20,
DebuffProcIceSlow: 0.20,
EnemyRegenDefault: 0.02,
EnemyRegenSkeletonKing: 0.10,
EnemyRegenForestWarden: 0.05,
EnemyRegenBattleLizard: 0.01,
SummonCycleSeconds: 15,
SummonDamageDivisor: 4,
LuckBuffMultiplier: 1.75,
MinAttackIntervalMs: 250,
CombatPaceMultiplier: 5,
PotionHealPercent: 0.30,
PotionAutoUseThreshold: 0.30,
ReviveHpPercent: 0.50,
AutoReviveAfterMs: int64(time.Hour / time.Millisecond),
XPCurveEarlyBase: 180,
XPCurveEarlyScale: 1.28,
XPCurveMidBase: 1450,
XPCurveMidScale: 1.15,
XPCurveLateBase: 23000,
XPCurveLateScale: 1.10,
LevelUpHPEvery: 10,
LevelUpATKEvery: 30,
LevelUpDEFEvery: 30,
LevelUpSTREvery: 40,
LevelUpCONEvery: 50,
LevelUpAGIEvery: 60,
LevelUpLUCKEvery: 100,
AgilityCoef: 0.03,
MaxAttackSpeed: 4.0,
MinAttackSpeed: 0.1,
IlvlFactorSlope: 0.03,
RarityMultiplierCommon: 1.00,
RarityMultiplierUncommon: 1.12,
RarityMultiplierRare: 1.30,
RarityMultiplierEpic: 1.52,
RarityMultiplierLegendary: 1.78,
RollIlvlEliteBaseChance: 0.4,
RollIlvlElitePlusOneChance: 0.4,
BuffChargePeriodMs: 24 * 60 * 60 * 1000,
FreeBuffActivationsPerPeriod: 2,
SubscriptionDurationMs: 7 * 24 * 60 * 60 * 1000,
SubscriptionWeeklyPriceRUB: 299,
BuffRefillPriceRUB: 50,
ResurrectionRefillPriceRUB: 150,
MaxRevivesFree: 1,
MaxRevivesSubscriber: 2,
EnemyScaleBandHP: 0.05,
EnemyScaleOvercapHP: 0.025,
EnemyScaleBandATK: 0.035,
EnemyScaleOvercapATK: 0.018,
EnemyScaleBandDEF: 0.035,
EnemyScaleOvercapDEF: 0.018,
EnemyScaleBandXP: 0.05,
EnemyScaleOvercapXP: 0.03,
EnemyScaleBandGold: 0.05,
EnemyScaleOvercapGold: 0.025,
AutoEquipThreshold: 1.03,
LootHistoryLimit: 50,
AdventureStartChance: 0.0001,
AdventureCooldownMs: 300_000,
AdventureOutDurationMs: 20_000,
AdventureWildMinMs: 560_000,
AdventureWildMaxMs: 2_960_000,
AdventureReturnDurationMs: 20_000,
AdventureDepthWorldUnits: 40.0,
AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true,
AdventureReturnWildnessMin: 0.35,
LowHpThreshold: 0.25,
RoadsideRestExitHp: 0.70,
AdventureRestTargetHp: 0.70,
RoadsideRestMinMs: 240_000,
RoadsideRestMaxMs: 600_000,
RoadsideRestHpPerS: 0.003,
AdventureRestHpPerS: 0.004,
RoadsideRestDepthWorldUnits: 12.0,
}
}
var current atomic.Value
func init() {
v := DefaultValues()
current.Store(&v)
}
func Get() Values {
p := current.Load().(*Values)
return *p
}
// EffectiveNPCShopCosts returns potion and full-heal prices from runtime tuning (DB-merged JSON),
// falling back to defaults when unset or non-positive.
func EffectiveNPCShopCosts() (potionCost, healCost int64) {
cfg := Get()
potionCost = cfg.NPCCostPotion
if potionCost <= 0 {
potionCost = DefaultValues().NPCCostPotion
}
healCost = cfg.NPCCostHeal
if healCost <= 0 {
healCost = DefaultValues().NPCCostHeal
}
return potionCost, healCost
}
// EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning.
func EffectiveQuestOffersPerNPC() int {
n := Get().QuestOffersPerNPC
if n <= 0 {
return DefaultValues().QuestOffersPerNPC
}
return n
}
// EffectiveQuestOfferRefreshHours returns the rotation cadence (hours) for quest_giver offers.
func EffectiveQuestOfferRefreshHours() int {
n := Get().QuestOfferRefreshHours
if n <= 0 {
return DefaultValues().QuestOfferRefreshHours
}
return n
}
func Set(v Values) {
current.Store(&v)
}
type PayloadLoader interface {
LoadRuntimeConfigPayload(ctx context.Context) ([]byte, error)
}
func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) error {
payload, err := loader.LoadRuntimeConfigPayload(ctx)
if err != nil {
if logger != nil {
logger.Warn("runtime config reload failed", "error", err)
}
return err
}
next := DefaultValues()
if len(payload) > 0 {
if err := json.Unmarshal(payload, &next); err != nil {
if logger != nil {
logger.Warn("runtime config payload parse failed", "error", err)
}
return err
}
}
Set(next)
return nil
}

@ -0,0 +1,5 @@
-- Subscription system: weekly subscription with expiry date.
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS subscription_expires_at TIMESTAMPTZ;
-- Payment type for subscription purchases.
-- Existing payments table is reused with type = 'subscription_weekly'.

@ -0,0 +1,126 @@
-- Migration 000019: Wider spiral world — ~3× spacing vs 000018, four new towns on the ring
-- (midpoints along progression segments Willowdale→Thornwatch→Ashengard→Redcliff→Boghollow),
-- full ring roads + road_waypoints recomputed from town centers (same rules as 000017/000018).
-- Level bands (140, non-overlapping) follow road order by level_min.
UPDATE towns SET level_min = 1, level_max = 4 WHERE name = 'Willowdale';
UPDATE towns SET level_min = 9, level_max = 12 WHERE name = 'Thornwatch';
UPDATE towns SET level_min = 17, level_max = 20 WHERE name = 'Ashengard';
UPDATE towns SET level_min = 25, level_max = 27 WHERE name = 'Redcliff';
UPDATE towns SET level_min = 31, level_max = 33 WHERE name = 'Boghollow';
UPDATE towns SET level_min = 34, level_max = 37 WHERE name = 'Cinderkeep';
UPDATE towns SET level_min = 38, level_max = 40 WHERE name = 'Starfall';
-- Positions: 000018 layout scaled ×3 from origin (stronger separation); new towns at segment midpoints.
UPDATE towns SET world_x = 7860, world_y = 2400 WHERE name = 'Willowdale';
UPDATE towns SET world_x = 8778, world_y = 3174 WHERE name = 'Thornwatch';
UPDATE towns SET world_x = 8697, world_y = 4752 WHERE name = 'Ashengard';
UPDATE towns SET world_x = 7197, world_y = 6168 WHERE name = 'Redcliff';
UPDATE towns SET world_x = 4605, world_y = 6378 WHERE name = 'Boghollow';
UPDATE towns SET world_x = 1899, world_y = 4713 WHERE name = 'Cinderkeep';
UPDATE towns SET world_x = 393, world_y = 1980 WHERE name = 'Starfall';
INSERT INTO towns (name, biome, world_x, world_y, radius, level_min, level_max) VALUES
('Mossharbor', 'meadow', 8319, 2787, 14.0, 5, 8),
('Emberwell', 'forest', 8738, 3963, 15.0, 13, 16),
('Frostmark', 'ruins', 7947, 5460, 14.0, 21, 24),
('Duskwatch', 'swamp', 5901, 6273, 14.0, 28, 30)
ON CONFLICT (name) DO NOTHING;
-- NPCs for new settlements (idempotent).
INSERT INTO npcs (town_id, name, type, offset_x, offset_y)
SELECT t.id, v.npc_name, v.npc_type, v.ox, v.oy
FROM (VALUES
('Mossharbor', 'Harbor-ward Lissa', 'quest_giver', -2.5::double precision, 1.0::double precision),
('Mossharbor', 'Dock Trader Milo', 'merchant', 2.5, 0.0),
('Emberwell', 'Ranger Kess', 'quest_giver', 1.0, -2.0),
('Emberwell', 'Ember Outfitter', 'merchant', -2.0, 2.0),
('Frostmark', 'Warden Torvik', 'quest_giver', -1.5, 1.5),
('Frostmark', 'Relic Peddler', 'merchant', 2.0, -1.0),
('Duskwatch', 'Sister Morah', 'quest_giver', 0.0, 2.5),
('Duskwatch', 'Bog Imports', 'merchant', -2.5, -1.0)
) AS v(town_name, npc_name, npc_type, ox, oy)
JOIN towns t ON t.name = v.town_name
WHERE NOT EXISTS (
SELECT 1 FROM npcs n WHERE n.town_id = t.id AND n.name = v.npc_name
);
-- Quest level windows: align with new town bands and progression.
UPDATE quests SET min_level = 1, max_level = 4 WHERE title = 'Wolf Cull';
UPDATE quests SET min_level = 1, max_level = 8 WHERE title = 'Deliver to Thornwatch';
UPDATE quests SET min_level = 2, max_level = 8 WHERE title = 'Boar Hunt';
UPDATE quests SET min_level = 9, max_level = 12 WHERE title IN ('Spider Infestation', 'Spider Fang Collection');
UPDATE quests SET min_level = 9, max_level = 14 WHERE title = 'Forest Patrol';
UPDATE quests SET min_level = 13, max_level = 20 WHERE title IN ('Undead Purge', 'Ancient Relics', 'Report to Redcliff');
UPDATE quests SET min_level = 21, max_level = 27 WHERE title IN ('Orc Raider Cleanup', 'Ore Samples');
UPDATE quests SET min_level = 28, max_level = 33 WHERE title IN ('Swamp Creatures', 'Venomous Harvest', 'Message to Cinderkeep');
UPDATE quests SET min_level = 34, max_level = 37 WHERE title IN ('Demon Slayer', 'Infernal Cores');
UPDATE quests SET min_level = 38, max_level = 40 WHERE title IN ('Titan''s Challenge', 'Void Fragments', 'Full Circle');
-- Replace road graph: bidirectional ring in level order + wrap Starfall → Willowdale.
DELETE FROM road_waypoints;
DELETE FROM roads;
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1000.0
FROM (VALUES
('Willowdale', 'Mossharbor'),
('Mossharbor', 'Thornwatch'),
('Thornwatch', 'Emberwell'),
('Emberwell', 'Ashengard'),
('Ashengard', 'Frostmark'),
('Frostmark', 'Redcliff'),
('Redcliff', 'Duskwatch'),
('Duskwatch', 'Boghollow'),
('Boghollow', 'Cinderkeep'),
('Cinderkeep', 'Starfall'),
('Starfall', 'Willowdale')
) AS seg(from_name, to_name)
JOIN towns f ON f.name = seg.from_name
JOIN towns t ON t.name = seg.to_name;
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT t.id, f.id, 1000.0
FROM (VALUES
('Willowdale', 'Mossharbor'),
('Mossharbor', 'Thornwatch'),
('Thornwatch', 'Emberwell'),
('Emberwell', 'Ashengard'),
('Ashengard', 'Frostmark'),
('Frostmark', 'Redcliff'),
('Redcliff', 'Duskwatch'),
('Duskwatch', 'Boghollow'),
('Boghollow', 'Cinderkeep'),
('Cinderkeep', 'Starfall'),
('Starfall', 'Willowdale')
) AS seg(from_name, to_name)
JOIN towns f ON f.name = seg.from_name
JOIN towns t ON t.name = seg.to_name;
-- Canonical polylines (same segment rule as Go road_graph / 000017 — no jitter).
INSERT INTO road_waypoints (road_id, seq, x, y)
SELECT
r.id,
gs.seq,
CASE
WHEN gs.seq = 0 THEN f.world_x
WHEN gs.seq = seg.nseg THEN t.world_x
ELSE f.world_x + (t.world_x - f.world_x) * (gs.seq::double precision / seg.nseg::double precision)
END,
CASE
WHEN gs.seq = 0 THEN f.world_y
WHEN gs.seq = seg.nseg THEN t.world_y
ELSE f.world_y + (t.world_y - f.world_y) * (gs.seq::double precision / seg.nseg::double precision)
END
FROM roads r
INNER JOIN towns f ON f.id = r.from_town_id
INNER JOIN towns t ON t.id = r.to_town_id
CROSS JOIN LATERAL (
SELECT GREATEST(
1,
FLOOR(
SQRT(POWER(t.world_x - f.world_x, 2) + POWER(t.world_y - f.world_y, 2)) / 20.0
)::integer
) AS nseg
) seg
CROSS JOIN LATERAL generate_series(0, seg.nseg) AS gs(seq);

@ -0,0 +1,10 @@
-- Backpack: unequipped gear (max 40 slots per hero).
CREATE TABLE IF NOT EXISTS hero_inventory (
hero_id BIGINT NOT NULL REFERENCES heroes(id) ON DELETE CASCADE,
slot_index SMALLINT NOT NULL CHECK (slot_index >= 0 AND slot_index < 40),
gear_id BIGINT NOT NULL REFERENCES gear(id) ON DELETE CASCADE,
PRIMARY KEY (hero_id, slot_index),
UNIQUE (gear_id)
);
CREATE INDEX IF NOT EXISTS idx_hero_inventory_hero ON hero_inventory(hero_id);

@ -0,0 +1,5 @@
-- Align heroes.state CHECK with model.GameState (resting / in_town used by town arrival & admin teleport).
ALTER TABLE heroes DROP CONSTRAINT IF EXISTS heroes_state_check;
ALTER TABLE heroes ADD CONSTRAINT heroes_state_check CHECK (
state IN ('walking', 'fighting', 'dead', 'resting', 'in_town')
);

@ -0,0 +1,36 @@
-- Scale all town centers outward from their centroid (~1.45×) so inter-city distances grow
-- while preserving spiral layout. RoadGraph recomputes segment distances in Go; refresh waypoints.
UPDATE towns AS t SET
world_x = c.cx + (t.world_x - c.cx) * 1.45,
world_y = c.cy + (t.world_y - c.cy) * 1.45
FROM (SELECT AVG(world_x) AS cx, AVG(world_y) AS cy FROM towns) AS c;
DELETE FROM road_waypoints;
INSERT INTO road_waypoints (road_id, seq, x, y)
SELECT
r.id,
gs.seq,
CASE
WHEN gs.seq = 0 THEN f.world_x
WHEN gs.seq = seg.nseg THEN t.world_x
ELSE f.world_x + (t.world_x - f.world_x) * (gs.seq::double precision / seg.nseg::double precision)
END,
CASE
WHEN gs.seq = 0 THEN f.world_y
WHEN gs.seq = seg.nseg THEN t.world_y
ELSE f.world_y + (t.world_y - f.world_y) * (gs.seq::double precision / seg.nseg::double precision)
END
FROM roads r
INNER JOIN towns f ON f.id = r.from_town_id
INNER JOIN towns t ON t.id = r.to_town_id
CROSS JOIN LATERAL (
SELECT GREATEST(
1,
FLOOR(
SQRT(POWER(t.world_x - f.world_x, 2) + POWER(t.world_y - f.world_y, 2)) / 20.0
)::integer
) AS nseg
) seg
CROSS JOIN LATERAL generate_series(0, seg.nseg) AS gs(seq);

@ -0,0 +1,2 @@
-- Persist movement timers / in-town NPC tour state so offline simulation can advance resting & town visits.
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS town_pause JSONB NULL;

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS runtime_config (
id BOOLEAN PRIMARY KEY DEFAULT TRUE,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT runtime_config_single_row CHECK (id = TRUE)
);
INSERT INTO runtime_config (id, payload)
VALUES (TRUE, '{}'::jsonb)
ON CONFLICT (id) DO NOTHING;

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS buff_debuff_config (
id BOOLEAN PRIMARY KEY DEFAULT TRUE,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT buff_debuff_config_single_row CHECK (id = TRUE)
);
INSERT INTO buff_debuff_config (id, payload)
VALUES (TRUE, '{}'::jsonb)
ON CONFLICT (id) DO NOTHING;

@ -0,0 +1,99 @@
-- Migration 000026: Town buildings — server-driven layout for towns.
-- Each NPC gets an assigned building; buildings have typed appearances per NPC role.
-- ============================================================
-- Town buildings: persistent structures placed in towns.
-- ============================================================
CREATE TABLE IF NOT EXISTS town_buildings (
id BIGSERIAL PRIMARY KEY,
town_id BIGINT NOT NULL REFERENCES towns(id) ON DELETE CASCADE,
building_type TEXT NOT NULL CHECK (building_type IN (
'house.quest_giver', 'house.merchant', 'house.healer',
'decoration.well', 'decoration.stall', 'decoration.signpost'
)),
offset_x DOUBLE PRECISION NOT NULL DEFAULT 0,
offset_y DOUBLE PRECISION NOT NULL DEFAULT 0,
facing TEXT NOT NULL DEFAULT 'south' CHECK (facing IN ('north','south','east','west')),
footprint_w DOUBLE PRECISION NOT NULL DEFAULT 2.0,
footprint_h DOUBLE PRECISION NOT NULL DEFAULT 2.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_town_buildings_town ON town_buildings(town_id);
-- ============================================================
-- Link NPCs to their buildings (nullable for migration transition).
-- ============================================================
ALTER TABLE npcs ADD COLUMN IF NOT EXISTS building_id BIGINT REFERENCES town_buildings(id) ON DELETE SET NULL;
-- ============================================================
-- Seed buildings for all existing towns, then link NPCs.
-- Layout strategy per town:
-- - NPC buildings are placed in a semicircle around the town center
-- - quest_giver at ~10 o'clock, merchant at ~2 o'clock, healer at ~6 o'clock
-- - A well decoration at center, signpost near entrance
-- ============================================================
-- Helper: create buildings for each town with NPCs, using deterministic offsets by NPC type.
-- quest_giver houses: upper-left zone
-- merchant houses: upper-right zone
-- healer houses: lower-center zone
DO $$
DECLARE
t RECORD;
n RECORD;
new_building_id BIGINT;
btype TEXT;
ox DOUBLE PRECISION;
oy DOUBLE PRECISION;
npc_idx INTEGER;
BEGIN
FOR t IN SELECT id, radius FROM towns ORDER BY id LOOP
npc_idx := 0;
FOR n IN SELECT id, type FROM npcs WHERE town_id = t.id ORDER BY id LOOP
-- Determine building type from NPC type
btype := 'house.' || n.type;
-- Spread NPCs in a semicircle; scale offset by town radius
-- Each NPC gets a distinct angular position
CASE n.type
WHEN 'quest_giver' THEN
ox := -0.45 * t.radius;
oy := -0.25 * t.radius;
WHEN 'merchant' THEN
ox := 0.45 * t.radius;
oy := -0.25 * t.radius;
WHEN 'healer' THEN
ox := 0.0;
oy := 0.45 * t.radius;
ELSE
ox := npc_idx * 2.0;
oy := 0.0;
END CASE;
-- Stagger if multiple NPCs of same type (add small offset per index)
ox := ox + (npc_idx % 3) * 1.5;
INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h)
VALUES (t.id, btype, ox, oy, 'south', 2.5, 2.0)
RETURNING id INTO new_building_id;
-- Link NPC to their building
UPDATE npcs SET building_id = new_building_id WHERE id = n.id;
-- Move NPC offset to be at the building entrance (slightly in front)
UPDATE npcs SET offset_x = ox, offset_y = oy + 1.2 WHERE id = n.id;
npc_idx := npc_idx + 1;
END LOOP;
-- Add a well decoration at town center
INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h)
VALUES (t.id, 'decoration.well', 0, 0, 'south', 1.5, 1.5);
-- Add a signpost near the entrance (south edge)
INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h)
VALUES (t.id, 'decoration.signpost', 0, 0.6 * t.radius, 'south', 0.5, 0.5);
END LOOP;
END $$;

@ -0,0 +1,84 @@
-- Migration 000027: Cross-roads — add shortcut roads between non-adjacent towns
-- so that from some towns there are multiple destination choices.
-- Shortcut 1: Willowdale <-> Ashengard (bypasses Mossharbor + Thornwatch + Emberwell)
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1500.0
FROM towns f, towns t
WHERE f.name = 'Willowdale' AND t.name = 'Ashengard'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1500.0
FROM towns f, towns t
WHERE f.name = 'Ashengard' AND t.name = 'Willowdale'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
-- Shortcut 2: Thornwatch <-> Frostmark (bypasses Emberwell + Ashengard)
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1200.0
FROM towns f, towns t
WHERE f.name = 'Thornwatch' AND t.name = 'Frostmark'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1200.0
FROM towns f, towns t
WHERE f.name = 'Frostmark' AND t.name = 'Thornwatch'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
-- Shortcut 3: Redcliff <-> Cinderkeep (bypasses Duskwatch + Boghollow)
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1400.0
FROM towns f, towns t
WHERE f.name = 'Redcliff' AND t.name = 'Cinderkeep'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1400.0
FROM towns f, towns t
WHERE f.name = 'Cinderkeep' AND t.name = 'Redcliff'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
-- Shortcut 4: Mossharbor <-> Emberwell (bypasses Thornwatch)
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1100.0
FROM towns f, towns t
WHERE f.name = 'Mossharbor' AND t.name = 'Emberwell'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1100.0
FROM towns f, towns t
WHERE f.name = 'Emberwell' AND t.name = 'Mossharbor'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
-- Generate waypoints for the new cross-roads (same rule as migration 000019).
INSERT INTO road_waypoints (road_id, seq, x, y)
SELECT
r.id,
gs.seq,
CASE
WHEN gs.seq = 0 THEN f.world_x
WHEN gs.seq = seg.nseg THEN t.world_x
ELSE f.world_x + (t.world_x - f.world_x) * (gs.seq::double precision / seg.nseg::double precision)
END,
CASE
WHEN gs.seq = 0 THEN f.world_y
WHEN gs.seq = seg.nseg THEN t.world_y
ELSE f.world_y + (t.world_y - f.world_y) * (gs.seq::double precision / seg.nseg::double precision)
END
FROM roads r
INNER JOIN towns f ON f.id = r.from_town_id
INNER JOIN towns t ON t.id = r.to_town_id
LEFT JOIN road_waypoints rw ON rw.road_id = r.id
CROSS JOIN LATERAL (
SELECT GREATEST(
1,
FLOOR(
SQRT(POWER(t.world_x - f.world_x, 2) + POWER(t.world_y - f.world_y, 2)) / 20.0
)::integer
) AS nseg
) seg
CROSS JOIN LATERAL generate_series(0, seg.nseg) AS gs(seq)
WHERE rw.road_id IS NULL;

@ -0,0 +1,23 @@
-- Seed excursion / roadside-rest tuning into runtime_config.payload (merged with existing keys).
UPDATE runtime_config
SET
payload = payload || '{
"adventureStartChance": 0.0001,
"adventureCooldownMs": 300000,
"adventureOutDurationMs": 20000,
"adventureWildMinMs": 560000,
"adventureWildMaxMs": 2960000,
"adventureReturnDurationMs": 20000,
"adventureDepthWorldUnits": 20,
"adventureEncounterCooldownMs": 6000,
"adventureReturnEncounterEnabled": true,
"lowHpThreshold": 0.25,
"roadsideRestExitHp": 0.7,
"adventureRestTargetHp": 0.7,
"roadsideRestMinMs": 240000,
"roadsideRestMaxMs": 600000,
"roadsideRestHpPerSecond": 0.003,
"adventureRestHpPerSecond": 0.004
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -0,0 +1,72 @@
-- Migration 000029: More cross-roads so every town has at least three direct neighbors
-- (ring + shortcuts). Complements 000027 for hubs that still had only two outgoing roads
-- (Starfall, Duskwatch, Boghollow).
-- Starfall <-> Mossharbor (Starfall otherwise only: Cinderkeep, Willowdale)
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1600.0
FROM towns f, towns t
WHERE f.name = 'Starfall' AND t.name = 'Mossharbor'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1600.0
FROM towns f, towns t
WHERE f.name = 'Mossharbor' AND t.name = 'Starfall'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
-- Duskwatch <-> Frostmark (Duskwatch otherwise only: Redcliff, Boghollow)
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1400.0
FROM towns f, towns t
WHERE f.name = 'Duskwatch' AND t.name = 'Frostmark'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1400.0
FROM towns f, towns t
WHERE f.name = 'Frostmark' AND t.name = 'Duskwatch'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
-- Boghollow <-> Ashengard (Boghollow otherwise only: Duskwatch, Cinderkeep)
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1500.0
FROM towns f, towns t
WHERE f.name = 'Boghollow' AND t.name = 'Ashengard'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
INSERT INTO roads (from_town_id, to_town_id, distance)
SELECT f.id, t.id, 1500.0
FROM towns f, towns t
WHERE f.name = 'Ashengard' AND t.name = 'Boghollow'
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
-- Waypoints for new roads only (same rule as 000019 / 000027).
INSERT INTO road_waypoints (road_id, seq, x, y)
SELECT
r.id,
gs.seq,
CASE
WHEN gs.seq = 0 THEN f.world_x
WHEN gs.seq = seg.nseg THEN t.world_x
ELSE f.world_x + (t.world_x - f.world_x) * (gs.seq::double precision / seg.nseg::double precision)
END,
CASE
WHEN gs.seq = 0 THEN f.world_y
WHEN gs.seq = seg.nseg THEN t.world_y
ELSE f.world_y + (t.world_y - f.world_y) * (gs.seq::double precision / seg.nseg::double precision)
END
FROM roads r
INNER JOIN towns f ON f.id = r.from_town_id
INNER JOIN towns t ON t.id = r.to_town_id
LEFT JOIN road_waypoints rw ON rw.road_id = r.id
CROSS JOIN LATERAL (
SELECT GREATEST(
1,
FLOOR(
SQRT(POWER(t.world_x - f.world_x, 2) + POWER(t.world_y - f.world_y, 2)) / 20.0
)::integer
) AS nseg
) seg
CROSS JOIN LATERAL generate_series(0, seg.nseg) AS gs(seq)
WHERE rw.road_id IS NULL;

@ -0,0 +1,13 @@
-- Seed combat roll + crit/block tuning into runtime_config.payload (merged with existing keys).
UPDATE runtime_config
SET
payload = payload || '{
"combatDamageRollMin": 0.6,
"combatDamageRollMax": 1.1,
"enemyCritChanceCap": 0.2,
"heroCritChanceCap": 0.12,
"heroBlockChancePerDefense": 0.0025,
"heroBlockChanceCap": 0.2
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -0,0 +1,8 @@
-- Adjust battle lizard regen in runtime_config.payload.
UPDATE runtime_config
SET
payload = payload || '{
"enemyRegenBattleLizard": 0.01
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -0,0 +1,33 @@
//go:build ignore
package main
import (
"fmt"
"math"
)
func main() {
const n = 12
a := 900.0
b := 2600.0
theta0 := 0.42
dtheta := 0.58
pts := make([]struct{ x, y float64 }, n)
for i := 0; i < n; i++ {
th := theta0 + float64(i)*dtheta
r := a + b*th
pts[i].x = r * math.Cos(th)
pts[i].y = r * math.Sin(th)
fmt.Printf("%d: %d, %d (r=%.0f)\n", i, int(math.Round(pts[i].x)), int(math.Round(pts[i].y)), r)
}
fmt.Println("--- distances ---")
var sum float64
for i := 0; i < n; i++ {
j := (i + 1) % n
d := math.Hypot(pts[j].x-pts[i].x, pts[j].y-pts[i].y)
sum += d
fmt.Printf("%d->%d: %.0f\n", i, j, d)
}
fmt.Println("avg:", sum/float64(n))
}

@ -0,0 +1,128 @@
# AutoHero — Art Bible (Sprite Pipeline)
**Purpose:** Single source of truth for mood, stylization, palette, and perspective so terrain tiles, props, buildings, heroes, NPCs, and enemies read as **one game**, not mixed asset packs. Use this for hand-off to artists, AI prompts, and internal reviews.
**Scope:** Static sprite art (phase 1). Animation adds frames later; this bible still governs silhouette, lighting, and palette on every frame.
**Related:** Content IDs remain defined in [`specification-content-catalog.md`](specification-content-catalog.md). Technical export sizes and keys for generation live in the sprite migration plan (repository plans; do not treat this file as the prompt bank).
---
## 1. Creative direction
| Axis | Direction |
|------|-----------|
| **Genre & mood** | Dark fantasy: dangerous, heavy, lived-in. Not a bright fairy tale. Hope exists as small sparks (lanterns, embers), not candy colors. |
| **Tone** | Brutal but readable: heroes and monsters feel physically convincing and threatening. Environment supports hardness (ruins, metal, harsh nature) without cute simplification. |
| **Stylization** | **Arcane-like:** painterly treatment, **strong silhouettes**, cinematic light and shadow, volume from large forms and careful shading—not from noisy micro-detail on tiny sprites. |
| **Clarity** | Icons and combat readability first: silhouette and value separation beat texture noise at small sizes. |
**English prompt prefix (for consistency across generation briefs):**
```text
Dark fantasy, brutal mood, stylized painterly look inspired by Arcane series,
cinematic lighting, strong readable silhouette, muted desaturated colors with rare accent highlights,
single game asset, transparent background, no text, no watermark, no border.
Isometric 45° or three-quarter view, consistent with tile-based RPG ground plane.
```
**Краткий бриф (RU):** мрачное фэнтези, брутально, стилизация в духе Arcane, приглушённая палитра, один объект, прозрачный фон, изометрия или три четверти, единый ракурс со сценой.
---
## 2. Visual pillars (non-negotiables)
1. **Silhouette first** — Every unit and large prop must read clearly in grayscale; interior line detail is secondary.
2. **One lighting story** — Key light feels directional (often top-left or front-top); rim or bounce sparingly. Avoid flat ambient fill that kills depth.
3. **Painterly, not noisy** — Broad strokes, controlled edges. No high-frequency speckle that turns to mush when scaled down.
4. **Unified detail density** — Tile, prop, and character art share the same “resolution of idea”: no hyper-detailed hero next to blob ground.
5. **Restrained VFX** — Magic, fire, blood, lightning are **accents** (see palette), not full-neon coverage.
---
## 3. Color & palette
### 3.1 Base (majority of each asset)
- **Neutrals:** Deep desaturated browns, cold grays, blue-grays, muted olive and pine greens.
- **Value range:** Favor mid-to-dark; highlights are **narrow** and purposeful (metal edge, wet surface, spell core).
- **Saturation:** Globally **low**; increase saturation only for intentional focal points.
### 3.2 Accents (use sparingly)
Deploy accents to guide the eye: magic (teal, violet, cold blue), fire and embers (warm orange-red, not neon), blood (deep crimson, not bright cherry), metal (cool specular), bioluminescence (subtle cyan-green in swamps).
**Rule:** If the whole sprite feels “colorful,” reduce saturation until the piece matches the base row, then add **one** accent zone.
### 3.3 Avoid
- Acid or toy primaries across large areas.
- Rainbow gradients on materials.
- Pure white (#FFFFFF) or pure black (#000000) except tiny specular hits or deep creases.
---
## 4. Camera, perspective, and ground contact
### 4.1 World alignment
- The game world uses an isometric diamond grid: **96×48 px** per cell (`TILE_WIDTH` × `TILE_HEIGHT` in `frontend/src/shared/constants.ts`).
- Ground tiles are **2:1 rhombus** fills; props and buildings sit on that ground plane.
### 4.2 Single viewpoint
- **Units and buildings:** **Three-quarter / isometric-style 3/4** view, consistent “camera-right” or three-quarter toward the camera (match existing renderer facing conventions; do not mix side-scroller flat with isometric props in the same set).
- **Tiles:** Top-down isometric diamond; texture fills the rhombus; corners may be transparent on a slightly larger canvas if needed.
- **UI weapon icons (HUD):** Not world-isometric; **side or slight top** for readability at 4864 px (see migration plan).
### 4.3 Anchor
- **Bottom-center** anchor for standing entities and vertical props: feet, trunk base, or building foundation centered on the contact point. Keeps `zIndex` sorting by screen `y` meaningful.
---
## 5. Scale cues (relative to grid)
These are **art targets**, not engine constraints:
| Category | Note |
|----------|------|
| **Tiles** | Art fits the 2:1 rhombus; target canvas per prompt bank (often 96×48 export). |
| **Tall props (trees, ruins)** | Extend above the tile; anchor at ground. |
| **Buildings** | Footprint roughly **~2 tiles wide**; height typically **220280 px** range in doc prompts. |
| **Player / NPC** | ~**128×160** canvas class; consistent with each other. |
| **Base enemies** | Slightly larger than a human (~1020%); **elites** ~**+15%** height and stronger silhouette/VFX read. |
Adjust only within the migration plans size tables; do not invent new keys without updating the content catalog.
---
## 6. Do / dont (review checklist)
| Do | Dont |
|----|--------|
| Match silhouette and value to Arcane-like painterly discipline | Mix unrelated styles (anime flat + gritty realistic) in one asset set |
| Keep ground tiles seamless at edges (where applicable) | Add readable text, logos, or watermarks on textures |
| Use muted palette with rare accents | Cover surfaces with loud saturated gradients |
| Align perspective with §4 | Rotate the same prop to contradictory viewpoints across variants |
| Export **PNG with alpha**, one object per file as per pipeline | Embed borders or frames in the texture |
---
## 7. References (workflow)
- **Primary:** *Arcane* (Netflix)—lighting, silhouette, painterly surfaces, restrained saturation.
- **Use references for:** mood boards and lighting—not for copying identifiable characters or logos.
- **Internal:** Place approved reference stills or links in team channels / design wiki; this repo file stays text-only so it stays easy to diff and version.
---
## 8. Sign-off
Art direction is **locked** for phase-1 sprite replacement when:
- All new sprites are reviewed against §§16,
- Palette and perspective exceptions are documented per asset only when technically required (e.g. HUD icons),
- Placeholders (if used) follow the same muted palette and anchor rules until final art lands.
**Document owner:** Art / creative direction (update this file when palette or perspective rules change).

@ -549,7 +549,7 @@ secondaryOut = round( baseSecondary × M(rarity) )
| Горение | -3% HP/сек |
| Оглушение | Нет атак (2 сек) |
| Замедление | -40% Movement |
| Ослабление | -30% входящий урон |
| Ослабление | +30% входящий урон |
---

@ -19,6 +19,7 @@ import {
getAdventureLog,
getTowns,
getTownNPCs,
getTownBuildings,
getHeroQuests,
getHeroEquipment,
claimQuest,
@ -28,9 +29,11 @@ import {
buyPotion,
healAtNPC,
requestRevive,
defaultNpcShopCosts,
npcShopCostsFromInit,
} from './network/api';
import type { HeroResponse, Achievement } from './network/api';
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem } from './game/types';
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api';
import {
BUFF_COOLDOWN_MS,
@ -202,14 +205,15 @@ function mapEquipment(
return out;
}
/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs */
function townToTownData(town: Town, npcs?: NPC[]): TownData {
/** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */
function townToTownData(town: Town, npcs?: NPC[], buildings?: BuildingData[]): TownData {
const npcData: NPCData[] | undefined = npcs?.map((n) => ({
id: n.id,
name: n.name,
type: n.type,
worldX: town.worldX + n.offsetX,
worldY: town.worldY + n.offsetY,
buildingId: n.buildingId,
}));
return {
id: town.id,
@ -221,6 +225,7 @@ function townToTownData(town: Town, npcs?: NPC[]): TownData {
levelMin: town.levelMin,
size: town.radius > 40 ? 'XL' : town.radius > 25 ? 'M' : town.radius > 15 ? 'S' : 'XS',
npcs: npcData,
buildings: buildings,
};
}
@ -233,6 +238,7 @@ function heroResponseToState(res: HeroResponse): HeroState {
position: { x: res.positionX ?? 0, y: res.positionY ?? 0 },
serverActivityState: res.state,
restKind: res.restKind,
excursionPhase: res.excursionPhase,
attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense,
@ -331,9 +337,12 @@ export function App() {
// NPC interaction state (server-driven via town_enter)
const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
/** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */
const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState<NPCData | null>(null);
// Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts);
// Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]);
const prevAchievementsRef = useRef<Achievement[]>([]);
@ -405,28 +414,70 @@ export function App() {
// Wire up damage events from the engine
engine.onDamage((dmg: FloatingDamageData) => {
setDamages((prev) => [...prev, dmg]);
if (dmg.isCrit) {
hapticImpact('heavy');
} else {
hapticImpact('light');
if (dmg.kind === 'damage') {
if (dmg.isCrit) {
hapticImpact('heavy');
} else {
hapticImpact('light');
}
engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150);
}
engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150);
});
engine.init(container).then(async () => {
let shouldOpenWS = false;
try {
const telegramId = getTelegramUserId() ?? 1;
const initRes = await initHero(telegramId);
setNpcShopCosts(npcShopCostsFromInit(initRes));
// Gate game start behind name entry
// Gate game start behind name entry — no hero row until POST /hero/name
if (initRes.needsName) {
setNeedsName(true);
const heroState = heroResponseToState(initRes.hero);
engine.initFromServer(heroState, initRes.hero.state);
if (initRes.hero) {
const heroState = heroResponseToState(initRes.hero);
engine.initFromServer(heroState, initRes.hero.state);
}
getTowns()
.then(async (t) => {
setTowns(t);
townsRef.current = t;
const townNPCMap = new Map<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
try {
const [npcResults, buildingResults] = await Promise.all([
Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
]);
for (const result of npcResults) {
if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
}
for (const result of buildingResults) {
if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
}
} catch {
/* ignore */
}
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id)));
engine.setTowns(townDataList);
const allNPCs: NPCData[] = [];
for (const td of townDataList) {
if (td.npcs) allNPCs.push(...td.npcs);
}
engine.setNPCs(allNPCs);
})
.catch(() => console.warn('[App] Could not fetch towns (name gate)'));
console.info('[App] Hero needs name, showing name entry screen');
return;
}
if (!initRes.hero) {
console.error('[App] init: missing hero without needsName');
setConnectionError('Invalid server response.');
return;
}
shouldOpenWS = true;
const heroState = heroResponseToState(initRes.hero);
engine.initFromServer(heroState, initRes.hero.state);
engine.setHeroName(initRes.hero.name);
@ -444,19 +495,22 @@ export function App() {
setTowns(t);
townsRef.current = t;
const townNPCMap = new Map<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
try {
const npcResults = await Promise.allSettled(
t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))),
);
const [npcResults, buildingResults] = await Promise.all([
Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
]);
for (const result of npcResults) {
if (result.status === 'fulfilled') {
townNPCMap.set(result.value.townId, result.value.npcs);
}
if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
}
for (const result of buildingResults) {
if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
}
} catch {
console.warn('[App] Error fetching town NPCs');
console.warn('[App] Error fetching town NPCs/buildings');
}
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id)));
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id)));
engine.setTowns(townDataList);
const allNPCs: NPCData[] = [];
for (const td of townDataList) {
@ -513,6 +567,9 @@ export function App() {
return;
}
engine.start();
if (shouldOpenWS) {
ws.connect();
}
}).catch((err) => {
console.error('[App] Failed to initialize game engine:', err);
});
@ -592,18 +649,10 @@ export function App() {
setCurrentTown(town);
setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' });
addLogEntry(`Entered ${p.townName}`);
const npcs = p.npcs ?? [];
if (npcs.length > 0) {
const firstNPC = npcs[0]!;
setNearestNPC({
id: firstNPC.id,
name: firstNPC.name,
type: firstNPC.type as NPCData['type'],
worldX: 0,
worldY: 0,
});
setNpcInteractionDismissed(null);
}
setNearestNPC(null);
setNpcVisitAwaitingProximity(null);
setSelectedNPC(null);
setNpcInteractionDismissed(null);
},
onAdventureLogLine: (p) => {
@ -611,22 +660,21 @@ export function App() {
},
onTownNPCVisit: (p) => {
const role =
p.type === 'merchant' ? tr.shopLabel : p.type === 'healer' ? tr.healerLabel : tr.questLabel;
setToast({ message: `${role}: ${p.name}`, color: '#c9a227' });
setNearestNPC({
setNearestNPC(null);
setNpcInteractionDismissed(null);
setNpcVisitAwaitingProximity({
id: p.npcId,
name: p.name,
type: p.type as NPCData['type'],
worldX: 0,
worldY: 0,
worldX: p.worldX ?? 0,
worldY: p.worldY ?? 0,
});
setNpcInteractionDismissed(null);
},
onTownExit: () => {
setCurrentTown(null);
setNearestNPC(null);
setNpcVisitAwaitingProximity(null);
},
onNPCEncounter: (p) => {
@ -705,8 +753,6 @@ export function App() {
},
});
ws.connect();
// ---- Telegram Theme Listener ----
const unsubTheme = onThemeChanged();
@ -724,6 +770,43 @@ export function App() {
};
}, []);
// Open trader / quest / healer panel only after the hero sprite has reached the NPC (not on town_enter).
useEffect(() => {
if (!npcVisitAwaitingProximity) return;
const pending = npcVisitAwaitingProximity;
const proximityR = 0.55;
const proximityR2 = proximityR * proximityR;
const timeoutMs = 5000;
const started = performance.now();
let raf = 0;
const step = () => {
const eng = engineRef.current;
let closeEnough = false;
if (eng) {
const { x, y } = eng.getHeroDisplayWorldPosition();
const dx = x - pending.worldX;
const dy = y - pending.worldY;
closeEnough = dx * dx + dy * dy <= proximityR2;
}
if (closeEnough || performance.now() - started > timeoutMs) {
const role =
pending.type === 'merchant'
? tr.shopLabel
: pending.type === 'healer'
? tr.healerLabel
: tr.questLabel;
setToast({ message: `${role}: ${pending.name}`, color: '#c9a227' });
setNearestNPC(pending);
setNpcVisitAwaitingProximity(null);
return;
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [npcVisitAwaitingProximity, tr]);
// Restore per-hero buff button cooldowns
useEffect(() => {
const id = gameState.hero?.id;
@ -806,9 +889,9 @@ export function App() {
return next;
});
// Optimistic decrement of per-buff charge (subscribers skip server-side consumption)
// Optimistic decrement of per-buff charge (server always consumes via ConsumeBuffCharge)
const currentCharge = hero.buffCharges?.[type];
if (!hero.subscriptionActive && currentCharge != null && currentCharge.remaining > 0) {
if (currentCharge != null && currentCharge.remaining > 0) {
const updatedCharges: Partial<Record<BuffType, BuffChargeState>> = {
...hero.buffCharges,
[type]: { ...currentCharge, remaining: currentCharge.remaining - 1 },
@ -907,28 +990,30 @@ export function App() {
const engine = engineRef.current;
if (engine) {
const heroState = heroResponseToState(hero);
const pos = engine.gameState.hero?.position;
if (pos) heroState.position = pos;
engine.initFromServer(heroState, hero.state);
engine.setHeroName(hero.name);
engine.start();
wsRef.current?.connect();
const telegramId = getTelegramUserId() ?? 1;
getTowns()
.then(async (t) => {
setTowns(t);
const townNPCMap = new Map<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
try {
const npcResults = await Promise.allSettled(
t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))),
);
const [npcResults, buildingResults] = await Promise.all([
Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))),
Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))),
]);
for (const result of npcResults) {
if (result.status === 'fulfilled') {
townNPCMap.set(result.value.townId, result.value.npcs);
}
if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs);
}
for (const result of buildingResults) {
if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings);
}
} catch { /* ignore */ }
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id)));
const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id)));
engine.setTowns(townDataList);
const allNPCs: NPCData[] = [];
for (const td of townDataList) {
@ -988,7 +1073,7 @@ export function App() {
buyPotion(telegramId)
.then((hero) => {
hapticImpact('medium');
setToast({ message: t(tr.boughtPotion, { cost: 50 }), color: '#88dd88' });
setToast({ message: t(tr.boughtPotion, { cost: npcShopCosts.potionCost }), color: '#88dd88' });
handleNPCHeroUpdated(hero);
// Server logs purchase + WS
})
@ -1003,11 +1088,11 @@ export function App() {
}
}
});
}, [handleNPCHeroUpdated]);
}, [handleNPCHeroUpdated, npcShopCosts.potionCost, tr]);
const handleNPCHeal = useCallback((_npc: NPCData) => {
const handleNPCHeal = useCallback((npc: NPCData) => {
const telegramId = getTelegramUserId() ?? 1;
healAtNPC(telegramId)
healAtNPC(telegramId, npc.id)
.then((hero) => {
hapticImpact('medium');
setToast({ message: tr.healedToFull, color: '#44cc44' });
@ -1158,6 +1243,8 @@ export function App() {
<NPCInteraction
npc={nearestNPC}
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
onViewQuests={handleNPCViewQuests}
onBuyPotion={handleNPCBuyPotion}
onHeal={handleNPCHeal}
@ -1171,6 +1258,8 @@ export function App() {
npc={selectedNPC}
heroQuests={heroQuests}
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
onClose={() => setSelectedNPC(null)}
onQuestsChanged={refreshHeroQuests}
onHeroUpdated={handleNPCHeroUpdated}

@ -0,0 +1,19 @@
import type { AdventureLogEntry } from './types';
import type { LogEntry } from '../network/api';
/** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */
export function adventureEntriesFromServerLog(serverLog: LogEntry[]): {
entries: AdventureLogEntry[];
maxId: number;
} {
const sorted = [...serverLog].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
const entries: AdventureLogEntry[] = sorted.map((entry) => ({
id: Number(entry.id),
message: entry.message,
timestamp: new Date(entry.createdAt).getTime(),
}));
const maxId = entries.reduce((m, e) => Math.max(m, e.id), 0);
return { entries, maxId };
}

@ -1,5 +1,6 @@
import {
CAMERA_FOLLOW_LERP,
CAMERA_LERP_REFERENCE_MS,
SHAKE_MAGNITUDE,
SHAKE_DURATION_MS,
} from '../shared/constants';
@ -53,9 +54,12 @@ export class Camera {
/** Update camera position. Call once per frame with delta time in ms. */
update(dtMs: number): void {
// Soft follow via linear interpolation
this.x += (this.targetX - this.x) * this.lerpFactor;
this.y += (this.targetY - this.y) * this.lerpFactor;
// Exponential smoothing; same factor as legacy per-frame lerp at ~60 Hz reference.
const k =
1 -
Math.pow(1 - this.lerpFactor, dtMs / CAMERA_LERP_REFERENCE_MS);
this.x += (this.targetX - this.x) * k;
this.y += (this.targetY - this.y) * k;
// Update screen shake
if (this.shakeTimeRemaining > 0) {

@ -1,16 +1,18 @@
import {
FIXED_DT_MS,
MAX_ACCUMULATED_MS,
} from '../shared/constants';
import { MAX_ACCUMULATED_MS } from '../shared/constants';
import type {
GameState,
EnemyState,
HeroState,
FloatingDamageData,
FloatingDamageKind,
FloatingDamageTarget,
LootDrop,
TownData,
NearbyHeroData,
NPCData,
BuildingData,
ActiveDebuff,
DebuffType,
} from './types';
import { GamePhase } from './types';
import { GameRenderer, worldToScreen } from './renderer';
@ -65,7 +67,6 @@ export class GameEngine {
private _running = false;
private _rafId: number | null = null;
private _lastTime = 0;
private _accumulator = 0;
/** Current game state (exposed to React via onStateChange) */
private _gameState: GameState = {
@ -166,6 +167,11 @@ export class GameEngine {
this._allNPCs = npcs;
}
/** Interpolated hero position in world space (for UI proximity checks). */
getHeroDisplayWorldPosition(): { x: number; y: number } {
return { x: this._heroDisplayX, y: this._heroDisplayY };
}
/** Update the list of nearby heroes for shared-world rendering. */
setNearbyHeroes(heroes: NearbyHeroData[]): void {
this._nearbyHeroes = heroes;
@ -371,10 +377,7 @@ export class GameEngine {
}
}
// Roadside rest: hero_state anchor stays on the road; display follows hero_move (+ lateral offset).
// Snapping here would cancel the forest offset every tick.
const skipPositionSnap = activity === 'resting';
if (!skipPositionSnap) {
{
const tdx = newX - this._targetPositionX;
const tdy = newY - this._targetPositionY;
if (
@ -419,6 +422,7 @@ export class GameEngine {
isCrit: boolean,
heroHp: number,
enemyHp: number,
outcome?: 'hit' | 'dodge' | 'block' | 'stun',
): void {
if (this._gameState.hero) {
this._gameState.hero.hp = heroHp;
@ -429,25 +433,53 @@ export class GameEngine {
// Emit floating damage at appropriate screen position
const viewport = getViewport();
if (source === 'hero') {
// Damage on enemy (right side of screen)
const isBlocked = outcome === 'block';
const isEvaded = outcome === 'dodge';
const defender: FloatingDamageTarget = source === 'enemy' ? 'hero' : 'enemy';
if (source === 'hero' || source === 'enemy') {
if (isBlocked || isEvaded) {
this._emitDamage(
0,
defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60,
viewport.height / 2 - 30,
false,
isBlocked ? 'blocked' : 'evaded',
defender,
);
} else {
this._emitDamage(
damage,
defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60,
viewport.height / 2 - 30,
source === 'hero' ? isCrit : false,
'damage',
defender,
);
}
}
// potion source: no floating damage
this._notifyStateChange();
}
/**
* Called when server sends enemy_regen.
* Updates enemy HP and emits floating regen numbers.
*/
applyEnemyRegen(amount: number, enemyHp: number): void {
if (!this._gameState.enemy) return;
this._gameState.enemy.hp = enemyHp;
if (amount > 0) {
const viewport = getViewport();
this._emitDamage(
damage,
amount,
viewport.width / 2 + 60,
viewport.height / 2 - 30,
isCrit,
);
} else if (source === 'enemy') {
// Damage on hero (left side of screen)
this._emitDamage(
damage,
viewport.width / 2 - 60,
viewport.height / 2 - 30,
false,
'regen',
'enemy',
);
}
// potion source: no floating damage
this._notifyStateChange();
}
@ -505,8 +537,15 @@ export class GameEngine {
/**
* Called when server sends town_enter.
* If buildings are provided, merge them into the matching town for rendering.
*/
applyTownEnter(): void {
applyTownEnter(townId?: number, buildings?: BuildingData[]): void {
if (townId && buildings && buildings.length > 0) {
const idx = this._towns.findIndex((t) => t.id === townId);
if (idx >= 0) {
this._towns[idx] = { ...this._towns[idx]!, buildings };
}
}
this._gameState = {
...this._gameState,
phase: GamePhase.InTown,
@ -614,6 +653,38 @@ export class GameEngine {
this._notifyStateChange();
}
/** Apply or refresh a single debuff from WS. */
applyDebuffApplied(type: DebuffType, durationMs: number, expiresAtMs?: number): void {
const hero = this._gameState.hero;
if (!hero) return;
const nowMs = Date.now();
const expMs = Number.isFinite(expiresAtMs)
? (expiresAtMs as number)
: nowMs + Math.max(0, durationMs);
const remainingMs = Math.max(0, expMs - nowMs);
const next: ActiveDebuff = {
type,
remainingMs,
durationMs: Math.max(0, durationMs),
expiresAtMs: expMs,
};
const debuffs = hero.debuffs
.filter((d) => d.type !== type)
.filter((d) => {
const exp = d.expiresAtMs ?? nowMs + d.remainingMs;
return exp > nowMs && d.remainingMs > 0;
});
debuffs.push(next);
this._gameState = {
...this._gameState,
hero: { ...hero, debuffs },
};
this._notifyStateChange();
}
/** Apply a full server state override (used for backward compat). */
applyServerState(state: GameState): void {
this._gameState = {
@ -642,7 +713,6 @@ export class GameEngine {
if (this._running) return;
this._running = true;
this._lastTime = performance.now();
this._accumulator = 0;
this._tick(performance.now());
}
@ -674,22 +744,19 @@ export class GameEngine {
const frameTime = Math.min(now - this._lastTime, MAX_ACCUMULATED_MS);
this._lastTime = now;
this._accumulator += frameTime;
// Fixed timestep updates (camera, loot timer only -- no game logic)
while (this._accumulator >= FIXED_DT_MS) {
this._update(FIXED_DT_MS);
this._accumulator -= FIXED_DT_MS;
// Interpolation + camera must run every frame. A 100ms fixed step (server tick rate)
// only updated ~10×/s and made the view stutter on 60 Hz displays.
if (frameTime > 0) {
this._update(frameTime);
}
// Render (alpha available for future interpolation use)
void this._accumulator;
this._render();
this._rafId = requestAnimationFrame(this._tick);
};
/** Fixed-step update -- camera follow and loot timer only */
/** Per-frame update -- hero interpolation, camera follow, loot timer */
private _update(dtMs: number): void {
// Interpolate hero display position toward target
this._interpolatePosition();
@ -780,18 +847,17 @@ export class GameEngine {
now,
);
const roadsideResting =
const rk = state.hero.restKind?.toLowerCase() ?? '';
const excPhase = state.hero.excursionPhase?.toLowerCase() ?? '';
// Camp only during the stationary wild phase; hide as soon as rest ends and return leg starts.
const showRestCamp =
state.phase === GamePhase.Resting &&
state.hero.serverActivityState?.toLowerCase() === 'resting' &&
state.hero.restKind === 'roadside';
if (roadsideResting) {
this.renderer.drawCampfire(
this._heroDisplayX,
this._heroDisplayY,
now,
);
excPhase === 'wild' &&
(rk === 'roadside' || rk === 'adventure_inline');
if (showRestCamp) {
this.renderer.drawRestCamp(this._heroDisplayX, this._heroDisplayY, now);
} else {
this.renderer.clearCampfire();
this.renderer.clearRestCamp();
}
// Thought bubble during rest/town pauses
@ -807,7 +873,7 @@ export class GameEngine {
this.renderer.clearThoughtBubble();
}
} else {
this.renderer.clearCampfire();
this.renderer.clearRestCamp();
}
// Draw NPCs from towns
@ -879,6 +945,8 @@ export class GameEngine {
x: number,
y: number,
isCrit: boolean,
kind: FloatingDamageKind,
target: FloatingDamageTarget,
): void {
if (!this._onDamage) return;
this._onDamage({
@ -888,6 +956,8 @@ export class GameEngine {
y,
isCrit,
createdAt: performance.now(),
kind,
target,
});
}

@ -204,40 +204,96 @@ export function proceduralTerrain(
return base;
}
/** Prop types for ground layer. */
/**
* Compute the distance from the nearest town edge (negative = inside town).
* Returns Infinity if no towns.
*/
function townEdgeDist(wx: number, wy: number, towns: TownTerrainInfluence[]): number {
let minEdge = Number.POSITIVE_INFINITY;
for (const t of towns) {
const d = Math.hypot(wx - t.cx, wy - t.cy) - t.radius;
if (d < minEdge) minEdge = d;
}
return minEdge;
}
/**
* Cluster-based noise: tiles near a "cluster seed" tile are more likely to share objects.
* Returns a hash that correlates with nearby tiles (spatial coherence ~3 tiles).
*/
function clusterHash(wx: number, wy: number, seed: number): number {
const cx = Math.floor(wx / 3);
const cy = Math.floor(wy / 3);
return tileHash(cx, cy, seed);
}
/**
* Prop types for ground layer.
* Uses zoning: plaza (sparse, server provides buildings), town edge (fences/barrels),
* road buffer (clear), wild (clustered natural objects).
*/
export function proceduralObject(
wx: number,
wy: number,
terrain: string,
context?: WorldTerrainContext | null,
): string | null {
// Inside plaza: no procedural props (server buildings handle this)
if (terrain === 'plaza') {
const h = tileHash(wx, wy, 201);
if (h < 0.1) return 'stall';
if (h < 0.16) return 'well';
if (h < 0.22) return 'banner';
return null;
}
if (context && roadClearance(wx, wy, context) < 3.0) return null;
const ctx = context;
const rd = ctx ? roadClearance(wx, wy, ctx) : Number.POSITIVE_INFINITY;
// Road buffer: keep clear for passage
if (rd < 3.0) return null;
// Town edge zone: sparse village decor (no trees/ruins)
if (ctx && ctx.towns.length > 0) {
const edge = townEdgeDist(wx, wy, ctx.towns);
if (edge >= -2.0 && edge < 5.0) {
const h = tileHash(wx, wy, 301);
if (h < 0.04) return 'barrel';
if (h < 0.065) return 'cart';
if (h < 0.085) return 'bush';
if (h < 0.095) return 'leaves';
return null;
}
}
// Wild zone: cluster-based natural objects for spatial coherence
const h = tileHash(wx, wy, 137);
let treeTh = 0.045;
if (terrain === 'forest_floor') treeTh = 0.09;
if (terrain === 'swamp_floor') treeTh = 0.025;
const ch = clusterHash(wx, wy, 137);
// Trees appear in groves (cluster hash controls grove probability)
let treeTh = ch < 0.3 ? 0.08 : 0.02;
if (terrain === 'forest_floor') treeTh = ch < 0.4 ? 0.14 : 0.03;
if (terrain === 'swamp_floor') treeTh = ch < 0.25 ? 0.06 : 0.01;
if (h < treeTh) return 'tree';
if (h < 0.08) return 'bush';
if (h < 0.095) return 'rock';
if (h < 0.11) return 'stump';
if (h < 0.122) return 'cart';
if (h < 0.132) return 'bones';
if (h < 0.142) return 'mushroom';
if (h < 0.15) return 'ruin';
// Bushes cluster around trees
const bushTh = ch < 0.35 ? 0.06 : 0.02;
if (h < treeTh + bushTh) return 'bush';
// Rocks appear in rocky patches
const rockCh = clusterHash(wx, wy, 241);
if (rockCh < 0.2 && h < treeTh + bushTh + 0.04) return 'rock';
// Sparse scattered objects (less chaotic than before)
const sparse = tileHash(wx, wy, 199);
if (sparse < 0.008) return 'stump';
if (sparse < 0.014) return 'cart';
if (sparse < 0.018) return 'bones';
if (sparse < 0.024) return 'mushroom';
if (sparse < 0.028) return 'leaves';
if (terrain === 'ruins_floor' && sparse < 0.04) return 'ruin';
return null;
}
/** Blocking object types that hero cannot walk through */
const BLOCKING_TYPES = new Set(['tree', 'bush', 'rock', 'ruin', 'stall', 'well']);
const BLOCKING_TYPES = new Set(['tree', 'bush', 'rock', 'ruin', 'stall', 'well', 'barrel']);
/**
* Check if a tile at the given world coordinate is blocked by a procedural obstacle.

@ -3,7 +3,7 @@ import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants';
import { getViewport } from '../shared/telegram';
import type { Camera } from './camera';
import type { EnemyType } from './types';
import type { TownData, NPCData } from './types';
import type { TownData, NPCData, BuildingData } from './types';
import { drawEnemyByType } from './enemyVisuals';
/**
@ -77,7 +77,8 @@ export class GameRenderer {
// Reusable Graphics objects (avoid GC in hot path)
private _groundGfx: Graphics | null = null;
private _heroGfx: Graphics | null = null;
private _campfireGfx: Graphics | null = null;
/** Tent + campfire while resting in the wild phase (roadside / adventure inline). */
private _restCampGfx: Graphics | null = null;
private _enemyGfx: Graphics | null = null;
private _thoughtGfx: Graphics | null = null;
private _thoughtText: Text | null = null;
@ -211,6 +212,31 @@ export class GameRenderer {
gfx.fill({ color: 0x6a3a8e, alpha: 0.92 });
}
private _drawBarrel(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.15) * 2.8;
gfx.ellipse(x, y, 5 * s, 3 * s);
gfx.fill({ color: 0x5a4228, alpha: 0.9 });
gfx.rect(x - 5 * s, y - 7 * s, 10 * s, 7 * s);
gfx.fill({ color: 0x6b4a30, alpha: 0.92 });
gfx.ellipse(x, y - 7 * s, 5 * s, 3 * s);
gfx.fill({ color: 0x7a5a3a, alpha: 0.9 });
gfx.rect(x - 5 * s, y - 5 * s, 10 * s, 1 * s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
gfx.rect(x - 5 * s, y - 2 * s, 10 * s, 1 * s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
}
private _drawLeafPile(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.85 + variant * 0.25) * 2.5;
const colors = [0x6a8a2a, 0x8a7a22, 0x5a7a28, 0x9a8a30];
for (let i = 0; i < 4; i++) {
const ox = (((variant * 100 + i * 37) | 0) % 7 - 3) * s;
const oy = (((variant * 100 + i * 53) | 0) % 5 - 2) * s * 0.5;
gfx.ellipse(x + ox, y + oy, 4 * s, 2.5 * s);
gfx.fill({ color: colors[i % colors.length]!, alpha: 0.7 });
}
}
private _terrainColors(terrain: string, dark: boolean): number {
if (terrain === 'plaza') return dark ? 0x5a5a62 : 0x6c6c75;
if (terrain === 'road') return dark ? 0x7b6545 : 0x8e7550;
@ -292,8 +318,8 @@ export class GameRenderer {
this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx);
this._campfireGfx = new Graphics();
this.entityLayer.addChild(this._campfireGfx);
this._restCampGfx = new Graphics();
this.entityLayer.addChild(this._restCampGfx);
this._enemyGfx = new Graphics();
this.entityLayer.addChild(this._enemyGfx);
@ -459,6 +485,8 @@ export class GameRenderer {
else if (obj === 'stall') this._drawMarketStall(gfx, iso.x, iso.y, variant);
else if (obj === 'well') this._drawWell(gfx, iso.x, iso.y, variant);
else if (obj === 'banner') this._drawBanner(gfx, iso.x, iso.y, variant);
else if (obj === 'barrel') this._drawBarrel(gfx, iso.x, iso.y, variant);
else if (obj === 'leaves') this._drawLeafPile(gfx, iso.x, iso.y, variant);
}
}
}
@ -533,30 +561,58 @@ export class GameRenderer {
}
}
/** Draw a small campfire near hero while roadside-resting. */
drawCampfire(wx: number, wy: number, now: number): void {
const gfx = this._campfireGfx;
/**
* Draw a small camp (A-frame tent + campfire) near the hero during wilderness rest (wild phase).
* Placed slightly behind the hero in screen space for a bivouac read.
*/
drawRestCamp(wx: number, wy: number, now: number): void {
const gfx = this._restCampGfx;
if (!gfx) return;
gfx.clear();
const iso = worldToScreen(wx, wy);
const bob = Math.sin(now * 0.005) * 1.2;
const cx = iso.x + 18;
const cy = iso.y + 9 + bob;
const bob = Math.sin(now * 0.004) * 1.0;
// --- Tent (screen-left of hero, reads “behind” in iso) ---
const tx = iso.x - 26;
const ty = iso.y - 4 + bob * 0.4;
// Ground shadow under tent
gfx.ellipse(tx, ty + 14, 22, 7);
gfx.fill({ color: 0x000000, alpha: 0.18 });
// Tent body (trapezoid wall + triangle roof)
gfx.poly([tx - 18, ty + 12, tx + 18, ty + 12, tx + 14, ty - 8, tx - 14, ty - 8]);
gfx.fill({ color: 0x8b6914, alpha: 0.92 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.fill({ color: 0xc4a574, alpha: 0.96 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.stroke({ color: 0x5c4030, width: 1.2, alpha: 0.85 });
gfx.rect(tx - 5, ty + 2, 10, 10);
gfx.fill({ color: 0x1a1510, alpha: 0.55 });
// Guy lines / pegs (tiny)
gfx.moveTo(tx - 18, ty + 12);
gfx.lineTo(tx - 26, ty + 16);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
gfx.moveTo(tx + 18, ty + 12);
gfx.lineTo(tx + 26, ty + 16);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
// --- Campfire (near tent / hero) ---
const cx = iso.x + 16;
const cy = iso.y + 10 + bob;
// Ground shadow / ember glow
gfx.ellipse(cx, cy + 6, 12, 4);
gfx.fill({ color: 0x000000, alpha: 0.22 });
gfx.ellipse(cx, cy + 3, 10, 3.2);
gfx.fill({ color: 0xff7a1a, alpha: 0.2 });
gfx.fill({ color: 0xff7a1a, alpha: 0.22 });
// Logs
gfx.roundRect(cx - 9, cy + 1, 18, 3, 1.5);
gfx.fill({ color: 0x5a3a24, alpha: 0.95 });
gfx.roundRect(cx - 8, cy - 1, 16, 3, 1.5);
gfx.fill({ color: 0x6b4428, alpha: 0.9 });
// Flame (layered circles for lightweight VFX)
const pulse = 0.9 + 0.2 * Math.sin(now * 0.012);
gfx.circle(cx, cy - 6, 5.2 * pulse);
gfx.fill({ color: 0xff8a2a, alpha: 0.8 });
@ -565,11 +621,11 @@ export class GameRenderer {
gfx.circle(cx, cy - 8, 1.6 * pulse);
gfx.fill({ color: 0xfff3b0, alpha: 0.95 });
gfx.zIndex = cy + 96;
gfx.zIndex = Math.max(ty, cy) + 94;
}
clearCampfire(): void {
if (this._campfireGfx) this._campfireGfx.clear();
clearRestCamp(): void {
if (this._restCampGfx) this._restCampGfx.clear();
}
/**
@ -775,10 +831,213 @@ export class GameRenderer {
gfx.fill({ color: 0x44aa88, alpha: 0.7 });
}
/**
* Draw server-defined buildings for a town. Each building type gets a distinct
* visual style so players can identify NPC houses at a glance.
*/
private _drawServerBuildings(
gfx: Graphics,
buildings: BuildingData[],
_townScreenX: number,
_townScreenY: number,
scale: number,
): void {
for (let i = 0; i < buildings.length; i++) {
const b = buildings[i]!;
const bScreen = worldToScreen(b.worldX, b.worldY);
const bx = bScreen.x;
const by = bScreen.y;
const w = 60 * scale * (b.footprintW / 2.5);
const h = 48 * scale * (b.footprintH / 2.0);
const rh = 32 * scale;
const bt = b.buildingType;
if (bt === 'house.quest_giver') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xb89040, 0x6a3a22, 0);
this._drawFence(gfx, bx, by, w, 'left');
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '!', 0xffd700, scale);
} else if (bt === 'house.merchant') {
this._drawHouse(gfx, bx, by, w * 1.1, h, rh * 0.8, 0x44aa55, 0x2a5a30, 1);
this._drawTownStall(gfx, bx + w * 0.7, by + 4, scale * 0.6);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale);
} else if (bt === 'house.healer') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale);
} else if (bt === 'decoration.well') {
this._drawTownWell(gfx, bx, by, scale);
} else if (bt === 'decoration.stall') {
this._drawTownStall(gfx, bx, by, scale * 0.9);
} else if (bt === 'decoration.signpost') {
this._drawSignpost(gfx, bx, by, scale);
}
}
}
/** Draw a small icon circle above a building to indicate its purpose. */
private _drawBuildingIcon(
gfx: Graphics, cx: number, cy: number, _icon: string, color: number, scale: number,
): void {
const r = 6 * scale;
gfx.circle(cx, cy, r);
gfx.fill({ color, alpha: 0.6 });
gfx.stroke({ color: 0x000000, width: 1.2, alpha: 0.4 });
}
/** Draw a town well decoration (server-driven building). */
private _drawTownWell(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.ellipse(cx, cy, 10 * s, 5 * s);
gfx.fill({ color: 0x6a6a7a, alpha: 0.8 });
gfx.stroke({ color: 0x4a4a5a, width: 1.5, alpha: 0.6 });
gfx.rect(cx - 1 * s, cy - 12 * s, 2 * s, 12 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
gfx.rect(cx - 6 * s, cy - 13 * s, 12 * s, 2 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
}
/** Small plaza fountain (procedural towns; server towns use decoration.well at center). */
private _drawTownFountain(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.ellipse(cx, cy + 2 * s, 14 * s, 7 * s);
gfx.fill({ color: 0x4a5a6a, alpha: 0.85 });
gfx.stroke({ color: 0x3a4550, width: 1.2, alpha: 0.65 });
gfx.ellipse(cx, cy + 2 * s, 10 * s, 5 * s);
gfx.fill({ color: 0x5a8aaa, alpha: 0.45 });
gfx.rect(cx - 3 * s, cy - 14 * s, 6 * s, 16 * s);
gfx.fill({ color: 0x7a7a88, alpha: 0.9 });
gfx.rect(cx - 5 * s, cy - 16 * s, 10 * s, 3 * s);
gfx.fill({ color: 0x6a6a78, alpha: 0.88 });
gfx.circle(cx, cy - 10 * s, 2.2 * s);
gfx.fill({ color: 0xaaddff, alpha: 0.35 });
gfx.arc(cx, cy - 10 * s, 3 * s, -Math.PI * 0.85, -Math.PI * 0.15);
gfx.stroke({ color: 0x88ccff, width: 1.2, alpha: 0.4 });
}
/**
* Paved town square at the settlement center (under well / fountain and civic building).
*/
private _drawTownPlaza(
gfx: Graphics,
tx: number,
ty: number,
groundW: number,
groundH: number,
): void {
const pw = groundW * 0.42;
const ph = groundH * 0.42;
gfx.ellipse(tx, ty, pw, ph);
gfx.fill({ color: 0x7a7878, alpha: 0.55 });
gfx.stroke({ color: 0x5a5858, width: 1.2, alpha: 0.45 });
const step = Math.max(10, pw * 0.14);
for (let dx = -pw + step * 0.3; dx < pw; dx += step) {
for (let dy = -ph + step * 0.25; dy < ph; dy += step * 0.85) {
if ((dx * dx) / (pw * pw) + (dy * dy) / (ph * ph) > 0.82) continue;
const h = ((Math.floor(dx / step) * 31) ^ (Math.floor(dy / step) * 17)) & 1;
gfx.rect(tx + dx - step * 0.08, ty + dy - step * 0.08, step * 0.45, step * 0.38);
gfx.fill({ color: h ? 0x6a686e : 0x757278, alpha: 0.35 });
}
}
}
/** Single civic building (hall / notice board) facing the plaza — not an NPC home. */
private _drawCivicBuilding(gfx: Graphics, cx: number, cy: number, s: number): void {
const w = 52 * s;
const h = 38 * s;
const rh = 26 * s;
gfx.rect(cx - w / 2, cy - h, w, h);
gfx.fill({ color: 0x8a9098, alpha: 0.95 });
gfx.stroke({ color: 0x4a5058, width: 1.2, alpha: 0.55 });
gfx.poly([
cx - w / 2 - 4 * s, cy - h,
cx + w / 2 + 4 * s, cy - h,
cx + w / 2, cy - h - rh,
cx - w / 2, cy - h - rh,
]);
gfx.fill({ color: 0x4a5560, alpha: 0.92 });
gfx.rect(cx - 8 * s, cy - h * 0.65, 16 * s, 22 * s);
gfx.fill({ color: 0x2a3540, alpha: 0.75 });
gfx.stroke({ color: 0x1a2530, width: 0.8, alpha: 0.5 });
gfx.rect(cx - w / 2 + 6 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55);
gfx.fill({ color: 0x6a7580, alpha: 0.85 });
gfx.rect(cx + w / 2 - 10 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55);
gfx.fill({ color: 0x6a7580, alpha: 0.85 });
}
/** Draw a signpost decoration. */
private _drawSignpost(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.rect(cx - 1 * s, cy - 16 * s, 2 * s, 16 * s);
gfx.fill({ color: 0x6a5a3a, alpha: 0.9 });
gfx.poly([
cx + 2 * s, cy - 14 * s,
cx + 12 * s, cy - 13 * s,
cx + 12 * s, cy - 10 * s,
cx + 2 * s, cy - 9 * s,
]);
gfx.fill({ color: 0x8a7a5a, alpha: 0.85 });
}
/**
* Fallback procedural building placement when server buildings are unavailable.
*/
private _drawProceduralBuildings(
gfx: Graphics, tx: number, ty: number, s: number,
spread: number, size: string, townSeed: number,
): void {
const houseCount = size === 'XS' ? 5 : size === 'S' ? 7 : size === 'M' ? 10 : 14;
const wallColors = [0x9a7e5a, 0x8b7252, 0xa08860, 0x7e6844, 0x907656, 0x9e8862, 0x887050];
const roofColors = [0x6a3a22, 0x5a3020, 0x7a4028, 0x5e3422, 0x6e3a24, 0x724030, 0x603828];
const baseW = 60;
const baseH = 48;
const baseRH = 32;
for (let i = 0; i < houseCount; i++) {
const hash = ((townSeed * 31 + i * 17) ^ (i * 0x45d9f3b)) >>> 0;
const r1 = (hash & 0xffff) / 0xffff;
const r2 = ((hash >> 16) & 0xffff) / 0xffff;
const r3 = ((hash * 7 + i * 13) & 0xff) / 0xff;
const angle = (i / houseCount) * Math.PI * 2 + r1 * 0.4;
// Keep town center clear for plaza + fountain/well + civic building
const dist = spread * (0.36 + r2 * 0.52);
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist * 0.5;
const sizeVar = 0.7 + r3 * 0.5;
const w = baseW * s * sizeVar;
const h = baseH * s * sizeVar;
const rh = baseRH * s * sizeVar;
const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;
this._drawHouse(
gfx, tx + dx, ty + dy, w, h, rh,
wallColors[i % wallColors.length]!,
roofColors[i % roofColors.length]!,
roofStyle,
);
if (i % 4 === 1) {
this._drawFence(gfx, tx + dx, ty + dy, w, i % 2 === 0 ? 'left' : 'right');
}
}
const stallCount = houseCount >= 10 ? 2 : 1;
for (let si = 0; si < stallCount; si++) {
const stallAngle = (si + 0.5) * Math.PI + (townSeed & 0xf) * 0.1;
const stallDist = spread * 0.42;
this._drawTownStall(
gfx,
tx + Math.cos(stallAngle) * stallDist,
ty + Math.sin(stallAngle) * stallDist * 0.5,
s * 0.9,
);
}
}
/**
* Draw towns visible in the current viewport.
* Each town renders a ground plane, a large cluster of buildings with detail,
* market stalls, fences, a name label, and a dashed border.
* Each town renders a ground plane, a paved central plaza, a fountain or well at the
* plaza center (procedural fallback) or from server buildings, one civic hall offset
* from center, NPC homes and stalls, a name label, and a dashed border.
*/
drawTowns(towns: TownData[], camera: Camera, screenWidth: number, screenHeight: number): void {
const gfx = this._townGfx;
@ -839,77 +1098,27 @@ export class GameRenderer {
gfx.circle(tx, ty, borderRadius * 0.6);
gfx.fill({ color: 0xdaa520, alpha: 0.04 });
// --- Building cluster: many houses spread wide ---
const houseCount =
town.size === 'XS' ? 5 :
town.size === 'S' ? 7 :
town.size === 'M' ? 10 : 14;
// --- Central plaza (paving); well/fountain + civic sit on or beside it ---
this._drawTownPlaza(gfx, tx, ty, groundW, groundH);
// Generate house positions spread across a wider area
const housePositions: Array<{ dx: number; dy: number; w: number; h: number; rh: number; roofStyle: number; fence: boolean; stall: boolean }> = [];
const spread = 100 * s;
const baseW = 60;
const baseH = 48;
const baseRH = 32;
// Seed pseudo-random from town id for deterministic layout
const townSeed = typeof town.id === 'number' ? town.id : 0;
for (let i = 0; i < houseCount; i++) {
// Deterministic pseudo-random using a simple hash
const hash = ((townSeed * 31 + i * 17) ^ (i * 0x45d9f3b)) >>> 0;
const r1 = ((hash & 0xffff) / 0xffff);
const r2 = (((hash >> 16) & 0xffff) / 0xffff);
const r3 = ((hash * 7 + i * 13) & 0xff) / 0xff;
// Angle-based layout to fill the town area
const angle = (i / houseCount) * Math.PI * 2 + r1 * 0.4;
const dist = spread * (0.2 + r2 * 0.65);
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist * 0.5; // isometric compression
const sizeVar = 0.7 + r3 * 0.5;
const w = baseW * s * sizeVar;
const h = baseH * s * sizeVar;
const rh = baseRH * s * sizeVar;
const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;
const fence = i % 4 === 1;
const stall = false; // stalls are added separately
housePositions.push({ dx, dy, w, h, rh, roofStyle, fence, stall });
}
const wallColors = [0x9a7e5a, 0x8b7252, 0xa08860, 0x7e6844, 0x907656, 0x9e8862, 0x887050];
const roofColors = [0x6a3a22, 0x5a3020, 0x7a4028, 0x5e3422, 0x6e3a24, 0x724030, 0x603828];
for (let i = 0; i < housePositions.length; i++) {
const hp = housePositions[i]!;
this._drawHouse(
gfx,
tx + hp.dx,
ty + hp.dy,
hp.w,
hp.h,
hp.rh,
wallColors[i % wallColors.length]!,
roofColors[i % roofColors.length]!,
hp.roofStyle,
);
if (hp.fence) {
this._drawFence(gfx, tx + hp.dx, ty + hp.dy, hp.w, i % 2 === 0 ? 'left' : 'right');
const spread = 100 * s;
const civicWx = town.centerX + 0.18 * town.radius;
const civicWy = town.centerY - 0.36 * town.radius;
const civicScreen = worldToScreen(civicWx, civicWy);
// --- Buildings: server-driven if available, fallback procedural ---
if (town.buildings && town.buildings.length > 0) {
this._drawServerBuildings(gfx, town.buildings, tx, ty, s);
this._drawCivicBuilding(gfx, civicScreen.x, civicScreen.y, s);
} else {
this._drawProceduralBuildings(gfx, tx, ty, s, spread, town.size, townSeed);
if ((townSeed & 1) === 0) {
this._drawTownFountain(gfx, tx, ty, s);
} else {
this._drawTownWell(gfx, tx, ty, s);
}
}
// Add 1-2 market stalls per town (larger towns get 2)
const stallCount = houseCount >= 10 ? 2 : 1;
for (let si = 0; si < stallCount; si++) {
const stallAngle = (si + 0.5) * Math.PI + (townSeed & 0xf) * 0.1;
const stallDist = spread * 0.35;
this._drawTownStall(
gfx,
tx + Math.cos(stallAngle) * stallDist,
ty + Math.sin(stallAngle) * stallDist * 0.5,
s * 0.9,
);
this._drawCivicBuilding(gfx, civicScreen.x, civicScreen.y, s);
}
// --- Town name label (larger font, positioned higher) ---

@ -30,6 +30,7 @@ export enum DebuffType {
Stun = 'stun', // no attacks (2 sec)
Slow = 'slow', // -40% movement
Weaken = 'weaken', // -30% outgoing damage
IceSlow = 'ice_slow', // -20% attack speed (Ice Guardian)
}
export interface BuffChargeState {
@ -118,8 +119,10 @@ export interface HeroState {
position: Position;
/** Server `state` field (walking | resting | in_town | …); used for movement/render parity */
serverActivityState?: string;
/** Server rest flavor: "town" | "roadside" */
/** Server rest flavor: "town" */
restKind?: string;
/** Mini-adventure leg: "out" | "wild" | "return" when excursion active */
excursionPhase?: string;
attackSpeed: number;
damage: number;
defense: number;
@ -246,6 +249,7 @@ export interface NPC {
type: 'quest_giver' | 'merchant' | 'healer';
offsetX: number;
offsetY: number;
buildingId?: number;
}
export interface Quest {
@ -257,6 +261,7 @@ export interface Quest {
targetCount: number;
targetEnemyType?: string;
targetTownId?: number;
targetTownName?: string;
dropChance: number;
minLevel: number;
maxLevel: number;
@ -279,6 +284,8 @@ export interface HeroQuest {
rewardPotions: number;
npcName: string;
townName: string;
/** Resolved name for visit_town delivery target */
targetTownName?: string;
}
// ---- Equipment Item (extended slots per §6.3) ----
@ -315,6 +322,18 @@ export interface NPCData {
type: 'quest_giver' | 'merchant' | 'healer';
worldX: number;
worldY: number;
buildingId?: number;
}
/** Server-driven building placed in a town */
export interface BuildingData {
id: number;
buildingType: string;
worldX: number;
worldY: number;
facing: 'north' | 'south' | 'east' | 'west';
footprintW: number;
footprintH: number;
}
/** Alias: engine-facing town data for map rendering */
@ -329,6 +348,7 @@ export interface TownData {
levelMin: number;
size: string;
npcs?: NPCData[];
buildings?: BuildingData[];
}
/** NPC encounter event returned instead of an enemy */
@ -360,6 +380,9 @@ export interface NearbyHeroData {
// ---- Floating Damage ----
export type FloatingDamageKind = 'damage' | 'blocked' | 'evaded' | 'regen';
export type FloatingDamageTarget = 'hero' | 'enemy';
export interface FloatingDamageData {
id: number;
value: number;
@ -367,6 +390,8 @@ export interface FloatingDamageData {
y: number;
isCrit: boolean;
createdAt: number;
kind: FloatingDamageKind;
target: FloatingDamageTarget;
}
// ---- Server -> Client Message Payloads ----
@ -378,6 +403,7 @@ export type ServerMessageType =
| 'route_assigned'
| 'combat_start'
| 'attack'
| 'enemy_regen'
| 'combat_end'
| 'hero_died'
| 'hero_revived'
@ -438,11 +464,17 @@ export interface AttackPayload {
source: 'hero' | 'enemy' | 'potion';
damage: number;
isCrit: boolean;
outcome?: 'hit' | 'dodge' | 'block' | 'stun';
heroHp: number;
enemyHp: number;
debuffApplied?: string;
}
export interface EnemyRegenPayload {
amount: number;
enemyHp: number;
}
export interface CombatEndPayload {
xpGained: number;
goldGained: number;
@ -474,19 +506,38 @@ export interface BuffAppliedPayload {
magnitude?: number;
}
export interface DebuffAppliedPayload {
debuffType: string;
durationMs?: number;
magnitude?: number;
expiresAt?: string;
}
export interface TownEnterPayload {
townId: number;
townName: string;
biome?: string;
npcs?: Array<{ id: number; name: string; type: string }>;
npcs?: Array<{ id: number; name: string; type: string; buildingId?: number; worldX: number; worldY: number }>;
buildings?: Array<{
id: number;
buildingType: string;
worldX: number;
worldY: number;
facing: string;
footprintW: number;
footprintH: number;
}>;
restDurationMs?: number;
}
/** worldX/Y = hero stand point near the NPC (server), not the NPC sprite center. */
export interface TownNPCVisitPayload {
npcId: number;
name: string;
type: string;
townId: number;
worldX: number;
worldY: number;
}
/** Server-persisted adventure log line (e.g. town NPC visit narration). */

@ -6,6 +6,7 @@ import type {
RouteAssignedPayload,
CombatStartPayload,
AttackPayload,
EnemyRegenPayload,
CombatEndPayload,
HeroDiedPayload,
HeroRevivedPayload,
@ -25,8 +26,10 @@ import type {
EnemyState,
LootDrop,
MerchantLootPayload,
DebuffAppliedPayload,
} from './types';
import { EnemyType, Rarity } from './types';
import { DebuffType, EnemyType, Rarity } from './types';
import { DEBUFF_DURATION_DEFAULTS } from '../shared/constants';
// ---- Callback types for UI layer (App.tsx) ----
@ -64,6 +67,9 @@ export function wireWSHandler(
engine: GameEngine,
callbacks: WSHandlerCallbacks,
): void {
const isDebuffType = (value: string): value is DebuffType => (
Object.values(DebuffType).includes(value as DebuffType)
);
// ---- Server -> Client: Movement ----
ws.on('hero_move', (msg: ServerMessage) => {
@ -108,7 +114,22 @@ export function wireWSHandler(
ws.on('attack', (msg: ServerMessage) => {
const p = msg.payload as AttackPayload;
engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp);
engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp, p.outcome);
});
ws.on('enemy_regen', (msg: ServerMessage) => {
const p = msg.payload as EnemyRegenPayload;
engine.applyEnemyRegen(p.amount, p.enemyHp);
});
ws.on('debuff_applied', (msg: ServerMessage) => {
const p = msg.payload as DebuffAppliedPayload;
if (!p?.debuffType || !isDebuffType(p.debuffType)) return;
const nowMs = Date.now();
const fallbackMs = DEBUFF_DURATION_DEFAULTS[p.debuffType] ?? 0;
const durationMs = Number.isFinite(p.durationMs) ? Math.max(0, p.durationMs as number) : fallbackMs;
const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs;
engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs);
});
ws.on('combat_end', (msg: ServerMessage) => {
@ -147,7 +168,7 @@ export function wireWSHandler(
ws.on('town_enter', (msg: ServerMessage) => {
const p = msg.payload as TownEnterPayload;
engine.applyTownEnter();
engine.applyTownEnter(p.townId, p.buildings as any);
callbacks.onTownEnter?.(p);
});

@ -0,0 +1,192 @@
export const en = {
// General
loading: 'Loading hero...',
close: 'Close',
cancel: 'Cancel',
confirm: 'Confirm',
empty: 'Empty',
none: 'None',
error: 'Error',
back: 'Back',
// Stats
hp: 'HP',
atk: 'ATK',
def: 'DEF',
spd: 'Speed',
moveSpd: 'Move SPD',
str: 'STR',
con: 'CON',
agi: 'AGI',
luck: 'LUCK',
xp: 'XP',
gold: 'Gold',
level: 'Lv',
stat: 'STAT',
// Hero Panel
heroStats: 'Hero Stats',
experience: 'Experience',
activeBuffs: 'Active Buffs',
activeDebuffs: 'Active Debuffs',
// Equipment
equipment: 'Equipment',
slotWeapon: 'Weapon',
slotOffHand: 'Off Hand',
slotHead: 'Head',
slotChest: 'Chest',
slotLegs: 'Legs',
slotFeet: 'Feet',
slotCloak: 'Cloak',
slotNeck: 'Neck',
slotRing: 'Ring',
slotWrist: 'Wrist',
slotHands: 'Hands',
slotQuiver: 'Quiver',
inventory: 'Inventory',
// Rarity
common: 'Common',
uncommon: 'Uncommon',
rare: 'Rare',
epic: 'Epic',
legendary: 'Legendary',
// Buff names
buffRush: 'Rush',
buffRage: 'Rage',
buffShield: 'Shield',
buffLuck: 'Luck',
buffResurrection: 'Resurrect',
buffHeal: 'Heal',
buffPowerPotion: 'Power',
buffWarCry: 'War Cry',
// Buff descriptions
buffRushDesc: '+50% movement speed',
buffRageDesc: '+100% damage',
buffShieldDesc: '-50% incoming damage',
buffLuckDesc: 'x2.5 loot drops',
buffResurrectionDesc: 'Revive at 50% HP',
buffHealDesc: '+50% HP instant',
buffPowerPotionDesc: '+150% damage',
buffWarCryDesc: '+100% attack speed',
// Buff UI
charges: 'Charges',
refillsAt: 'Refills at',
refill: 'Refill',
refillQuestion: 'Refill {label}?',
noChargesLeft: 'No charges left for {label}',
// Debuff names
debuffPoison: 'Poison',
debuffFreeze: 'Freeze',
debuffBurn: 'Burn',
debuffStun: 'Stun',
debuffSlow: 'Slow',
debuffWeaken: 'Weaken',
debuffIceSlow: 'Ice Slow',
// Quest system
questLog: 'Quest Log',
noActiveQuests: 'No active quests. Visit an NPC to accept quests!',
claimRewards: 'Claim Rewards',
questDestination: 'Destination',
abandon: 'Abandon',
acceptQuest: 'Accept',
questAccepted: 'Quest accepted!',
questRewardsClaimed: 'Quest rewards claimed!',
questAbandoned: 'Quest abandoned',
failedToAcceptQuest: 'Failed to accept quest',
failedToClaimRewards: 'Failed to claim rewards',
failedToAbandonQuest: 'Failed to abandon quest',
completed: 'Completed',
// NPC
questGiver: 'Quest Giver',
merchant: 'Merchant',
healer: 'Healer',
npc: 'NPC',
buyPotion: 'Buy Potion',
healToFull: 'Heal to Full',
boughtPotion: 'Bought a potion for {cost} gold',
healedToFull: 'Healed to full HP!',
notEnoughGold: 'Not enough gold!',
failedToBuyPotion: 'Failed to buy potion',
failedToHeal: 'Failed to heal',
// Wandering NPC
giveGoldForItem: 'Give {cost} gold for a mysterious item?',
accept: 'Accept',
decline: 'Decline',
giving: 'Giving...',
// Death screen
youDied: 'YOU DIED',
reviveNow: 'REVIVE NOW',
freeRevivesLeft: 'Free revives left: {count}',
autoReviveIn: 'Auto-revive in {timer}s',
noFreeRevives: 'No free revives left \u2014 subscription required',
// Name entry
chooseHeroName: 'Choose Your Hero Name',
enterName: 'Enter a name...',
continue: 'Continue',
saving: 'Saving...',
nameTaken: 'Name already taken, try another',
invalidName: 'Invalid name',
serverError: 'Server error ({status})',
connectionFailed: 'Connection failed, please retry',
// Offline report
whileYouWereAway: 'While you were away...',
killedMonsters: 'Killed {count} monster(s)',
gainedXP: '+{xp} XP',
gainedGold: '+{gold} gold',
gainedLevels: 'Gained {levels} level(s)!',
tapToDismiss: 'Tap anywhere to dismiss',
// Toasts
levelUp: 'Level up! Now level {level}',
heroRevived: 'Hero revived!',
entering: 'Entering {townName}',
newEquipment: 'New {slot}: {itemName}',
potionsCollected: '+{count} potion(s)',
questProgress: '{title} ({current}/{target})',
questCompleted: 'Quest completed: {title}!',
buffLimitReached: 'Buff limit reached',
reviveNotAllowed: 'Revive not allowed',
dailyTaskClaimed: 'Daily task reward claimed!',
failedToClaimReward: 'Failed to claim reward',
// Minimap
map: 'MAP',
// Adventure log
noEventsYet: 'No events yet...',
// Misc
adventureLog: 'Adventure Log',
shopLabel: 'Shop',
healerLabel: 'Healer',
questLabel: 'Quest',
// Hero Sheet tabs
heroSheetQuestBadgeAria: 'Quests ready to turn in: {count}',
stats: 'Stats',
character: 'Char',
journal: 'Journal',
quests: 'Quests',
hero: 'Hero',
// Settings
settings: 'Settings',
language: 'Language',
english: 'English',
russian: 'Russian',
} as const;
export type TranslationKey = keyof typeof en;
export type Translations = Record<TranslationKey, string>;

@ -0,0 +1,73 @@
import { createContext, useContext } from 'react';
import { en, type TranslationKey, type Translations } from './en';
import { ru } from './ru';
export type Locale = 'en' | 'ru';
const bundles: Record<Locale, Translations> = { en, ru };
/** Detect locale from Telegram WebApp or browser */
export function detectLocale(): Locale {
// Check localStorage first (user override)
try {
const saved = localStorage.getItem('autohero_locale');
if (saved === 'en' || saved === 'ru') return saved;
} catch { /* ignore */ }
// Telegram Mini App language
try {
const tg = (window as any).Telegram?.WebApp;
const lang: string | undefined =
tg?.initDataUnsafe?.user?.language_code ?? tg?.language_code;
if (lang?.startsWith('ru')) return 'ru';
} catch { /* ignore */ }
// Browser language fallback
const nav = navigator.language ?? (navigator as any).userLanguage ?? '';
if (nav.startsWith('ru')) return 'ru';
return 'en';
}
// ---- Context ----
interface I18nValue {
tr: Translations;
locale: Locale;
setLocale: (l: Locale) => void;
}
export const I18nContext = createContext<I18nValue>({
tr: en,
locale: 'en',
setLocale: () => {},
});
/** Hook: returns the full translation object for the current locale */
export function useT(): Translations {
return useContext(I18nContext).tr;
}
/** Hook: returns locale + setter for the settings UI */
export function useLocale(): { locale: Locale; setLocale: (l: Locale) => void } {
const { locale, setLocale } = useContext(I18nContext);
return { locale, setLocale };
}
/**
* Interpolate {placeholders} in a translation string.
* Usage: t(translations.levelUp, { level: 5 }) => "Level up! Now level 5"
*/
export function t(template: string, vars?: Record<string, string | number>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (_, key) =>
vars[key] != null ? String(vars[key]) : `{${key}}`,
);
}
/** Get translations bundle for a locale */
export function getTranslations(locale: Locale): Translations {
return bundles[locale] ?? en;
}
export type { TranslationKey, Translations };

@ -0,0 +1,192 @@
import type { Translations } from './en';
export const ru: Translations = {
// General
loading: '\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0433\u0435\u0440\u043e\u044f...',
close: '\u0417\u0430\u043a\u0440\u044b\u0442\u044c',
cancel: '\u041e\u0442\u043c\u0435\u043d\u0430',
confirm: '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c',
empty: '\u041f\u0443\u0441\u0442\u043e',
none: '\u041d\u0435\u0442',
error: '\u041e\u0448\u0438\u0431\u043a\u0430',
back: '\u041d\u0430\u0437\u0430\u0434',
// Stats
hp: 'HP',
atk: '\u0410\u0422\u041a',
def: '\u0417\u0410\u0429',
spd: '\u0421\u043a\u043e\u0440.',
moveSpd: '\u0421\u043a\u043e\u0440. \u0434\u0432\u0438\u0436.',
str: '\u0421\u0418\u041b',
con: '\u0412\u042b\u041d',
agi: '\u041b\u041e\u0412',
luck: '\u0423\u0414\u0410\u0427',
xp: '\u041e\u041f',
gold: '\u0417\u043e\u043b\u043e\u0442\u043e',
level: '\u0423\u0440',
stat: '\u0421\u0422\u0410\u0422',
// Hero Panel
heroStats: '\u0421\u0442\u0430\u0442\u044b \u0433\u0435\u0440\u043e\u044f',
experience: '\u041e\u043f\u044b\u0442',
activeBuffs: '\u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0435 \u0431\u0430\u0444\u044b',
activeDebuffs: '\u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0435 \u0434\u0435\u0431\u0430\u0444\u044b',
// Equipment
equipment: '\u0421\u043d\u0430\u0440\u044f\u0436\u0435\u043d\u0438\u0435',
slotWeapon: '\u041e\u0440\u0443\u0436\u0438\u0435',
slotOffHand: '\u041b\u0435\u0432\u0430\u044f \u0440\u0443\u043a\u0430',
slotHead: '\u0413\u043e\u043b\u043e\u0432\u0430',
slotChest: '\u041d\u0430\u0433\u0440\u0443\u0434\u043d\u0438\u043a',
slotLegs: '\u041d\u043e\u0433\u0438',
slotFeet: '\u041e\u0431\u0443\u0432\u044c',
slotCloak: '\u041f\u043b\u0430\u0449',
slotNeck: '\u0428\u0435\u044f',
slotRing: '\u041a\u043e\u043b\u044c\u0446\u043e',
slotWrist: '\u0417\u0430\u043f\u044f\u0441\u0442\u044c\u0435',
slotHands: '\u0420\u0443\u043a\u0438',
slotQuiver: '\u041a\u043e\u043b\u0447\u0430\u043d',
inventory: '\u0418\u043d\u0432\u0435\u043d\u0442\u0430\u0440\u044c',
// Rarity
common: '\u041e\u0431\u044b\u0447\u043d\u043e\u0435',
uncommon: '\u041d\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0435',
rare: '\u0420\u0435\u0434\u043a\u043e\u0435',
epic: '\u042d\u043f\u0438\u0447\u0435\u0441\u043a\u043e\u0435',
legendary: '\u041b\u0435\u0433\u0435\u043d\u0434\u0430\u0440\u043d\u043e\u0435',
// Buff names
buffRush: '\u0420\u044b\u0432\u043e\u043a',
buffRage: '\u042f\u0440\u043e\u0441\u0442\u044c',
buffShield: '\u0429\u0438\u0442',
buffLuck: '\u0423\u0434\u0430\u0447\u0430',
buffResurrection: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435',
buffHeal: '\u0418\u0441\u0446\u0435\u043b\u0435\u043d\u0438\u0435',
buffPowerPotion: '\u0421\u0438\u043b\u0430',
buffWarCry: '\u041a\u043b\u0438\u0447',
// Buff descriptions
buffRushDesc: '+50% \u043a \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f',
buffRageDesc: '+100% \u043a \u0443\u0440\u043e\u043d\u0443',
buffShieldDesc: '-50% \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u0443\u0440\u043e\u043d\u0430',
buffLuckDesc: 'x2.5 \u0434\u0440\u043e\u043f \u043f\u0440\u0435\u0434\u043c\u0435\u0442\u043e\u0432',
buffResurrectionDesc: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0441 50% HP',
buffHealDesc: '+50% HP \u043c\u0433\u043d\u043e\u0432\u0435\u043d\u043d\u043e',
buffPowerPotionDesc: '+150% \u043a \u0443\u0440\u043e\u043d\u0443',
buffWarCryDesc: '+100% \u043a \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 \u0430\u0442\u0430\u043a\u0438',
// Buff UI
charges: '\u0417\u0430\u0440\u044f\u0434\u044b',
refillsAt: '\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0432',
refill: '\u041f\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u044c',
refillQuestion: '\u041f\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u044c {label}?',
noChargesLeft: '\u041d\u0435\u0442 \u0437\u0430\u0440\u044f\u0434\u043e\u0432 \u0434\u043b\u044f {label}',
// Debuff names
debuffPoison: '\u042f\u0434',
debuffFreeze: '\u0417\u0430\u043c\u043e\u0440\u043e\u0437\u043a\u0430',
debuffBurn: '\u041e\u0436\u043e\u0433',
debuffStun: '\u041e\u0433\u043b\u0443\u0448\u0435\u043d\u0438\u0435',
debuffSlow: '\u0417\u0430\u043c\u0435\u0434\u043b\u0435\u043d\u0438\u0435',
debuffWeaken: '\u041e\u0441\u043b\u0430\u0431\u043b\u0435\u043d\u0438\u0435',
debuffIceSlow: '\u041b\u0435\u0434\u044f\u043d\u043e\u0435 \u0437\u0430\u043c\u0435\u0434\u043b\u0435\u043d\u0438\u0435',
// Quest system
questLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u0437\u0430\u0434\u0430\u043d\u0438\u0439',
noActiveQuests: '\u041d\u0435\u0442 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u0434\u0430\u043d\u0438\u0439. \u041f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u0442\u0435 \u0441 NPC!',
claimRewards: '\u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
questDestination: '\u041f\u0443\u043d\u043a\u0442 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f',
abandon: '\u041e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f',
acceptQuest: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
questAccepted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043d\u044f\u0442\u043e!',
questRewardsClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
questAbandoned: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u043e',
failedToAcceptQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u0438\u043d\u044f\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
failedToClaimRewards: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
failedToAbandonQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
completed: '\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e',
// NPC
questGiver: '\u041a\u0432\u0435\u0441\u0442\u043e\u0434\u0430\u0442\u0435\u043b\u044c',
merchant: '\u0422\u043e\u0440\u0433\u043e\u0432\u0435\u0446',
healer: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
npc: 'NPC',
buyPotion: '\u041a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
healToFull: '\u0418\u0441\u0446\u0435\u043b\u0438\u0442\u044c \u043f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e',
boughtPotion: '\u041a\u0443\u043f\u043b\u0435\u043d\u043e \u0437\u0435\u043b\u044c\u0435 \u0437\u0430 {cost} \u0437\u043e\u043b\u043e\u0442\u0430',
healedToFull: '\u0417\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e!',
notEnoughGold: '\u041d\u0435\u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0437\u043e\u043b\u043e\u0442\u0430!',
failedToBuyPotion: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
failedToHeal: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0438\u0441\u0446\u0435\u043b\u0438\u0442\u044c',
// Wandering NPC
giveGoldForItem: '\u041e\u0442\u0434\u0430\u0442\u044c {cost} \u0437\u043e\u043b\u043e\u0442\u0430 \u0437\u0430 \u0437\u0430\u0433\u0430\u0434\u043e\u0447\u043d\u044b\u0439 \u043f\u0440\u0435\u0434\u043c\u0435\u0442?',
accept: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
decline: '\u041e\u0442\u043a\u043b\u043e\u043d\u0438\u0442\u044c',
giving: '\u041e\u0442\u0434\u0430\u044e...',
// Death screen
youDied: '\u0412\u042b \u041f\u041e\u0413\u0418\u0411\u041b\u0418',
reviveNow: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c',
freeRevivesLeft: '\u0411\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}',
autoReviveIn: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 {timer}\u0441',
noFreeRevives: '\u041d\u0435\u0442 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u2014 \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430',
// Name entry
chooseHeroName: '\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043c\u044f \u0433\u0435\u0440\u043e\u044f',
enterName: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f...',
continue: '\u041f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c',
saving: '\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...',
nameTaken: '\u0418\u043c\u044f \u0443\u0436\u0435 \u0437\u0430\u043d\u044f\u0442\u043e, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0434\u0440\u0443\u0433\u043e\u0435',
invalidName: '\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u043e\u0435 \u0438\u043c\u044f',
serverError: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 ({status})',
connectionFailed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430',
// Offline report
whileYouWereAway: '\u041f\u043e\u043a\u0430 \u0432\u0430\u0441 \u043d\u0435 \u0431\u044b\u043b\u043e...',
killedMonsters: '\u0423\u0431\u0438\u0442\u043e \u043c\u043e\u043d\u0441\u0442\u0440\u043e\u0432: {count}',
gainedXP: '+{xp} \u041e\u041f',
gainedGold: '+{gold} \u0437\u043e\u043b\u043e\u0442\u0430',
gainedLevels: '\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0443\u0440\u043e\u0432\u043d\u0435\u0439: {levels}!',
tapToDismiss: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f',
// Toasts
levelUp: '\u041d\u043e\u0432\u044b\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c: {level}!',
heroRevived: '\u0413\u0435\u0440\u043e\u0439 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d!',
entering: '\u0412\u0445\u043e\u0434 \u0432 {townName}',
newEquipment: '\u041d\u043e\u0432\u043e\u0435 {slot}: {itemName}',
potionsCollected: '+{count} \u0437\u0435\u043b\u044c\u0435(\u0439)',
questProgress: '{title} ({current}/{target})',
questCompleted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e: {title}!',
buffLimitReached: '\u041b\u0438\u043c\u0438\u0442 \u0431\u0430\u0444\u043e\u0432 \u0434\u043e\u0441\u0442\u0438\u0433\u043d\u0443\u0442',
reviveNotAllowed: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e',
dailyTaskClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u0437\u0430 \u0437\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
failedToClaimReward: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
// Minimap
map: '\u041a\u0410\u0420\u0422\u0410',
// Adventure log
noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...',
// Misc
adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439',
shopLabel: '\u041c\u0430\u0433\u0430\u0437\u0438\u043d',
healerLabel: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
questLabel: '\u041a\u0432\u0435\u0441\u0442',
// Hero Sheet tabs
heroSheetQuestBadgeAria:
'\u041a\u0432\u0435\u0441\u0442\u044b \u043a \u0441\u0434\u0430\u0447\u0435: {count}',
stats: '\u0421\u0442\u0430\u0442\u044b',
character: '\u041f\u0435\u0440\u0441.',
journal: '\u0416\u0443\u0440\u043d\u0430\u043b',
quests: '\u041a\u0432\u0435\u0441\u0442\u044b',
hero: '\u0413\u0435\u0440\u043e\u0439',
// Settings
settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
language: '\u042f\u0437\u044b\u043a',
english: 'English',
russian: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439',
};

@ -108,6 +108,7 @@ export interface HeroResponse {
luck: number;
state: string;
restKind?: string;
excursionPhase?: string;
weaponId: number;
armorId: number;
weapon: WeaponResponse | null;
@ -155,6 +156,7 @@ export interface HeroResponse {
export interface AuthResponse {
token: string;
/** 0 if the player has not created a hero yet. */
heroId: number;
}
@ -179,10 +181,30 @@ export interface OfflineReport {
}
export interface InitHeroResponse {
hero: HeroResponse;
/** Null until the player submits a valid name (no DB row until then). */
hero: HeroResponse | null;
offlineReport: OfflineReport | null;
mapRef: MapRefResponse;
needsName?: boolean;
/** Runtime tuning: merchant potion price (from DB / runtime_config). */
npcCostPotion?: number;
/** Runtime tuning: healer full heal price (from DB / runtime_config). */
npcCostHeal?: number;
}
/** Matches server defaults when init omits costs (must stay in sync with tuning.DefaultValues). */
export function defaultNpcShopCosts(): { potionCost: number; healCost: number } {
return { potionCost: 50, healCost: 100 };
}
export function npcShopCostsFromInit(res: InitHeroResponse): { potionCost: number; healCost: number } {
const d = defaultNpcShopCosts();
const p = res.npcCostPotion;
const h = res.npcCostHeal;
return {
potionCost: typeof p === 'number' && p > 0 ? p : d.potionCost,
healCost: typeof h === 'number' && h > 0 ? h : d.healCost,
};
}
/** Initialize or retrieve a hero from the backend (creates on first call) */
@ -434,7 +456,7 @@ export async function usePotion(telegramId?: number): Promise<HeroResponse> {
// ---- Towns ----
import type { Town, HeroQuest, NPC, Quest } from '../game/types';
import type { Town, HeroQuest, NPC, Quest, BuildingData } from '../game/types';
/** Fetch all towns */
export async function getTowns(): Promise<Town[]> {
@ -446,10 +468,16 @@ export async function getTownNPCs(townId: number): Promise<NPC[]> {
return apiGet<NPC[]>(`/towns/${townId}/npcs`);
}
/** Fetch buildings for a town */
export async function getTownBuildings(townId: number): Promise<BuildingData[]> {
return apiGet<BuildingData[]>(`/towns/${townId}/buildings`);
}
/** Fetch available quests from an NPC */
export async function getNPCQuests(npcId: number, telegramId?: number): Promise<Quest[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<Quest[]>(`/npcs/${npcId}/quests${query}`);
const data = await apiGet<Quest[] | null>(`/npcs/${npcId}/quests${query}`);
return Array.isArray(data) ? data : [];
}
// ---- Hero Quests ----
@ -470,6 +498,7 @@ interface HeroQuestRaw {
targetCount: number;
targetEnemyType?: string;
targetTownId?: number;
targetTownName?: string;
dropChance: number;
minLevel: number;
maxLevel: number;
@ -498,6 +527,7 @@ function flattenHeroQuest(raw: HeroQuestRaw): HeroQuest {
description: raw.description ?? q?.description ?? '',
type: raw.type ?? q?.type ?? '',
targetCount: raw.targetCount ?? q?.targetCount ?? 0,
targetTownName: raw.quest?.targetTownName ?? q?.targetTownName,
progress: raw.progress,
status: (raw.status as HeroQuest['status']) ?? 'accepted',
rewardXp: raw.rewardXp ?? q?.rewardXp ?? 0,
@ -541,10 +571,10 @@ export async function buyPotion(telegramId?: number): Promise<HeroResponse> {
return apiPost<HeroResponse>(`/hero/npc-buy-potion${query}`);
}
/** Heal to full at a healer NPC (matches backend POST /api/v1/hero/npc-heal) */
export async function healAtNPC(telegramId?: number): Promise<HeroResponse> {
/** Heal to full at a healer NPC (matches backend POST /api/v1/hero/npc-heal; body must be JSON with npcId) */
export async function healAtNPC(telegramId?: number, npcId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/npc-heal${query}`);
return apiPost<HeroResponse>(`/hero/npc-heal${query}`, { npcId: npcId ?? 0 });
}
// ---- NPC Proximity & Interaction ----

@ -19,6 +19,9 @@ export const TILE_HEIGHT = 48;
/** Camera follow lerp factor (0 = no follow, 1 = instant snap) */
export const CAMERA_FOLLOW_LERP = 0.08;
/** Reference frame duration for dt-scaled camera smoothing (~60 Hz) */
export const CAMERA_LERP_REFERENCE_MS = 1000 / 60;
/** Map zoom level (<1 = zoomed out to show more tiles, 1 = default) */
export const MAP_ZOOM = 1.0;
@ -44,7 +47,7 @@ export const WS_HEARTBEAT_TIMEOUT_MS = 5000;
export const MAX_ACCUMULATED_MS = 250;
/** Floating damage number duration in milliseconds */
export const DAMAGE_NUMBER_DURATION_MS = 1200;
export const DAMAGE_NUMBER_DURATION_MS = 1800;
/** Floating damage rise distance in pixels */
export const DAMAGE_NUMBER_RISE_PX = 60;
@ -93,6 +96,7 @@ export const DEBUFF_COLORS: Record<string, string> = {
stun: '#ffdd44',
slow: '#4488ff',
weaken: '#aa44dd',
ice_slow: '#66aaff',
};
// ---- Debuff Default Durations (ms) ----
@ -104,6 +108,7 @@ export const DEBUFF_DURATION_DEFAULTS: Record<string, number> = {
stun: 2000,
slow: 4000,
weaken: 5000,
ice_slow: 4000,
};
/** Loot popup display duration in milliseconds */

@ -10,12 +10,11 @@ interface BuffStatusStripProps {
const rowStyle: CSSProperties = {
display: 'flex',
gap: 4,
flexWrap: 'nowrap',
flexWrap: 'wrap',
alignItems: 'center',
flexShrink: 0,
maxWidth: 'min(46vw, 260px)',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
width: '100%',
maxWidth: '100%',
minWidth: 0,
pointerEvents: 'none',
};

@ -11,6 +11,7 @@ const DEBUFF_META: Record<DebuffType, { icon: string; label: string }> = {
[DebuffType.Stun]: { icon: '\uD83D\uDCAB', label: 'Stun' },
[DebuffType.Slow]: { icon: '\uD83D\uDC22', label: 'Slow' },
[DebuffType.Weaken]: { icon: '\uD83D\uDCC9', label: 'Weaken' },
[DebuffType.IceSlow]: { icon: '\u2744\uFE0F', label: 'Ice Slow' },
};
// ---- Types ----

@ -0,0 +1,173 @@
import type { CSSProperties } from 'react';
import type { EquipmentItem } from '../game/types';
import { RARITY_COLORS, RARITY_GLOW } from '../shared/constants';
import { useT } from '../i18n';
import type { Translations } from '../i18n';
/**
* Grid layout (row-major):
* finger | head | neck
* weapon | chest (2 rows) | cloak
* hands | | wrist
* feet | . | legs
*/
const SLOT_LAYOUT: Array<{
key: string;
icon: string;
labelKey: keyof Translations;
area: 'finger' | 'head' | 'neck' | 'weapon' | 'chest' | 'cloak' | 'hands' | 'wrist' | 'feet' | 'legs';
}> = [
{ key: 'finger', icon: '\uD83D\uDCBF', labelKey: 'slotRing', area: 'finger' },
{ key: 'head', icon: '\u26D1\uFE0F', labelKey: 'slotHead', area: 'head' },
{ key: 'neck', icon: '\uD83D\uDCBF', labelKey: 'slotNeck', area: 'neck' },
{ key: 'main_hand', icon: '\u2694\uFE0F', labelKey: 'slotWeapon', area: 'weapon' },
{ key: 'chest', icon: '\uD83D\uDEE1\uFE0F', labelKey: 'slotChest', area: 'chest' },
{ key: 'cloak', icon: '\uD83D\uDEE1\uFE0F', labelKey: 'slotCloak', area: 'cloak' },
{ key: 'hands', icon: '\uD83D\uDC42', labelKey: 'slotHands', area: 'hands' },
{ key: 'wrist', icon: '\uD83D\uDC42', labelKey: 'slotWrist', area: 'wrist' },
{ key: 'feet', icon: '\uD83D\uDC62', labelKey: 'slotFeet', area: 'feet' },
{ key: 'legs', icon: '\uD83D\uDC62', labelKey: 'slotLegs', area: 'legs' },
];
const dollWrap: CSSProperties = {
display: 'grid',
gridTemplateAreas: `
"finger head neck"
"weapon chest cloak"
"hands chest wrist"
"feet . legs"
`,
gridTemplateColumns: 'minmax(72px, 1fr) minmax(100px, 1.2fr) minmax(72px, 1fr)',
gap: 6,
alignItems: 'stretch',
justifyItems: 'center',
padding: '8px 4px',
minHeight: 300,
};
const chestBackdrop: CSSProperties = {
gridArea: 'chest',
width: '100%',
maxWidth: 132,
minHeight: 120,
borderRadius: '45% 45% 40% 40% / 55% 55% 45% 45%',
background: 'linear-gradient(180deg, rgba(70,80,110,0.4) 0%, rgba(35,40,55,0.55) 100%)',
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: 'inset 0 -24px 48px rgba(0,0,0,0.4)',
zIndex: 0,
pointerEvents: 'none',
alignSelf: 'stretch',
justifySelf: 'center',
};
const slotBox: CSSProperties = {
width: '100%',
minWidth: 0,
maxWidth: 96,
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.14)',
backgroundColor: 'rgba(0,0,0,0.42)',
padding: '6px 6px',
fontSize: 10,
color: '#bbb',
textAlign: 'center',
zIndex: 1,
position: 'relative',
};
const slotLabel: CSSProperties = {
fontSize: 9,
fontWeight: 600,
color: 'rgba(200,210,230,0.55)',
marginBottom: 4,
textTransform: 'uppercase',
letterSpacing: 0.3,
};
const iconRow: CSSProperties = {
fontSize: 14,
marginBottom: 2,
};
const itemNameStyle: CSSProperties = {
fontWeight: 600,
fontSize: 10,
lineHeight: 1.25,
wordBreak: 'break-word',
};
const statTiny: CSSProperties = {
fontSize: 9,
color: '#888',
marginTop: 2,
};
function rarityColor(rarity: string): string {
return RARITY_COLORS[rarity.toLowerCase()] ?? '#9d9d9d';
}
function rarityGlow(rarity: string): string {
return RARITY_GLOW[rarity.toLowerCase()] ?? 'none';
}
function statLabel(statType: string, tr: Translations): string {
switch (statType) {
case 'attack': return tr.atk;
case 'defense': return tr.def;
case 'speed': return tr.spd;
default: return tr.stat;
}
}
interface EquipmentPaperDollProps {
equipment: Record<string, EquipmentItem>;
}
export function EquipmentPaperDoll({ equipment }: EquipmentPaperDollProps) {
const tr = useT();
return (
<div style={dollWrap}>
<div style={chestBackdrop} aria-hidden />
{SLOT_LAYOUT.map((def) => {
const item = equipment?.[def.key];
const gridArea = def.area;
if (!item) {
return (
<div key={def.key} style={{ ...slotBox, gridArea }}>
<div style={slotLabel}>{tr[def.labelKey]}</div>
<div style={iconRow}>{def.icon}</div>
<div style={{ color: '#555', fontStyle: 'italic', fontSize: 9 }}>{tr.empty}</div>
</div>
);
}
const color = rarityColor(item.rarity);
const glow = rarityGlow(item.rarity);
return (
<div
key={def.key}
style={{
...slotBox,
gridArea,
borderColor: `${color}55`,
boxShadow:
glow !== 'none'
? `0 0 10px ${color}33, inset 0 0 8px ${color}18`
: `inset 0 0 6px ${color}15`,
}}
>
<div style={slotLabel}>{tr[def.labelKey]}</div>
<div style={iconRow}>{def.icon}</div>
<div style={{ ...itemNameStyle, color }}>{item.name}</div>
<div style={statTiny}>
{statLabel(item.statType, tr)} {item.primaryStat} · ilvl {item.ilvl}
</div>
</div>
);
})}
</div>
);
}

@ -36,7 +36,14 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
const offsetY = -progress * DAMAGE_NUMBER_RISE_PX;
const opacity = 1 - progress * progress; // ease-out fade
const scale = data.isCrit ? 1.4 - progress * 0.4 : 1;
const scale = data.isCrit && data.kind === 'damage' ? 1.4 - progress * 0.4 : 1;
const isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded';
const color = data.kind === 'regen'
? '#44dd66'
: isOutcomeText
? (data.target === 'hero' ? '#44dd66' : '#ff5566')
: (data.isCrit ? '#ffdd44' : '#ffffff');
const fontSize = isOutcomeText ? 16 : (data.isCrit ? 24 : 18);
const style: CSSProperties = {
position: 'absolute',
@ -44,8 +51,8 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
top: data.y + offsetY,
transform: `translate(-50%, -50%) scale(${scale})`,
opacity,
color: data.isCrit ? '#ffdd44' : '#ffffff',
fontSize: data.isCrit ? 24 : 18,
color,
fontSize,
fontWeight: 900,
textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)',
pointerEvents: 'none',
@ -54,8 +61,15 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) {
return (
<div style={style}>
{data.isCrit && 'CRIT '}
{Math.round(data.value)}
{data.kind === 'damage' && (
<>
{data.isCrit && 'CRIT '}
{Math.round(data.value)}
</>
)}
{data.kind === 'regen' && `+${Math.round(data.value)}`}
{data.kind === 'blocked' && 'BLOCKED'}
{data.kind === 'evaded' && 'EVADED'}
</div>
);
}

@ -68,10 +68,16 @@ const hpBuffRowStyle: CSSProperties = {
alignItems: 'center',
gap: 8,
width: '100%',
marginTop: 8,
minWidth: 0,
};
const heroHpBuffColumnStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
width: '100%',
};
const hpBarFlex: CSSProperties = {
flex: 1,
minWidth: 0,
@ -90,6 +96,9 @@ const enemyNameStyle: CSSProperties = {
};
const bottomSection: CSSProperties = {
display: 'flex',
justifyContent: 'center',
width: '100%',
pointerEvents: 'auto',
};
@ -218,7 +227,7 @@ export function HUD({
<span style={{ fontSize: 14 }}>&#x1F9EA;</span> {hero.potions}
</button>
</div>
<div>
<div style={heroHpBuffColumnStyle}>
<div style={hpBuffRowStyle}>
<div
style={{
@ -284,9 +293,10 @@ export function HUD({
label={tr.hp}
/>
</div>
</div>
<div style={{ pointerEvents: 'auto' as const, width: '100%' }}>
<BuffStatusStrip buffs={hero.activeBuffs} nowMs={nowMs} />
</div>
{/* Per-buff charge quotas are now shown on each BuffBar button */}
</div>
<InventoryStrip hero={hero} lastLoot={lastVictoryLoot} />

@ -27,6 +27,7 @@ function debuffLabel(tr: Translations, type: DebuffType): string {
[DebuffType.Stun]: tr.debuffStun,
[DebuffType.Slow]: tr.debuffSlow,
[DebuffType.Weaken]: tr.debuffWeaken,
[DebuffType.IceSlow]: tr.debuffIceSlow,
};
return map[type] ?? type;
}
@ -130,7 +131,8 @@ export function HeroStatsContent({ hero, nowMs }: HeroPanelProps) {
|| hasActiveBuff(hero.activeBuffs, BuffType.PowerPotion, nowMs);
const atkNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Weaken, nowMs);
const spdBuffed = hasActiveBuff(hero.activeBuffs, BuffType.WarCry, nowMs);
const spdNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Freeze, nowMs);
const spdNerfed = hasActiveDebuff(hero.debuffs, DebuffType.Freeze, nowMs)
|| hasActiveDebuff(hero.debuffs, DebuffType.IceSlow, nowMs);
const defBuffed = hasActiveBuff(hero.activeBuffs, BuffType.Shield, nowMs);
return (

@ -0,0 +1,276 @@
import { useEffect, useState, type CSSProperties } from 'react';
import type { AdventureLogEntry, EquipmentItem, HeroQuest, HeroState } from '../game/types';
import { EquipmentPaperDoll } from './EquipmentPaperDoll';
import { InventoryGrid } from './InventoryGrid';
import { HeroStatsContent } from './HeroPanel';
import { AdventureLogEntries } from './AdventureLog';
import { QuestLogList } from './QuestLog';
import { useT, useLocale, type Locale } from '../i18n';
export type HeroSheetTab = 'stats' | 'character' | 'inventory' | 'journal' | 'quests' | 'settings';
const overlay: CSSProperties = {
position: 'fixed',
inset: 0,
zIndex: 800,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
pointerEvents: 'auto',
};
const backdrop: CSSProperties = {
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(4px)',
};
const panel: CSSProperties = {
position: 'relative',
width: '100%',
maxWidth: 420,
height: 'min(88vh, 640px)',
maxHeight: 'min(88vh, 640px)',
display: 'flex',
flexDirection: 'column',
borderRadius: 14,
border: '1px solid rgba(255, 255, 255, 0.12)',
backgroundColor: 'rgba(12, 14, 22, 0.94)',
boxShadow: '0 12px 48px rgba(0,0,0,0.55)',
overflow: 'hidden',
};
const header: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
};
const titleStyle: CSSProperties = {
fontSize: 15,
fontWeight: 700,
color: '#e8e8e8',
};
const closeBtn: CSSProperties = {
background: 'none',
border: 'none',
color: '#888',
fontSize: 22,
cursor: 'pointer',
padding: '2px 8px',
lineHeight: 1,
};
const tabsRow: CSSProperties = {
display: 'flex',
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
};
const tabBtn = (active: boolean): CSSProperties => ({
flex: 1,
padding: '8px 2px',
fontSize: 9,
fontWeight: 700,
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
textTransform: 'uppercase',
letterSpacing: 0.4,
background: active ? 'rgba(80, 120, 200, 0.22)' : 'transparent',
color: active ? '#c8d8ff' : '#778',
borderBottom: active ? '2px solid #6a9eef' : '2px solid transparent',
WebkitTapHighlightColor: 'transparent',
});
const body: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: 'auto',
padding: '12px 12px 16px',
fontSize: 12,
color: '#ccc',
WebkitOverflowScrolling: 'touch',
};
const goldBar: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 8,
background: 'rgba(255, 215, 0, 0.08)',
border: '1px solid rgba(255, 215, 0, 0.2)',
color: '#ffd700',
fontWeight: 700,
fontSize: 14,
};
interface HeroSheetModalProps {
open: boolean;
onClose: () => void;
initialTab?: HeroSheetTab;
hero: HeroState;
nowMs: number;
equipment: Record<string, EquipmentItem>;
logEntries: AdventureLogEntry[];
quests: HeroQuest[];
onQuestClaim: (heroQuestId: number) => void;
onQuestAbandon: (heroQuestId: number) => void;
}
export function HeroSheetModal({
open,
onClose,
initialTab = 'stats',
hero,
nowMs,
equipment,
logEntries,
quests,
onQuestClaim,
onQuestAbandon,
}: HeroSheetModalProps) {
const [tab, setTab] = useState<HeroSheetTab>(initialTab);
const tr = useT();
const { locale, setLocale } = useLocale();
useEffect(() => {
if (open) setTab(initialTab);
}, [open, initialTab]);
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [open, onClose]);
if (!open) return null;
return (
<div style={overlay}>
<div style={backdrop} onClick={onClose} aria-hidden />
<div style={panel} role="dialog" aria-modal aria-labelledby="hero-sheet-title">
<div style={header}>
<span id="hero-sheet-title" style={titleStyle}>
{tr.hero} · {tr.level}.{hero.level}
</span>
<button type="button" style={closeBtn} onClick={onClose} aria-label="Close">
{'\u2715'}
</button>
</div>
<div style={tabsRow}>
<button type="button" style={tabBtn(tab === 'stats')} onClick={() => setTab('stats')}>
{tr.stats}
</button>
<button type="button" style={tabBtn(tab === 'character')} onClick={() => setTab('character')}>
{tr.character}
</button>
<button type="button" style={tabBtn(tab === 'inventory')} onClick={() => setTab('inventory')}>
{tr.inventory}
</button>
<button type="button" style={tabBtn(tab === 'journal')} onClick={() => setTab('journal')}>
{tr.journal}
</button>
<button type="button" style={tabBtn(tab === 'quests')} onClick={() => setTab('quests')}>
{tr.quests}
</button>
<button type="button" style={tabBtn(tab === 'settings')} onClick={() => setTab('settings')}>
{'\u2699\uFE0F'}
</button>
</div>
<div style={body}>
{tab === 'stats' && <HeroStatsContent hero={hero} nowMs={nowMs} />}
{tab === 'character' && <EquipmentPaperDoll equipment={equipment} />}
{tab === 'inventory' && (
<>
<div style={goldBar}>
<span style={{ fontSize: 18 }}>{'\uD83E\uDE99'}</span>
<span>{hero.gold.toLocaleString()} {tr.gold.toLowerCase()}</span>
</div>
<InventoryGrid items={hero.inventory} />
</>
)}
{tab === 'journal' && <AdventureLogEntries entries={logEntries} />}
{tab === 'quests' && (
<QuestLogList
quests={quests}
onClaim={onQuestClaim}
onAbandon={onQuestAbandon}
/>
)}
{tab === 'settings' && (
<SettingsContent locale={locale} setLocale={setLocale} tr={tr} />
)}
</div>
</div>
</div>
);
}
// ---- Settings Tab ----
const settingRow: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderBottom: '1px solid rgba(255,255,255,0.06)',
};
const langBtn = (active: boolean): CSSProperties => ({
padding: '6px 16px',
borderRadius: 6,
border: active ? '2px solid #ffd700' : '1px solid rgba(255,255,255,0.15)',
background: active ? 'rgba(255, 215, 0, 0.15)' : 'rgba(255,255,255,0.04)',
color: active ? '#ffd700' : '#aaa',
fontWeight: active ? 700 : 400,
fontSize: 13,
cursor: 'pointer',
transition: 'all 150ms ease',
});
interface SettingsContentProps {
locale: Locale;
setLocale: (l: Locale) => void;
tr: { settings: string; language: string; english: string; russian: string };
}
function SettingsContent({ locale, setLocale, tr }: SettingsContentProps) {
return (
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#e8e8e8', marginBottom: 12 }}>
{tr.settings}
</div>
<div style={settingRow}>
<span style={{ color: '#ccc', fontSize: 13 }}>{tr.language}</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
style={langBtn(locale === 'en')}
onClick={() => setLocale('en')}
>
{tr.english}
</button>
<button
type="button"
style={langBtn(locale === 'ru')}
onClick={() => setLocale('ru')}
>
{tr.russian}
</button>
</div>
</div>
</div>
);
}

@ -0,0 +1,233 @@
import { useCallback, useState, type CSSProperties } from 'react';
import type { EquipmentItem } from '../game/types';
import { RARITY_COLORS, RARITY_GLOW } from '../shared/constants';
import { useT, type Translations } from '../i18n';
/** 5×8 = 40 слотов */
const COLS = 5;
const ROWS = 8;
const MAX_SLOTS = COLS * ROWS;
/** Large slot icons (same family as EquipmentPaperDoll) */
const SLOT_ICONS: Record<string, string> = {
finger: '\uD83D\uDCBF',
head: '\u26D1\uFE0F',
neck: '\uD83D\uDCBF',
main_hand: '\u2694\uFE0F',
off_hand: '\uD83D\uDEE1\uFE0F',
chest: '\uD83D\uDEE1\uFE0F',
cloak: '\uD83C\uDF83',
hands: '\uD83D\uDC42',
wrist: '\uD83D\uDC58',
feet: '\uD83D\uDC62',
legs: '\uD83D\uDC56',
quiver: '\uD83C\uDFF9',
};
function slotIcon(slot: string): string {
return SLOT_ICONS[slot] ?? '\uD83D\uDCE6';
}
function rarityColor(rarity: string): string {
return RARITY_COLORS[rarity.toLowerCase()] ?? '#9d9d9d';
}
function rarityGlow(rarity: string): string {
return RARITY_GLOW[rarity.toLowerCase()] ?? 'none';
}
function statLabel(tr: Translations, statType: string): string {
switch (statType) {
case 'attack': return tr.atk;
case 'defense': return tr.def;
case 'speed': return tr.spd;
default: return tr.stat;
}
}
function slotLabel(tr: Translations, slot: string): string {
const labels: Record<string, string> = {
main_hand: tr.slotWeapon,
off_hand: tr.slotOffHand,
head: tr.slotHead,
chest: tr.slotChest,
legs: tr.slotLegs,
feet: tr.slotFeet,
cloak: tr.slotCloak,
neck: tr.slotNeck,
finger: tr.slotRing,
wrist: tr.slotWrist,
hands: tr.slotHands,
quiver: tr.slotQuiver,
};
return labels[slot] ?? slot;
}
const wrap: CSSProperties = {
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid rgba(255,255,255,0.08)',
};
const title: CSSProperties = {
fontSize: 11,
fontWeight: 700,
color: 'rgba(200,210,230,0.75)',
textTransform: 'uppercase',
letterSpacing: 0.6,
marginBottom: 8,
};
// Предыдущий шаг (20% от 54px-базы), затем ещё 30% от текущих величин.
const grid: CSSProperties = {
display: 'grid',
gridTemplateColumns: `repeat(${COLS}, minmax(30px, 1fr))`,
gap: 4,
width: '100%',
overflow: 'visible',
};
const cellBase: CSSProperties = {
position: 'relative',
aspectRatio: '1',
minHeight: 0,
borderRadius: 4,
border: '1px solid rgba(60, 70, 90, 0.85)',
background:
'linear-gradient(145deg, rgba(15,18,28,0.95) 0%, rgba(25,30,42,0.85) 50%, rgba(12,14,20,0.95) 100%)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06), inset 0 -2px 6px rgba(0,0,0,0.45)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 3,
overflow: 'visible',
};
const iconDisplay: CSSProperties = {
fontSize: 15,
lineHeight: 1,
userSelect: 'none',
pointerEvents: 'none',
};
const tooltipBox: CSSProperties = {
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: 4,
minWidth: 88,
maxWidth: 157,
padding: '6px 7px',
borderRadius: 4,
background: 'rgba(12, 14, 22, 0.96)',
border: '1px solid rgba(255,255,255,0.14)',
boxShadow: '0 4px 15px rgba(0,0,0,0.55)',
zIndex: 50,
pointerEvents: 'none',
opacity: 0,
visibility: 'hidden',
transition: 'opacity 0.12s ease, visibility 0.12s ease',
};
const tooltipVisible: CSSProperties = {
opacity: 1,
visibility: 'visible',
};
const tooltipName: CSSProperties = {
fontSize: 8,
fontWeight: 700,
lineHeight: 1.3,
marginBottom: 4,
wordBreak: 'break-word',
};
const tooltipLine: CSSProperties = {
fontSize: 7,
color: 'rgba(200,210,230,0.88)',
lineHeight: 1.45,
};
const tooltipMuted: CSSProperties = {
fontSize: 7,
color: 'rgba(160,170,190,0.75)',
marginTop: 4,
};
interface InventoryGridProps {
items?: EquipmentItem[];
}
export function InventoryGrid({ items = [] }: InventoryGridProps) {
const tr = useT();
const [hoverSlot, setHoverSlot] = useState<number | null>(null);
const showTip = useCallback((i: number) => setHoverSlot(i), []);
const hideTip = useCallback(() => setHoverSlot(null), []);
const cells = Array.from({ length: MAX_SLOTS }, (_, i) => i);
return (
<div style={wrap}>
<div style={title}>{tr.inventory}</div>
<div style={grid}>
{cells.map((i) => {
const item = items[i];
const color = item ? rarityColor(item.rarity) : undefined;
const glow = item ? rarityGlow(item.rarity) : 'none';
const show = hoverSlot === i && item;
const fallbackTitle = item
? `${item.name}${item.rarity}, ilvl ${item.ilvl}, ${statLabel(tr, item.statType)} ${item.primaryStat}`
: undefined;
return (
<div
key={i}
style={{
...cellBase,
borderColor: item ? `${color}66` : cellBase.border as string,
boxShadow:
item && glow !== 'none'
? `${glow}, inset 0 1px 0 rgba(255,255,255,0.06)`
: cellBase.boxShadow,
}}
title={fallbackTitle}
onMouseEnter={() => item && showTip(i)}
onMouseLeave={hideTip}
onFocus={() => item && showTip(i)}
onBlur={hideTip}
tabIndex={item ? 0 : undefined}
>
{item && (
<div
style={{
...tooltipBox,
...(show ? tooltipVisible : {}),
}}
aria-hidden={!show}
>
<div style={{ ...tooltipName, color }}>{item.name}</div>
<div style={tooltipLine}>
{item.rarity.charAt(0).toUpperCase() + item.rarity.slice(1)} · ilvl {item.ilvl}
</div>
<div style={tooltipLine}>
{statLabel(tr, item.statType)} +{item.primaryStat}
</div>
<div style={tooltipMuted}>{slotLabel(tr, item.slot)}</div>
</div>
)}
{item ? (
<span style={iconDisplay} aria-hidden>
{slotIcon(item.slot)}
</span>
) : null}
</div>
);
})}
</div>
</div>
);
}

@ -12,6 +12,8 @@ interface NPCDialogProps {
npc: NPC;
heroQuests: HeroQuest[];
heroGold: number;
potionCost: number;
healCost: number;
onClose: () => void;
onQuestsChanged: () => void;
onHeroUpdated: (hero: HeroResponse) => void;
@ -228,17 +230,14 @@ function questTypeIcon(type: string): string {
}
}
// ---- Constants ----
const POTION_COST = 50;
const HEAL_COST = 30;
// ---- Component ----
export function NPCDialog({
npc,
heroQuests,
heroGold,
potionCost,
healCost,
onClose,
onQuestsChanged,
onHeroUpdated,
@ -255,7 +254,7 @@ export function NPCDialog({
if (npc.type !== 'quest_giver') return;
setLoading(true);
getNPCQuests(npc.id, telegramId)
.then((qs) => setAvailableQuests(qs))
.then((qs) => setAvailableQuests(Array.isArray(qs) ? qs : []))
.catch((err) => {
console.warn('[NPCDialog] Failed to fetch quests:', err);
setAvailableQuests([]);
@ -280,7 +279,7 @@ export function NPCDialog({
onToast(tr.questAccepted, '#44aaff');
onQuestsChanged();
// Remove from available list
setAvailableQuests((prev) => prev.filter((q) => q.id !== questId));
setAvailableQuests((prev) => (Array.isArray(prev) ? prev : []).filter((q) => q.id !== questId));
})
.catch((err) => {
console.warn('[NPCDialog] Failed to accept quest:', err);
@ -308,28 +307,28 @@ export function NPCDialog({
);
const handleBuyPotion = useCallback(() => {
if (heroGold < POTION_COST) {
if (heroGold < potionCost) {
onToast(tr.notEnoughGold, '#ff4444');
return;
}
buyPotion(telegramId)
.then((hero) => {
hapticImpact('medium');
onToast(t(tr.boughtPotion, { cost: POTION_COST }), '#88dd88');
onToast(t(tr.boughtPotion, { cost: potionCost }), '#88dd88');
onHeroUpdated(hero);
})
.catch((err) => {
console.warn('[NPCDialog] Failed to buy potion:', err);
onToast(tr.failedToBuyPotion, '#ff4444');
});
}, [telegramId, heroGold, onHeroUpdated, onToast]);
}, [telegramId, heroGold, potionCost, onHeroUpdated, onToast, tr]);
const handleHeal = useCallback(() => {
if (heroGold < HEAL_COST) {
if (heroGold < healCost) {
onToast(tr.notEnoughGold, '#ff4444');
return;
}
healAtNPC(telegramId)
healAtNPC(telegramId, npc.id)
.then((hero) => {
hapticImpact('medium');
onToast(tr.healedToFull, '#44cc44');
@ -339,7 +338,7 @@ export function NPCDialog({
console.warn('[NPCDialog] Failed to heal:', err);
onToast(tr.failedToHeal, '#ff4444');
});
}, [telegramId, heroGold, onHeroUpdated, onToast]);
}, [telegramId, heroGold, healCost, onHeroUpdated, onToast, npc.id, tr]);
// Quests relevant to this NPC
const npcHeroQuests = heroQuests.filter(
@ -469,10 +468,10 @@ export function NPCDialog({
<div style={{ color: '#666', fontSize: 12, textAlign: 'center', padding: 16 }}>
Loading quests...
</div>
) : availableQuests.length > 0 ? (
) : (availableQuests?.length ?? 0) > 0 ? (
<>
<div style={sectionTitleStyle}>Available Quests</div>
{availableQuests.map((q) => (
{(availableQuests ?? []).map((q) => (
<div key={q.id} style={questCardStyle}>
<div style={questTitleRow}>
<span style={{ fontSize: 14 }}>{questTypeIcon(q.type)}</span>
@ -522,14 +521,14 @@ export function NPCDialog({
<div style={sectionTitleStyle}>Shop</div>
<button
style={
heroGold >= POTION_COST
heroGold >= potionCost
? { ...serviceBtnStyle, backgroundColor: 'rgba(68, 200, 68, 0.2)', color: '#88dd88' }
: { ...disabledBtnStyle, backgroundColor: 'rgba(68, 200, 68, 0.1)', color: '#88dd88' }
}
onClick={handleBuyPotion}
disabled={heroGold < POTION_COST}
disabled={heroGold < potionCost}
>
{'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {POTION_COST} {tr.gold}
{'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {potionCost} {tr.gold}
</button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold}
@ -543,14 +542,14 @@ export function NPCDialog({
<div style={sectionTitleStyle}>Services</div>
<button
style={
heroGold >= HEAL_COST
heroGold >= healCost
? { ...serviceBtnStyle, backgroundColor: 'rgba(200, 68, 68, 0.2)', color: '#ff8888' }
: { ...disabledBtnStyle, backgroundColor: 'rgba(200, 68, 68, 0.1)', color: '#ff8888' }
}
onClick={handleHeal}
disabled={heroGold < HEAL_COST}
disabled={heroGold < healCost}
>
{'\u2764\uFE0F'} {tr.healToFull} &mdash; {HEAL_COST} {tr.gold}
{'\u2764\uFE0F'} {tr.healToFull} &mdash; {healCost} {tr.gold}
</button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold}

@ -6,6 +6,8 @@ import type { NPCData } from '../game/types';
interface NPCInteractionProps {
npc: NPCData;
heroGold: number;
potionCost: number;
healCost: number;
onViewQuests: (npc: NPCData) => void;
onBuyPotion: (npc: NPCData) => void;
onHeal: (npc: NPCData) => void;
@ -77,9 +79,6 @@ const actionBtnStyle: CSSProperties = {
textAlign: 'center',
};
const POTION_COST = 50;
const HEAL_COST = 30;
// ---- NPC appearance ----
function npcColor(type: string): { bg: string; icon: string; text: string } {
@ -100,6 +99,8 @@ function npcColor(type: string): { bg: string; icon: string; text: string } {
export function NPCInteraction({
npc,
heroGold,
potionCost,
healCost,
onViewQuests,
onBuyPotion,
onHeal,
@ -126,9 +127,9 @@ export function NPCInteraction({
case 'quest_giver':
return 'View Quests';
case 'merchant':
return `Buy Potion (${POTION_COST}g)`;
return `Buy Potion (${potionCost}g)`;
case 'healer':
return `Heal to Full (${HEAL_COST}g)`;
return `Heal to Full (${healCost}g)`;
default:
return 'Talk';
}
@ -136,8 +137,8 @@ export function NPCInteraction({
const canAfford =
npc.type === 'quest_giver' ||
(npc.type === 'merchant' && heroGold >= POTION_COST) ||
(npc.type === 'healer' && heroGold >= HEAL_COST);
(npc.type === 'merchant' && heroGold >= potionCost) ||
(npc.type === 'healer' && heroGold >= healCost);
return (
<>

@ -266,6 +266,11 @@ export function QuestLogList({ quests, onClaim, onAbandon }: QuestLogListProps)
{isExpanded && (
<>
<div style={descriptionStyle}>{q.description}</div>
{q.type === 'visit_town' && q.targetTownName ? (
<div style={{ ...descriptionStyle, color: '#9bdcff', fontSize: 11 }}>
{tr.questDestination}: {q.targetTownName}
</div>
) : null}
<div style={{ ...descriptionStyle, color: '#777', fontSize: 10 }}>
{q.npcName} &middot; {q.townName}
</div>

@ -21,6 +21,7 @@ param(
"start-rest",
"leave-town",
"start-roadside-rest",
"stop-adventure",
"stop-rest",
"time-pause",
"time-resume"
@ -172,6 +173,10 @@ switch ($Command) {
Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/start-roadside-rest" -Body @{}
}
"stop-adventure" {
Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/stop-adventure" -Body @{}
}
"stop-rest" {
Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/stop-rest" -Body @{}

Loading…
Cancel
Save