Compare commits
33 Commits
9a6762d0ba
...
220418c4c6
@ -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;
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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,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,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,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 };
|
||||
}
|
||||
@ -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',
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue