Compare commits
No commits in common. '220418c4c684f39c1e801b6fd9b1ec5c6778c7ba' and '9a6762d0baf111aecc97e932e789b335a5082cfc' have entirely different histories.
220418c4c6
...
9a6762d0ba
@ -1,89 +0,0 @@
|
|||||||
# =========================
|
|
||||||
# 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
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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
@ -1,29 +0,0 @@
|
|||||||
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.
@ -1,32 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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
@ -1,602 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,440 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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."
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
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"`
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// TownPausePersisted mirrors HeroMovement fields needed to resume resting, an in-town NPC tour,
|
|
||||||
// or a mid-adventure excursion after reconnect or during offline catch-up.
|
|
||||||
// Stored in heroes.town_pause (JSONB).
|
|
||||||
type TownPausePersisted struct {
|
|
||||||
RestUntil *time.Time `json:"restUntil,omitempty"`
|
|
||||||
RestKind RestKind `json:"restKind,omitempty"`
|
|
||||||
TownRestHealRemainder float64 `json:"townRestHealRemainder,omitempty"`
|
|
||||||
RestHealRemainder float64 `json:"restHealRemainder,omitempty"`
|
|
||||||
|
|
||||||
NPCQueue []int64 `json:"npcQueue,omitempty"`
|
|
||||||
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
|
|
||||||
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
|
|
||||||
TownVisitNPCName string `json:"townVisitNPCName,omitempty"`
|
|
||||||
TownVisitNPCType string `json:"townVisitNPCType,omitempty"`
|
|
||||||
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
|
|
||||||
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
|
|
||||||
|
|
||||||
// Walk-to-NPC: hero is mid-walk toward an NPC inside the town.
|
|
||||||
NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"`
|
|
||||||
NPCWalkFromX float64 `json:"npcWalkFromX,omitempty"`
|
|
||||||
NPCWalkFromY float64 `json:"npcWalkFromY,omitempty"`
|
|
||||||
NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
|
|
||||||
NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
|
|
||||||
NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"`
|
|
||||||
NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"`
|
|
||||||
|
|
||||||
// Plaza: walk to town center after NPC tour, then wait/rest before leaving.
|
|
||||||
TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
|
|
||||||
CenterWalkFromX float64 `json:"centerWalkFromX,omitempty"`
|
|
||||||
CenterWalkFromY float64 `json:"centerWalkFromY,omitempty"`
|
|
||||||
CenterWalkToX float64 `json:"centerWalkToX,omitempty"`
|
|
||||||
CenterWalkToY float64 `json:"centerWalkToY,omitempty"`
|
|
||||||
CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"`
|
|
||||||
CenterWalkArrive *time.Time `json:"centerWalkArrive,omitempty"`
|
|
||||||
|
|
||||||
// Excursion (mini-adventure) session persisted for reconnect / offline resume.
|
|
||||||
Excursion *ExcursionPersisted `json:"excursion,omitempty"`
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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]
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,455 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- 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'.
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- 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')
|
|
||||||
);
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
-- 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 $$;
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
-- Adjust battle lizard regen in runtime_config.payload.
|
|
||||||
UPDATE runtime_config
|
|
||||||
SET
|
|
||||||
payload = payload || '{
|
|
||||||
"enemyRegenBattleLizard": 0.01
|
|
||||||
}'::jsonb,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = TRUE;
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
//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))
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import type { AdventureLogEntry } from './types';
|
|
||||||
import type { LogEntry } from '../network/api';
|
|
||||||
|
|
||||||
/** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */
|
|
||||||
export function adventureEntriesFromServerLog(serverLog: LogEntry[]): {
|
|
||||||
entries: AdventureLogEntry[];
|
|
||||||
maxId: number;
|
|
||||||
} {
|
|
||||||
const sorted = [...serverLog].sort(
|
|
||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
||||||
);
|
|
||||||
const entries: AdventureLogEntry[] = sorted.map((entry) => ({
|
|
||||||
id: Number(entry.id),
|
|
||||||
message: entry.message,
|
|
||||||
timestamp: new Date(entry.createdAt).getTime(),
|
|
||||||
}));
|
|
||||||
const maxId = entries.reduce((m, e) => Math.max(m, e.id), 0);
|
|
||||||
return { entries, maxId };
|
|
||||||
}
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
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>;
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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 };
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
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',
|
|
||||||
};
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,276 +0,0 @@
|
|||||||
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