diff --git a/admin-web/Dockerfile b/admin-web/Dockerfile new file mode 100644 index 0000000..19aeab9 --- /dev/null +++ b/admin-web/Dockerfile @@ -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;"] diff --git a/admin-web/index.html b/admin-web/index.html new file mode 100644 index 0000000..1109aec --- /dev/null +++ b/admin-web/index.html @@ -0,0 +1,1445 @@ + + + + + + AutoHero Admin + + + +
+ + + diff --git a/admin-web/nginx.conf b/admin-web/nginx.conf new file mode 100644 index 0000000..b525423 --- /dev/null +++ b/admin-web/nginx.conf @@ -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; + } +} diff --git a/backend/cmd/server/server.exe b/backend/cmd/server/server.exe new file mode 100644 index 0000000..e6ad8b5 Binary files /dev/null and b/backend/cmd/server/server.exe differ diff --git a/backend/cmd/spiralcalc/main.go b/backend/cmd/spiralcalc/main.go new file mode 100644 index 0000000..abe9a83 --- /dev/null +++ b/backend/cmd/spiralcalc/main.go @@ -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)) +} diff --git a/backend/internal/game/gear_inventory.go b/backend/internal/game/gear_inventory.go new file mode 100644 index 0000000..14d8d30 --- /dev/null +++ b/backend/internal/game/gear_inventory.go @@ -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 +} diff --git a/backend/internal/game/gear_inventory_test.go b/backend/internal/game/gear_inventory_test.go new file mode 100644 index 0000000..c1c7138 --- /dev/null +++ b/backend/internal/game/gear_inventory_test.go @@ -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) + } +} + diff --git a/backend/internal/handler/api_time_middleware.go b/backend/internal/handler/api_time_middleware.go new file mode 100644 index 0000000..c55c482 --- /dev/null +++ b/backend/internal/handler/api_time_middleware.go @@ -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) + }) + } +} diff --git a/backend/internal/handler/payments.go b/backend/internal/handler/payments.go new file mode 100644 index 0000000..a7daad5 --- /dev/null +++ b/backend/internal/handler/payments.go @@ -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 +} diff --git a/backend/internal/model/buff_catalog.go b/backend/internal/model/buff_catalog.go new file mode 100644 index 0000000..cece615 --- /dev/null +++ b/backend/internal/model/buff_catalog.go @@ -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 +} diff --git a/backend/internal/model/building.go b/backend/internal/model/building.go new file mode 100644 index 0000000..dfd5765 --- /dev/null +++ b/backend/internal/model/building.go @@ -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." +} diff --git a/backend/internal/model/effect_deadlines.go b/backend/internal/model/effect_deadlines.go new file mode 100644 index 0000000..47e1f5d --- /dev/null +++ b/backend/internal/model/effect_deadlines.go @@ -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 + } +} diff --git a/backend/internal/model/gear_test.go b/backend/internal/model/gear_test.go new file mode 100644 index 0000000..6742eb9 --- /dev/null +++ b/backend/internal/model/gear_test.go @@ -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) + } +} diff --git a/backend/internal/model/town_pause.go b/backend/internal/model/town_pause.go new file mode 100644 index 0000000..edd14de --- /dev/null +++ b/backend/internal/model/town_pause.go @@ -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"` +} diff --git a/backend/internal/storage/buff_debuff_config_store.go b/backend/internal/storage/buff_debuff_config_store.go new file mode 100644 index 0000000..796ffff --- /dev/null +++ b/backend/internal/storage/buff_debuff_config_store.go @@ -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 +} diff --git a/backend/internal/storage/content_store.go b/backend/internal/storage/content_store.go new file mode 100644 index 0000000..d26d3e3 --- /dev/null +++ b/backend/internal/storage/content_store.go @@ -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 +} + diff --git a/backend/internal/storage/runtime_config_store.go b/backend/internal/storage/runtime_config_store.go new file mode 100644 index 0000000..690c381 --- /dev/null +++ b/backend/internal/storage/runtime_config_store.go @@ -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 +} + diff --git a/backend/internal/telegram/bot_api.go b/backend/internal/telegram/bot_api.go new file mode 100644 index 0000000..cf287c2 --- /dev/null +++ b/backend/internal/telegram/bot_api.go @@ -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 +} diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go new file mode 100644 index 0000000..c3266a7 --- /dev/null +++ b/backend/internal/tuning/runtime.go @@ -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 +} + diff --git a/backend/migrations/000015_subscription.sql b/backend/migrations/000015_subscription.sql new file mode 100644 index 0000000..fb42d31 --- /dev/null +++ b/backend/migrations/000015_subscription.sql @@ -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'. diff --git a/backend/migrations/000019_world_towns_spiral_v2.sql b/backend/migrations/000019_world_towns_spiral_v2.sql new file mode 100644 index 0000000..b67134d --- /dev/null +++ b/backend/migrations/000019_world_towns_spiral_v2.sql @@ -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 (1–40, 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); diff --git a/backend/migrations/000020_hero_inventory.sql b/backend/migrations/000020_hero_inventory.sql new file mode 100644 index 0000000..1a25fd3 --- /dev/null +++ b/backend/migrations/000020_hero_inventory.sql @@ -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); diff --git a/backend/migrations/000021_heroes_state_resting_in_town.sql b/backend/migrations/000021_heroes_state_resting_in_town.sql new file mode 100644 index 0000000..f8f664a --- /dev/null +++ b/backend/migrations/000021_heroes_state_resting_in_town.sql @@ -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') +); diff --git a/backend/migrations/000022_town_world_wider_spacing.sql b/backend/migrations/000022_town_world_wider_spacing.sql new file mode 100644 index 0000000..5f346db --- /dev/null +++ b/backend/migrations/000022_town_world_wider_spacing.sql @@ -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); diff --git a/backend/migrations/000023_hero_town_pause.sql b/backend/migrations/000023_hero_town_pause.sql new file mode 100644 index 0000000..8c4eac4 --- /dev/null +++ b/backend/migrations/000023_hero_town_pause.sql @@ -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; diff --git a/backend/migrations/000024_runtime_config.sql b/backend/migrations/000024_runtime_config.sql new file mode 100644 index 0000000..b1b660c --- /dev/null +++ b/backend/migrations/000024_runtime_config.sql @@ -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; + diff --git a/backend/migrations/000025_buff_debuff_config.sql b/backend/migrations/000025_buff_debuff_config.sql new file mode 100644 index 0000000..6f347bf --- /dev/null +++ b/backend/migrations/000025_buff_debuff_config.sql @@ -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; diff --git a/backend/migrations/000026_town_buildings.sql b/backend/migrations/000026_town_buildings.sql new file mode 100644 index 0000000..5f25c59 --- /dev/null +++ b/backend/migrations/000026_town_buildings.sql @@ -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 $$; diff --git a/backend/migrations/000027_cross_roads.sql b/backend/migrations/000027_cross_roads.sql new file mode 100644 index 0000000..fc0ec66 --- /dev/null +++ b/backend/migrations/000027_cross_roads.sql @@ -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; diff --git a/backend/tmp_spiral.go b/backend/tmp_spiral.go new file mode 100644 index 0000000..93b4cb9 --- /dev/null +++ b/backend/tmp_spiral.go @@ -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)) +} diff --git a/frontend/src/game/adventureLogMap.ts b/frontend/src/game/adventureLogMap.ts new file mode 100644 index 0000000..a8b7157 --- /dev/null +++ b/frontend/src/game/adventureLogMap.ts @@ -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 }; +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts new file mode 100644 index 0000000..c640e8c --- /dev/null +++ b/frontend/src/i18n/en.ts @@ -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; diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..ce857a6 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -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 = { 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({ + 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 { + 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 }; diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts new file mode 100644 index 0000000..5e237e5 --- /dev/null +++ b/frontend/src/i18n/ru.ts @@ -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', +}; diff --git a/frontend/src/ui/EquipmentPaperDoll.tsx b/frontend/src/ui/EquipmentPaperDoll.tsx new file mode 100644 index 0000000..08f046b --- /dev/null +++ b/frontend/src/ui/EquipmentPaperDoll.tsx @@ -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; +} + +export function EquipmentPaperDoll({ equipment }: EquipmentPaperDollProps) { + const tr = useT(); + return ( +
+
+ + {SLOT_LAYOUT.map((def) => { + const item = equipment?.[def.key]; + const gridArea = def.area; + + if (!item) { + return ( +
+
{tr[def.labelKey]}
+
{def.icon}
+
{tr.empty}
+
+ ); + } + + const color = rarityColor(item.rarity); + const glow = rarityGlow(item.rarity); + + return ( +
+
{tr[def.labelKey]}
+
{def.icon}
+
{item.name}
+
+ {statLabel(item.statType, tr)} {item.primaryStat} · ilvl {item.ilvl} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/ui/HeroSheetModal.tsx b/frontend/src/ui/HeroSheetModal.tsx new file mode 100644 index 0000000..465a9ce --- /dev/null +++ b/frontend/src/ui/HeroSheetModal.tsx @@ -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; + 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(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 ( +
+
+
+
+ + {tr.hero} · {tr.level}.{hero.level} + + +
+
+ + + + + + +
+
+ {tab === 'stats' && } + {tab === 'character' && } + {tab === 'inventory' && ( + <> +
+ {'\uD83E\uDE99'} + {hero.gold.toLocaleString()} {tr.gold.toLowerCase()} +
+ + + )} + {tab === 'journal' && } + {tab === 'quests' && ( + + )} + {tab === 'settings' && ( + + )} +
+
+
+ ); +} + +// ---- 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 ( +
+
+ {tr.settings} +
+
+ {tr.language} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/ui/InventoryGrid.tsx b/frontend/src/ui/InventoryGrid.tsx new file mode 100644 index 0000000..e64705c --- /dev/null +++ b/frontend/src/ui/InventoryGrid.tsx @@ -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 = { + 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 = { + 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(null); + + const showTip = useCallback((i: number) => setHoverSlot(i), []); + const hideTip = useCallback(() => setHoverSlot(null), []); + + const cells = Array.from({ length: MAX_SLOTS }, (_, i) => i); + + return ( +
+
{tr.inventory}
+
+ {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 ( +
item && showTip(i)} + onMouseLeave={hideTip} + onFocus={() => item && showTip(i)} + onBlur={hideTip} + tabIndex={item ? 0 : undefined} + > + {item && ( +
+
{item.name}
+
+ {item.rarity.charAt(0).toUpperCase() + item.rarity.slice(1)} · ilvl {item.ilvl} +
+
+ {statLabel(tr, item.statType)} +{item.primaryStat} +
+
{slotLabel(tr, item.slot)}
+
+ )} + + {item ? ( + + {slotIcon(item.slot)} + + ) : null} +
+ ); + })} +
+
+ ); +}