remove and add some stuff

master
Denis Ranneft 1 month ago
parent 3907eacb30
commit 08111d846e

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

File diff suppressed because it is too large Load Diff

@ -0,0 +1,18 @@
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;
}
}

Binary file not shown.

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

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

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

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

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

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

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

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

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

@ -0,0 +1,20 @@
package model
import "time"
// TownPausePersisted mirrors HeroMovement fields needed to resume resting or an in-town NPC tour
// after reconnect or during offline catch-up.
// Stored in heroes.town_pause (JSONB).
type TownPausePersisted struct {
RestUntil *time.Time `json:"restUntil,omitempty"`
RestKind string `json:"restKind,omitempty"`
TownRestHealRemainder float64 `json:"townRestHealRemainder,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"`
}

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

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

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

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

@ -0,0 +1,322 @@
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"`
TownNPCRollMinMs int64 `json:"townNpcRollMinMs"`
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
TownNPCRetryMs int64 `json:"townNpcRetryMs"`
TownNPCPauseMs int64 `json:"townNpcPauseMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
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"`
CombatDamageScale float64 `json:"combatDamageScale"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
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"`
}
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,
TownNPCRollMinMs: 800,
TownNPCRollMaxMs: 2600,
TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000,
TownNPCLogIntervalMs: 5_000,
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,
CombatDamageScale: 0.35,
EnemyDodgeChance: 0.20,
EnemyCriticalMinChance: 0.15,
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.02,
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,
}
}
var current atomic.Value
func init() {
v := DefaultValues()
current.Store(&v)
}
func Get() Values {
p := current.Load().(*Values)
return *p
}
func Set(v Values) {
current.Store(&v)
}
type PayloadLoader interface {
LoadRuntimeConfigPayload(ctx context.Context) ([]byte, error)
}
func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) error {
payload, err := loader.LoadRuntimeConfigPayload(ctx)
if err != nil {
if logger != nil {
logger.Warn("runtime config reload failed", "error", err)
}
return err
}
next := DefaultValues()
if len(payload) > 0 {
if err := json.Unmarshal(payload, &next); err != nil {
if logger != nil {
logger.Warn("runtime config payload parse failed", "error", err)
}
return err
}
}
Set(next)
return nil
}

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,190 @@
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',
// Quest system
questLog: 'Quest Log',
noActiveQuests: 'No active quests. Visit an NPC to accept quests!',
claimRewards: 'Claim Rewards',
abandon: 'Abandon',
acceptQuest: 'Accept',
questAccepted: 'Quest accepted!',
questRewardsClaimed: 'Quest rewards claimed!',
questAbandoned: 'Quest abandoned',
failedToAcceptQuest: 'Failed to accept quest',
failedToClaimRewards: 'Failed to claim rewards',
failedToAbandonQuest: 'Failed to abandon quest',
completed: 'Completed',
// NPC
questGiver: 'Quest Giver',
merchant: 'Merchant',
healer: 'Healer',
npc: 'NPC',
buyPotion: 'Buy Potion',
healToFull: 'Heal to Full',
boughtPotion: 'Bought a potion for {cost} gold',
healedToFull: 'Healed to full HP!',
notEnoughGold: 'Not enough gold!',
failedToBuyPotion: 'Failed to buy potion',
failedToHeal: 'Failed to heal',
// Wandering NPC
giveGoldForItem: 'Give {cost} gold for a mysterious item?',
accept: 'Accept',
decline: 'Decline',
giving: 'Giving...',
// Death screen
youDied: 'YOU DIED',
reviveNow: 'REVIVE NOW',
freeRevivesLeft: 'Free revives left: {count}',
autoReviveIn: 'Auto-revive in {timer}s',
noFreeRevives: 'No free revives left \u2014 subscription required',
// Name entry
chooseHeroName: 'Choose Your Hero Name',
enterName: 'Enter a name...',
continue: 'Continue',
saving: 'Saving...',
nameTaken: 'Name already taken, try another',
invalidName: 'Invalid name',
serverError: 'Server error ({status})',
connectionFailed: 'Connection failed, please retry',
// Offline report
whileYouWereAway: 'While you were away...',
killedMonsters: 'Killed {count} monster(s)',
gainedXP: '+{xp} XP',
gainedGold: '+{gold} gold',
gainedLevels: 'Gained {levels} level(s)!',
tapToDismiss: 'Tap anywhere to dismiss',
// Toasts
levelUp: 'Level up! Now level {level}',
heroRevived: 'Hero revived!',
entering: 'Entering {townName}',
newEquipment: 'New {slot}: {itemName}',
potionsCollected: '+{count} potion(s)',
questProgress: '{title} ({current}/{target})',
questCompleted: 'Quest completed: {title}!',
buffLimitReached: 'Buff limit reached',
reviveNotAllowed: 'Revive not allowed',
dailyTaskClaimed: 'Daily task reward claimed!',
failedToClaimReward: 'Failed to claim reward',
// Minimap
map: 'MAP',
// Adventure log
noEventsYet: 'No events yet...',
// Misc
adventureLog: 'Adventure Log',
shopLabel: 'Shop',
healerLabel: 'Healer',
questLabel: 'Quest',
// Hero Sheet tabs
heroSheetQuestBadgeAria: 'Quests ready to turn in: {count}',
stats: 'Stats',
character: 'Char',
journal: 'Journal',
quests: 'Quests',
hero: 'Hero',
// Settings
settings: 'Settings',
language: 'Language',
english: 'English',
russian: 'Russian',
} as const;
export type TranslationKey = keyof typeof en;
export type Translations = Record<TranslationKey, string>;

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

@ -0,0 +1,190 @@
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',
// 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',
abandon: '\u041e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f',
acceptQuest: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
questAccepted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043d\u044f\u0442\u043e!',
questRewardsClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
questAbandoned: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u043e',
failedToAcceptQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u0438\u043d\u044f\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
failedToClaimRewards: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
failedToAbandonQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
completed: '\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e',
// NPC
questGiver: '\u041a\u0432\u0435\u0441\u0442\u043e\u0434\u0430\u0442\u0435\u043b\u044c',
merchant: '\u0422\u043e\u0440\u0433\u043e\u0432\u0435\u0446',
healer: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
npc: 'NPC',
buyPotion: '\u041a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
healToFull: '\u0418\u0441\u0446\u0435\u043b\u0438\u0442\u044c \u043f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e',
boughtPotion: '\u041a\u0443\u043f\u043b\u0435\u043d\u043e \u0437\u0435\u043b\u044c\u0435 \u0437\u0430 {cost} \u0437\u043e\u043b\u043e\u0442\u0430',
healedToFull: '\u0417\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e!',
notEnoughGold: '\u041d\u0435\u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0437\u043e\u043b\u043e\u0442\u0430!',
failedToBuyPotion: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
failedToHeal: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0438\u0441\u0446\u0435\u043b\u0438\u0442\u044c',
// Wandering NPC
giveGoldForItem: '\u041e\u0442\u0434\u0430\u0442\u044c {cost} \u0437\u043e\u043b\u043e\u0442\u0430 \u0437\u0430 \u0437\u0430\u0433\u0430\u0434\u043e\u0447\u043d\u044b\u0439 \u043f\u0440\u0435\u0434\u043c\u0435\u0442?',
accept: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
decline: '\u041e\u0442\u043a\u043b\u043e\u043d\u0438\u0442\u044c',
giving: '\u041e\u0442\u0434\u0430\u044e...',
// Death screen
youDied: '\u0412\u042b \u041f\u041e\u0413\u0418\u0411\u041b\u0418',
reviveNow: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c',
freeRevivesLeft: '\u0411\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}',
autoReviveIn: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 {timer}\u0441',
noFreeRevives: '\u041d\u0435\u0442 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u2014 \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430',
// Name entry
chooseHeroName: '\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043c\u044f \u0433\u0435\u0440\u043e\u044f',
enterName: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f...',
continue: '\u041f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c',
saving: '\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...',
nameTaken: '\u0418\u043c\u044f \u0443\u0436\u0435 \u0437\u0430\u043d\u044f\u0442\u043e, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0434\u0440\u0443\u0433\u043e\u0435',
invalidName: '\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u043e\u0435 \u0438\u043c\u044f',
serverError: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 ({status})',
connectionFailed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430',
// Offline report
whileYouWereAway: '\u041f\u043e\u043a\u0430 \u0432\u0430\u0441 \u043d\u0435 \u0431\u044b\u043b\u043e...',
killedMonsters: '\u0423\u0431\u0438\u0442\u043e \u043c\u043e\u043d\u0441\u0442\u0440\u043e\u0432: {count}',
gainedXP: '+{xp} \u041e\u041f',
gainedGold: '+{gold} \u0437\u043e\u043b\u043e\u0442\u0430',
gainedLevels: '\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0443\u0440\u043e\u0432\u043d\u0435\u0439: {levels}!',
tapToDismiss: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f',
// Toasts
levelUp: '\u041d\u043e\u0432\u044b\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c: {level}!',
heroRevived: '\u0413\u0435\u0440\u043e\u0439 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d!',
entering: '\u0412\u0445\u043e\u0434 \u0432 {townName}',
newEquipment: '\u041d\u043e\u0432\u043e\u0435 {slot}: {itemName}',
potionsCollected: '+{count} \u0437\u0435\u043b\u044c\u0435(\u0439)',
questProgress: '{title} ({current}/{target})',
questCompleted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e: {title}!',
buffLimitReached: '\u041b\u0438\u043c\u0438\u0442 \u0431\u0430\u0444\u043e\u0432 \u0434\u043e\u0441\u0442\u0438\u0433\u043d\u0443\u0442',
reviveNotAllowed: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e',
dailyTaskClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u0437\u0430 \u0437\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
failedToClaimReward: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
// Minimap
map: '\u041a\u0410\u0420\u0422\u0410',
// Adventure log
noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...',
// Misc
adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439',
shopLabel: '\u041c\u0430\u0433\u0430\u0437\u0438\u043d',
healerLabel: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
questLabel: '\u041a\u0432\u0435\u0441\u0442',
// Hero Sheet tabs
heroSheetQuestBadgeAria:
'\u041a\u0432\u0435\u0441\u0442\u044b \u043a \u0441\u0434\u0430\u0447\u0435: {count}',
stats: '\u0421\u0442\u0430\u0442\u044b',
character: '\u041f\u0435\u0440\u0441.',
journal: '\u0416\u0443\u0440\u043d\u0430\u043b',
quests: '\u041a\u0432\u0435\u0441\u0442\u044b',
hero: '\u0413\u0435\u0440\u043e\u0439',
// Settings
settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
language: '\u042f\u0437\u044b\u043a',
english: 'English',
russian: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439',
};

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

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

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