remove and add some stuff
parent
3907eacb30
commit
08111d846e
@ -0,0 +1,9 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /admin-api/ {
|
||||||
|
proxy_pass http://backend:8080/admin/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
const n = 12
|
||||||
|
a := 1400.0
|
||||||
|
b := 4200.0
|
||||||
|
theta0 := 0.38
|
||||||
|
dtheta := 0.52
|
||||||
|
pts := make([]struct{ x, y, r float64 }, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
th := theta0 + float64(i)*dtheta
|
||||||
|
r := a + b*th
|
||||||
|
pts[i].x = r * math.Cos(th)
|
||||||
|
pts[i].y = r * math.Sin(th)
|
||||||
|
pts[i].r = r
|
||||||
|
fmt.Printf("%d: %d, %d (r=%.0f)\n", i, int(math.Round(pts[i].x)), int(math.Round(pts[i].y)), r)
|
||||||
|
}
|
||||||
|
fmt.Println("--- distances ---")
|
||||||
|
var sum float64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
j := (i + 1) % n
|
||||||
|
d := math.Hypot(pts[j].x-pts[i].x, pts[j].y-pts[i].y)
|
||||||
|
sum += d
|
||||||
|
fmt.Printf("%d->%d: %.0f\n", i, j, d)
|
||||||
|
}
|
||||||
|
fmt.Println("avg:", sum/float64(n))
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
"github.com/denisovdennis/autohero/internal/tuning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TryAutoEquipInMemory equips the item if the slot is empty or the new item improves combat
|
||||||
|
// rating by at least the runtime-configured threshold. Mutates hero.Gear. Does not touch the database.
|
||||||
|
func TryAutoEquipInMemory(hero *model.Hero, item *model.GearItem, now time.Time) bool {
|
||||||
|
hero.EnsureGearMap()
|
||||||
|
current := hero.Gear[item.Slot]
|
||||||
|
if current == nil {
|
||||||
|
hero.Gear[item.Slot] = item
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
oldRating := hero.CombatRatingAt(now)
|
||||||
|
hero.Gear[item.Slot] = item
|
||||||
|
if hero.CombatRatingAt(now) >= oldRating*tuning.Get().AutoEquipThreshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
hero.Gear[item.Slot] = current
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryEquipOrStashOffline runs TryAutoEquipInMemory; if not equipped, appends to inventory
|
||||||
|
// or invokes onDiscard with an adventure-log message when the backpack is full.
|
||||||
|
func TryEquipOrStashOffline(hero *model.Hero, item *model.GearItem, now time.Time, onDiscard func(string)) {
|
||||||
|
hero.EnsureInventorySlice()
|
||||||
|
if TryAutoEquipInMemory(hero, item, now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(hero.Inventory) >= model.MaxInventorySlots {
|
||||||
|
if onDiscard != nil {
|
||||||
|
onDiscard(fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hero.Inventory = append(hero.Inventory, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoSellRandomInventoryShare sells a random share of inventory items.
|
||||||
|
// At least minShare (0..1) of current inventory is sold; returns sold count and gold gained.
|
||||||
|
func AutoSellRandomInventoryShare(hero *model.Hero, minShare float64, rng *rand.Rand) (int, int64) {
|
||||||
|
if hero == nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
hero.EnsureInventorySlice()
|
||||||
|
n := len(hero.Inventory)
|
||||||
|
if n == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
if minShare < 0 {
|
||||||
|
minShare = 0
|
||||||
|
}
|
||||||
|
if minShare > 1 {
|
||||||
|
minShare = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
minSell := int(math.Ceil(float64(n) * minShare))
|
||||||
|
if minSell < 1 {
|
||||||
|
minSell = 1
|
||||||
|
}
|
||||||
|
if minSell > n {
|
||||||
|
minSell = n
|
||||||
|
}
|
||||||
|
|
||||||
|
var soldCount int
|
||||||
|
if n == minSell {
|
||||||
|
soldCount = n
|
||||||
|
} else if rng == nil {
|
||||||
|
soldCount = minSell + rand.Intn(n-minSell+1)
|
||||||
|
} else {
|
||||||
|
soldCount = minSell + rng.Intn(n-minSell+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
perm := make([]int, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
perm[i] = i
|
||||||
|
}
|
||||||
|
if rng == nil {
|
||||||
|
rand.Shuffle(n, func(i, j int) { perm[i], perm[j] = perm[j], perm[i] })
|
||||||
|
} else {
|
||||||
|
rng.Shuffle(n, func(i, j int) { perm[i], perm[j] = perm[j], perm[i] })
|
||||||
|
}
|
||||||
|
|
||||||
|
sold := make(map[int]struct{}, soldCount)
|
||||||
|
for i := 0; i < soldCount; i++ {
|
||||||
|
sold[perm[i]] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := make([]*model.GearItem, 0, n-soldCount)
|
||||||
|
var goldGained int64
|
||||||
|
for i, item := range hero.Inventory {
|
||||||
|
if _, ok := sold[i]; ok {
|
||||||
|
if item != nil {
|
||||||
|
goldGained += model.AutoSellPrice(item.Rarity)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, item)
|
||||||
|
}
|
||||||
|
hero.Inventory = kept
|
||||||
|
hero.Gold += goldGained
|
||||||
|
return soldCount, goldGained
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAutoSellRandomInventoryShare_SellsAtLeastThirtyPercent(t *testing.T) {
|
||||||
|
hero := &model.Hero{
|
||||||
|
Gold: 0,
|
||||||
|
Inventory: []*model.GearItem{
|
||||||
|
{Rarity: model.RarityCommon},
|
||||||
|
{Rarity: model.RarityUncommon},
|
||||||
|
{Rarity: model.RarityRare},
|
||||||
|
{Rarity: model.RarityEpic},
|
||||||
|
{Rarity: model.RarityLegendary},
|
||||||
|
{Rarity: model.RarityCommon},
|
||||||
|
{Rarity: model.RarityUncommon},
|
||||||
|
{Rarity: model.RarityRare},
|
||||||
|
{Rarity: model.RarityEpic},
|
||||||
|
{Rarity: model.RarityLegendary},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
startN := len(hero.Inventory)
|
||||||
|
startGold := hero.Gold
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewSource(7))
|
||||||
|
soldCount, goldGained := AutoSellRandomInventoryShare(hero, 0.30, rng)
|
||||||
|
|
||||||
|
minExpected := int(math.Ceil(float64(startN) * 0.30))
|
||||||
|
if soldCount < minExpected {
|
||||||
|
t.Fatalf("soldCount=%d, want >= %d", soldCount, minExpected)
|
||||||
|
}
|
||||||
|
if soldCount > startN {
|
||||||
|
t.Fatalf("soldCount=%d, inventory=%d", soldCount, startN)
|
||||||
|
}
|
||||||
|
if len(hero.Inventory) != startN-soldCount {
|
||||||
|
t.Fatalf("inventory len=%d, want %d", len(hero.Inventory), startN-soldCount)
|
||||||
|
}
|
||||||
|
if goldGained <= 0 {
|
||||||
|
t.Fatalf("goldGained=%d, want > 0", goldGained)
|
||||||
|
}
|
||||||
|
if hero.Gold != startGold+goldGained {
|
||||||
|
t.Fatalf("hero gold=%d, want %d", hero.Gold, startGold+goldGained)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APITimePausedMiddleware blocks mutating /api/v1 requests while global simulation time is frozen.
|
||||||
|
// GET/HEAD/OPTIONS still work so clients can read state.
|
||||||
|
func APITimePausedMiddleware(engine *game.Engine) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if engine != nil && engine.IsTimePaused() {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"error": "server time is paused",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,440 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
"github.com/denisovdennis/autohero/internal/storage"
|
||||||
|
"github.com/denisovdennis/autohero/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaymentsHandler handles Telegram Payments invoice creation and webhook callbacks.
|
||||||
|
type PaymentsHandler struct {
|
||||||
|
botToken string
|
||||||
|
paymentProviderToken string
|
||||||
|
store *storage.HeroStore
|
||||||
|
logStore *storage.LogStore
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPaymentsHandler creates a new PaymentsHandler.
|
||||||
|
func NewPaymentsHandler(
|
||||||
|
botToken, paymentProviderToken string,
|
||||||
|
store *storage.HeroStore,
|
||||||
|
logStore *storage.LogStore,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *PaymentsHandler {
|
||||||
|
return &PaymentsHandler{
|
||||||
|
botToken: botToken,
|
||||||
|
paymentProviderToken: paymentProviderToken,
|
||||||
|
store: store,
|
||||||
|
logStore: logStore,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request / response types ---
|
||||||
|
|
||||||
|
type createInvoiceRequest struct {
|
||||||
|
Type string `json:"type"` // "subscription_weekly", "buff_refill", "resurrection_refill"
|
||||||
|
BuffType string `json:"buffType"` // required when type == "buff_refill"
|
||||||
|
}
|
||||||
|
|
||||||
|
type createInvoiceResponse struct {
|
||||||
|
InvoiceURL string `json:"invoiceUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CreateInvoice ---
|
||||||
|
|
||||||
|
// CreateInvoice generates a Telegram invoice link for the requested purchase.
|
||||||
|
// POST /api/v1/payments/create-invoice
|
||||||
|
func (h *PaymentsHandler) CreateInvoice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
telegramID, ok := resolveTelegramID(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createInvoiceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("create-invoice: load hero failed", "telegram_id", telegramID, "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hero == nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
params, err := h.buildInvoiceParams(req, hero.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link, err := telegram.CreateInvoiceLink(h.botToken, params)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("create-invoice: telegram API failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create invoice"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("invoice link created",
|
||||||
|
"hero_id", hero.ID,
|
||||||
|
"type", req.Type,
|
||||||
|
"payload", params.Payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, createInvoiceResponse{InvoiceURL: link})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInvoiceParams maps the client request to Telegram invoice parameters.
|
||||||
|
func (h *PaymentsHandler) buildInvoiceParams(req createInvoiceRequest, heroID int64, now time.Time) (telegram.InvoiceLinkParams, error) {
|
||||||
|
ts := now.Unix()
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "subscription_weekly":
|
||||||
|
return telegram.InvoiceLinkParams{
|
||||||
|
Title: "Weekly Subscription",
|
||||||
|
Description: "7 days of x2 buffs and x2 revives",
|
||||||
|
Payload: fmt.Sprintf("sub_weekly_%d_%d", heroID, ts),
|
||||||
|
ProviderToken: h.paymentProviderToken,
|
||||||
|
Currency: "RUB",
|
||||||
|
Prices: []telegram.LabeledAmount{
|
||||||
|
{Label: "Weekly Subscription", Amount: int(model.SubscriptionWeeklyPrice() * 100)}, // rubles -> kopecks
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "buff_refill":
|
||||||
|
bt, valid := model.ValidBuffType(req.BuffType)
|
||||||
|
if !valid {
|
||||||
|
return telegram.InvoiceLinkParams{}, fmt.Errorf("invalid buff type: %s", req.BuffType)
|
||||||
|
}
|
||||||
|
if bt == model.BuffResurrection {
|
||||||
|
return telegram.InvoiceLinkParams{}, fmt.Errorf("use type \"resurrection_refill\" for resurrection")
|
||||||
|
}
|
||||||
|
return telegram.InvoiceLinkParams{
|
||||||
|
Title: fmt.Sprintf("Buff Refill: %s", strings.Title(req.BuffType)),
|
||||||
|
Description: fmt.Sprintf("Refill %s buff charges to maximum", req.BuffType),
|
||||||
|
Payload: fmt.Sprintf("buff_%s_%d_%d", req.BuffType, heroID, ts),
|
||||||
|
ProviderToken: h.paymentProviderToken,
|
||||||
|
Currency: "RUB",
|
||||||
|
Prices: []telegram.LabeledAmount{
|
||||||
|
{Label: "Buff Refill", Amount: model.BuffRefillPrice() * 100},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "resurrection_refill":
|
||||||
|
return telegram.InvoiceLinkParams{
|
||||||
|
Title: "Resurrection Refill",
|
||||||
|
Description: "Refill Resurrection charges",
|
||||||
|
Payload: fmt.Sprintf("buff_resurrection_%d_%d", heroID, ts),
|
||||||
|
ProviderToken: h.paymentProviderToken,
|
||||||
|
Currency: "RUB",
|
||||||
|
Prices: []telegram.LabeledAmount{
|
||||||
|
{Label: "Resurrection Refill", Amount: model.ResurrectionRefillPrice() * 100},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return telegram.InvoiceLinkParams{}, fmt.Errorf("unknown purchase type: %s", req.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telegram Webhook ---
|
||||||
|
|
||||||
|
// TelegramWebhook handles incoming Telegram Update objects for payment callbacks.
|
||||||
|
// POST /api/v1/payments/telegram-webhook
|
||||||
|
func (h *PaymentsHandler) TelegramWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var update telegramUpdate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||||
|
h.logger.Warn("telegram-webhook: invalid body", "error", err)
|
||||||
|
// Always return 200 to Telegram so it does not retry garbage.
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pre_checkout_query — must respond within 10 seconds.
|
||||||
|
if update.PreCheckoutQuery != nil {
|
||||||
|
h.handlePreCheckout(update.PreCheckoutQuery)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle successful_payment inside a message.
|
||||||
|
if update.Message != nil && update.Message.SuccessfulPayment != nil {
|
||||||
|
h.handleSuccessfulPayment(r.Context(), update.Message)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown update type — acknowledge and ignore.
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePreCheckout approves a pre-checkout query after basic payload validation.
|
||||||
|
func (h *PaymentsHandler) handlePreCheckout(q *preCheckoutQuery) {
|
||||||
|
// Validate payload format: must start with "sub_weekly_" or "buff_".
|
||||||
|
payload := q.InvoicePayload
|
||||||
|
valid := strings.HasPrefix(payload, "sub_weekly_") || strings.HasPrefix(payload, "buff_")
|
||||||
|
if !valid {
|
||||||
|
h.logger.Warn("pre_checkout: unknown payload format", "payload", payload)
|
||||||
|
if err := telegram.AnswerPreCheckoutQuery(h.botToken, q.ID, false, "Unknown purchase type"); err != nil {
|
||||||
|
h.logger.Error("pre_checkout: answer failed", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("pre_checkout: approving", "query_id", q.ID, "payload", payload)
|
||||||
|
if err := telegram.AnswerPreCheckoutQuery(h.botToken, q.ID, true, ""); err != nil {
|
||||||
|
h.logger.Error("pre_checkout: answer failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSuccessfulPayment processes a completed Telegram payment.
|
||||||
|
func (h *PaymentsHandler) handleSuccessfulPayment(ctx context.Context, msg *telegramMessage) {
|
||||||
|
sp := msg.SuccessfulPayment
|
||||||
|
payload := sp.InvoicePayload
|
||||||
|
|
||||||
|
h.logger.Info("successful_payment received",
|
||||||
|
"payload", payload,
|
||||||
|
"total_amount", sp.TotalAmount,
|
||||||
|
"currency", sp.Currency,
|
||||||
|
"telegram_charge_id", sp.TelegramPaymentChargeID,
|
||||||
|
"provider_charge_id", sp.ProviderPaymentChargeID,
|
||||||
|
)
|
||||||
|
|
||||||
|
heroID, err := parseHeroIDFromPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("successful_payment: parse payload failed", "payload", payload, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hero, err := h.store.GetByID(ctx, heroID)
|
||||||
|
if err != nil || hero == nil {
|
||||||
|
h.logger.Error("successful_payment: load hero failed", "hero_id", heroID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(payload, "sub_weekly_"):
|
||||||
|
h.applySubscription(ctx, hero, now, sp)
|
||||||
|
|
||||||
|
case strings.HasPrefix(payload, "buff_"):
|
||||||
|
h.applyBuffRefill(ctx, hero, now, payload, sp)
|
||||||
|
|
||||||
|
default:
|
||||||
|
h.logger.Error("successful_payment: unknown payload prefix", "payload", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySubscription activates a weekly subscription for the hero.
|
||||||
|
func (h *PaymentsHandler) applySubscription(ctx context.Context, hero *model.Hero, now time.Time, sp *successfulPayment) {
|
||||||
|
hero.ActivateSubscription(now)
|
||||||
|
|
||||||
|
// Upgrade buff charges to subscriber limits.
|
||||||
|
hero.EnsureBuffChargesPopulated(now)
|
||||||
|
for bt := range model.BuffFreeChargesPerType {
|
||||||
|
state := hero.GetBuffCharges(bt, now)
|
||||||
|
subMax := hero.MaxBuffCharges(bt)
|
||||||
|
if state.Remaining < subMax {
|
||||||
|
state.Remaining = subMax
|
||||||
|
hero.BuffCharges[string(bt)] = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payment := &model.Payment{
|
||||||
|
HeroID: hero.ID,
|
||||||
|
Type: "subscription_weekly",
|
||||||
|
AmountRUB: int(model.SubscriptionWeeklyPrice()),
|
||||||
|
Status: model.PaymentCompleted,
|
||||||
|
CreatedAt: now,
|
||||||
|
CompletedAt: &now,
|
||||||
|
}
|
||||||
|
if err := h.store.CreatePayment(ctx, payment); err != nil {
|
||||||
|
h.logger.Error("successful_payment: create payment record failed", "hero_id", hero.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.Save(ctx, hero); err != nil {
|
||||||
|
h.logger.Error("successful_payment: save hero failed", "hero_id", hero.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPrice()))
|
||||||
|
h.logger.Info("subscription activated via Telegram Payment",
|
||||||
|
"hero_id", hero.ID,
|
||||||
|
"telegram_charge_id", sp.TelegramPaymentChargeID,
|
||||||
|
"expires_at", hero.SubscriptionExpiresAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBuffRefill resets a specific buff's charges after a successful payment.
|
||||||
|
func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero, now time.Time, payload string, sp *successfulPayment) {
|
||||||
|
buffTypeStr, err := parseBuffTypeFromPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("successful_payment: parse buff type failed", "payload", payload, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bt, valid := model.ValidBuffType(buffTypeStr)
|
||||||
|
if !valid {
|
||||||
|
h.logger.Error("successful_payment: invalid buff type in payload", "buff_type", buffTypeStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priceRUB := model.BuffRefillPrice()
|
||||||
|
paymentType := model.PaymentBuffReplenish
|
||||||
|
if bt == model.BuffResurrection {
|
||||||
|
priceRUB = model.ResurrectionRefillPrice()
|
||||||
|
paymentType = model.PaymentResurrectionReplenish
|
||||||
|
}
|
||||||
|
|
||||||
|
hero.ResetBuffCharges(&bt, now)
|
||||||
|
|
||||||
|
payment := &model.Payment{
|
||||||
|
HeroID: hero.ID,
|
||||||
|
Type: paymentType,
|
||||||
|
BuffType: string(bt),
|
||||||
|
AmountRUB: priceRUB,
|
||||||
|
Status: model.PaymentCompleted,
|
||||||
|
CreatedAt: now,
|
||||||
|
CompletedAt: &now,
|
||||||
|
}
|
||||||
|
if err := h.store.CreatePayment(ctx, payment); err != nil {
|
||||||
|
h.logger.Error("successful_payment: create payment record failed", "hero_id", hero.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.Save(ctx, hero); err != nil {
|
||||||
|
h.logger.Error("successful_payment: save hero failed", "hero_id", hero.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s (%d₽)", bt, priceRUB))
|
||||||
|
h.logger.Info("buff refill via Telegram Payment",
|
||||||
|
"hero_id", hero.ID,
|
||||||
|
"buff_type", bt,
|
||||||
|
"price_rub", priceRUB,
|
||||||
|
"telegram_charge_id", sp.TelegramPaymentChargeID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addLog writes an adventure log entry for the hero.
|
||||||
|
func (h *PaymentsHandler) addLog(heroID int64, message string) {
|
||||||
|
if h.logStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.logStore.Add(ctx, heroID, message); err != nil {
|
||||||
|
h.logger.Warn("payments: failed to write adventure log", "hero_id", heroID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SetWebhook ---
|
||||||
|
|
||||||
|
// SetWebhook registers the Telegram webhook URL for payment callbacks.
|
||||||
|
// POST /admin/payments/set-webhook
|
||||||
|
func (h *PaymentsHandler) SetWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provide a non-empty url"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := telegram.SetWebhook(h.botToken, req.URL); err != nil {
|
||||||
|
h.logger.Error("set-webhook failed", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("telegram webhook set", "url", req.URL)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "url": req.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telegram Update types (subset needed for payments) ---
|
||||||
|
|
||||||
|
type telegramUpdate struct {
|
||||||
|
UpdateID int64 `json:"update_id"`
|
||||||
|
PreCheckoutQuery *preCheckoutQuery `json:"pre_checkout_query,omitempty"`
|
||||||
|
Message *telegramMessage `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type preCheckoutQuery struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
From tgUser `json:"from"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
TotalAmount int `json:"total_amount"`
|
||||||
|
InvoicePayload string `json:"invoice_payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramMessage struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
From *tgUser `json:"from,omitempty"`
|
||||||
|
SuccessfulPayment *successfulPayment `json:"successful_payment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type successfulPayment struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
TotalAmount int `json:"total_amount"`
|
||||||
|
InvoicePayload string `json:"invoice_payload"`
|
||||||
|
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
|
||||||
|
ProviderPaymentChargeID string `json:"provider_payment_charge_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tgUser struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IsBot bool `json:"is_bot"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payload parsing helpers ---
|
||||||
|
|
||||||
|
// parseHeroIDFromPayload extracts the hero ID from a payment payload string.
|
||||||
|
// Payload formats:
|
||||||
|
//
|
||||||
|
// "sub_weekly_{heroID}_{timestamp}"
|
||||||
|
// "buff_{buffType}_{heroID}_{timestamp}"
|
||||||
|
func parseHeroIDFromPayload(payload string) (int64, error) {
|
||||||
|
parts := strings.Split(payload, "_")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(payload, "sub_weekly_") && len(parts) >= 4:
|
||||||
|
// sub_weekly_{heroID}_{ts}
|
||||||
|
return strconv.ParseInt(parts[2], 10, 64)
|
||||||
|
|
||||||
|
case strings.HasPrefix(payload, "buff_") && len(parts) >= 4:
|
||||||
|
// buff_{type}_{heroID}_{ts}
|
||||||
|
return strconv.ParseInt(parts[2], 10, 64)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unrecognized payload format: %s", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBuffTypeFromPayload extracts the buff type string from a buff refill payload.
|
||||||
|
// "buff_{type}_{heroID}_{ts}" -> "{type}"
|
||||||
|
func parseBuffTypeFromPayload(payload string) (string, error) {
|
||||||
|
parts := strings.Split(payload, "_")
|
||||||
|
if len(parts) < 4 || parts[0] != "buff" {
|
||||||
|
return "", fmt.Errorf("invalid buff payload format: %s", payload)
|
||||||
|
}
|
||||||
|
return parts[1], nil
|
||||||
|
}
|
||||||
@ -0,0 +1,270 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuffJSON is DB/admin JSON for one buff type (durations in ms).
|
||||||
|
type BuffJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
Magnitude float64 `json:"magnitude"`
|
||||||
|
CooldownMs int64 `json:"cooldownMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebuffJSON is DB/admin JSON for one debuff type (duration in ms).
|
||||||
|
type DebuffJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
Magnitude float64 `json:"magnitude"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type buffDebuffPayload struct {
|
||||||
|
Buffs map[string]BuffJSON `json:"buffs"`
|
||||||
|
Debuffs map[string]DebuffJSON `json:"debuffs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type buffDebuffCatalogData struct {
|
||||||
|
buffs map[BuffType]Buff
|
||||||
|
debuffs map[DebuffType]Debuff
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffDebuffCatalog atomic.Value
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
buffDebuffCatalog.Store(&buffDebuffCatalogData{
|
||||||
|
buffs: seedBuffMap(),
|
||||||
|
debuffs: seedDebuffMap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedBuffMap() map[BuffType]Buff {
|
||||||
|
return map[BuffType]Buff{
|
||||||
|
BuffRush: {
|
||||||
|
Type: BuffRush, Name: "Rush",
|
||||||
|
Duration: 5 * time.Minute, Magnitude: 0.30,
|
||||||
|
CooldownDuration: 15 * time.Minute,
|
||||||
|
},
|
||||||
|
BuffRage: {
|
||||||
|
Type: BuffRage, Name: "Rage",
|
||||||
|
Duration: 3 * time.Minute, Magnitude: 0.50,
|
||||||
|
CooldownDuration: 10 * time.Minute,
|
||||||
|
},
|
||||||
|
BuffShield: {
|
||||||
|
Type: BuffShield, Name: "Shield",
|
||||||
|
Duration: 5 * time.Minute, Magnitude: 0.35,
|
||||||
|
CooldownDuration: 12 * time.Minute,
|
||||||
|
},
|
||||||
|
BuffLuck: {
|
||||||
|
Type: BuffLuck, Name: "Luck",
|
||||||
|
Duration: 30 * time.Minute, Magnitude: 1.0,
|
||||||
|
CooldownDuration: 2 * time.Hour,
|
||||||
|
},
|
||||||
|
BuffResurrection: {
|
||||||
|
Type: BuffResurrection, Name: "Resurrection",
|
||||||
|
Duration: 10 * time.Minute, Magnitude: 0.40,
|
||||||
|
CooldownDuration: 30 * time.Minute,
|
||||||
|
},
|
||||||
|
BuffHeal: {
|
||||||
|
Type: BuffHeal, Name: "Heal",
|
||||||
|
Duration: 1 * time.Second, Magnitude: 0.35,
|
||||||
|
CooldownDuration: 5 * time.Minute,
|
||||||
|
},
|
||||||
|
BuffPowerPotion: {
|
||||||
|
Type: BuffPowerPotion, Name: "Power Potion",
|
||||||
|
Duration: 5 * time.Minute, Magnitude: 0.85,
|
||||||
|
CooldownDuration: 20 * time.Minute,
|
||||||
|
},
|
||||||
|
BuffWarCry: {
|
||||||
|
Type: BuffWarCry, Name: "War Cry",
|
||||||
|
Duration: 3 * time.Minute, Magnitude: 0.50,
|
||||||
|
CooldownDuration: 10 * time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDebuffMap() map[DebuffType]Debuff {
|
||||||
|
return map[DebuffType]Debuff{
|
||||||
|
DebuffPoison: {
|
||||||
|
Type: DebuffPoison, Name: "Poison",
|
||||||
|
Duration: 5 * time.Second, Magnitude: 0.02,
|
||||||
|
},
|
||||||
|
DebuffFreeze: {
|
||||||
|
Type: DebuffFreeze, Name: "Freeze",
|
||||||
|
Duration: 3 * time.Second, Magnitude: 0.50,
|
||||||
|
},
|
||||||
|
DebuffBurn: {
|
||||||
|
Type: DebuffBurn, Name: "Burn",
|
||||||
|
Duration: 4 * time.Second, Magnitude: 0.03,
|
||||||
|
},
|
||||||
|
DebuffStun: {
|
||||||
|
Type: DebuffStun, Name: "Stun",
|
||||||
|
Duration: 2 * time.Second, Magnitude: 1.0,
|
||||||
|
},
|
||||||
|
DebuffSlow: {
|
||||||
|
Type: DebuffSlow, Name: "Slow",
|
||||||
|
Duration: 4 * time.Second, Magnitude: 0.40,
|
||||||
|
},
|
||||||
|
DebuffWeaken: {
|
||||||
|
Type: DebuffWeaken, Name: "Weaken",
|
||||||
|
Duration: 5 * time.Second, Magnitude: 0.30,
|
||||||
|
},
|
||||||
|
DebuffIceSlow: {
|
||||||
|
Type: DebuffIceSlow, Name: "Ice Slow",
|
||||||
|
Duration: 4 * time.Second, Magnitude: 0.20,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buffFromStrictJSON(bt BuffType, j BuffJSON) Buff {
|
||||||
|
return Buff{
|
||||||
|
Type: bt,
|
||||||
|
Name: j.Name,
|
||||||
|
Duration: time.Duration(j.DurationMs) * time.Millisecond,
|
||||||
|
Magnitude: j.Magnitude,
|
||||||
|
CooldownDuration: time.Duration(j.CooldownMs) * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func debuffFromStrictJSON(dt DebuffType, j DebuffJSON) Debuff {
|
||||||
|
return Debuff{
|
||||||
|
Type: dt,
|
||||||
|
Name: j.Name,
|
||||||
|
Duration: time.Duration(j.DurationMs) * time.Millisecond,
|
||||||
|
Magnitude: j.Magnitude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneBuffMap(src map[BuffType]Buff) map[BuffType]Buff {
|
||||||
|
out := make(map[BuffType]Buff, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneDebuffMap(src map[DebuffType]Debuff) map[DebuffType]Debuff {
|
||||||
|
out := make(map[DebuffType]Debuff, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuffDebuffPayloadLoader loads raw JSON from persistence.
|
||||||
|
type BuffDebuffPayloadLoader interface {
|
||||||
|
LoadBuffDebuffConfigPayload(ctx context.Context) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadBuffDebuffCatalog merges DB payload into built-in seeds (same pattern as tuning).
|
||||||
|
func ReloadBuffDebuffCatalog(ctx context.Context, logger *slog.Logger, loader BuffDebuffPayloadLoader) error {
|
||||||
|
payload, err := loader.LoadBuffDebuffConfigPayload(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("buff/debuff config load failed", "error", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffs := cloneBuffMap(seedBuffMap())
|
||||||
|
debuffs := cloneDebuffMap(seedDebuffMap())
|
||||||
|
|
||||||
|
if len(payload) > 0 {
|
||||||
|
var raw buffDebuffPayload
|
||||||
|
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("buff/debuff config parse failed", "error", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Per-key full replace: payload must include all fields for edited types (admin UI sends full effective maps).
|
||||||
|
for key, j := range raw.Buffs {
|
||||||
|
bt := BuffType(key)
|
||||||
|
if _, ok := buffs[bt]; ok {
|
||||||
|
buffs[bt] = buffFromStrictJSON(bt, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, j := range raw.Debuffs {
|
||||||
|
dt := DebuffType(key)
|
||||||
|
if _, ok := debuffs[dt]; ok {
|
||||||
|
debuffs[dt] = debuffFromStrictJSON(dt, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffDebuffCatalog.Store(&buffDebuffCatalogData{buffs: buffs, debuffs: debuffs})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogData() *buffDebuffCatalogData {
|
||||||
|
return buffDebuffCatalog.Load().(*buffDebuffCatalogData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuffDefinition returns the active buff template (DB + defaults).
|
||||||
|
func BuffDefinition(bt BuffType) (Buff, bool) {
|
||||||
|
b, ok := catalogData().buffs[bt]
|
||||||
|
return b, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebuffDefinition returns the active debuff template (DB + defaults).
|
||||||
|
func DebuffDefinition(dt DebuffType) (Debuff, bool) {
|
||||||
|
d, ok := catalogData().debuffs[dt]
|
||||||
|
return d, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuffCatalogSnapshot returns copies for admin/API.
|
||||||
|
func BuffCatalogSnapshot() map[BuffType]Buff {
|
||||||
|
src := catalogData().buffs
|
||||||
|
out := make(map[BuffType]Buff, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebuffCatalogSnapshot returns copies for admin/API.
|
||||||
|
func DebuffCatalogSnapshot() map[DebuffType]Debuff {
|
||||||
|
src := catalogData().debuffs
|
||||||
|
out := make(map[DebuffType]Debuff, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuffToJSON converts Buff to BuffJSON (ms).
|
||||||
|
func BuffToJSON(b Buff) BuffJSON {
|
||||||
|
return BuffJSON{
|
||||||
|
Name: b.Name,
|
||||||
|
DurationMs: b.Duration.Milliseconds(),
|
||||||
|
Magnitude: b.Magnitude,
|
||||||
|
CooldownMs: b.CooldownDuration.Milliseconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebuffToJSON converts Debuff to DebuffJSON (ms).
|
||||||
|
func DebuffToJSON(d Debuff) DebuffJSON {
|
||||||
|
return DebuffJSON{
|
||||||
|
Name: d.Name,
|
||||||
|
DurationMs: d.Duration.Milliseconds(),
|
||||||
|
Magnitude: d.Magnitude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuffCatalogEffectiveJSON builds string-keyed maps for admin/API.
|
||||||
|
func BuffCatalogEffectiveJSON() (map[string]BuffJSON, map[string]DebuffJSON) {
|
||||||
|
buffs := BuffCatalogSnapshot()
|
||||||
|
outB := make(map[string]BuffJSON, len(buffs))
|
||||||
|
for t, b := range buffs {
|
||||||
|
outB[string(t)] = BuffToJSON(b)
|
||||||
|
}
|
||||||
|
debuffs := DebuffCatalogSnapshot()
|
||||||
|
outD := make(map[string]DebuffJSON, len(debuffs))
|
||||||
|
for t, d := range debuffs {
|
||||||
|
outD[string(t)] = DebuffToJSON(d)
|
||||||
|
}
|
||||||
|
return outB, outD
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// TownBuilding represents a persistent structure placed in a town.
|
||||||
|
type TownBuilding struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TownID int64 `json:"townId"`
|
||||||
|
BuildingType string `json:"buildingType"` // house.quest_giver, house.merchant, house.healer, decoration.*
|
||||||
|
OffsetX float64 `json:"offsetX"`
|
||||||
|
OffsetY float64 `json:"offsetY"`
|
||||||
|
Facing string `json:"facing"` // north, south, east, west
|
||||||
|
FootprintW float64 `json:"footprintW"`
|
||||||
|
FootprintH float64 `json:"footprintH"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildingView is the frontend-friendly view with absolute world coordinates.
|
||||||
|
type BuildingView struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
BuildingType string `json:"buildingType"`
|
||||||
|
WorldX float64 `json:"worldX"`
|
||||||
|
WorldY float64 `json:"worldY"`
|
||||||
|
Facing string `json:"facing"`
|
||||||
|
FootprintW float64 `json:"footprintW"`
|
||||||
|
FootprintH float64 `json:"footprintH"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildingTypeForNPC returns the expected building type for a given NPC type.
|
||||||
|
func BuildingTypeForNPC(npcType string) string {
|
||||||
|
return "house." + npcType
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHouseBuilding returns true if the building type is a house (not decoration).
|
||||||
|
func IsHouseBuilding(buildingType string) bool {
|
||||||
|
return len(buildingType) > 6 && buildingType[:6] == "house."
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ShiftHeroEffectDeadlines moves buff/debuff expiry and buff quota windows by d so in-game time
|
||||||
|
// does not advance during a global server pause (wall clock still moves).
|
||||||
|
func ShiftHeroEffectDeadlines(h *Hero, d time.Duration) {
|
||||||
|
if h == nil || d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range h.Buffs {
|
||||||
|
h.Buffs[i].ExpiresAt = h.Buffs[i].ExpiresAt.Add(d)
|
||||||
|
h.Buffs[i].AppliedAt = h.Buffs[i].AppliedAt.Add(d)
|
||||||
|
}
|
||||||
|
for i := range h.Debuffs {
|
||||||
|
h.Debuffs[i].ExpiresAt = h.Debuffs[i].ExpiresAt.Add(d)
|
||||||
|
h.Debuffs[i].AppliedAt = h.Debuffs[i].AppliedAt.Add(d)
|
||||||
|
}
|
||||||
|
if h.BuffQuotaPeriodEnd != nil {
|
||||||
|
t := h.BuffQuotaPeriodEnd.Add(d)
|
||||||
|
h.BuffQuotaPeriodEnd = &t
|
||||||
|
}
|
||||||
|
for k, v := range h.BuffCharges {
|
||||||
|
if v.PeriodEnd != nil {
|
||||||
|
t := v.PeriodEnd.Add(d)
|
||||||
|
v.PeriodEnd = &t
|
||||||
|
}
|
||||||
|
h.BuffCharges[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSetGearCatalog_FillsMissingSlotsFromDefaults(t *testing.T) {
|
||||||
|
originalCatalog := append([]GearFamily(nil), GearCatalog...)
|
||||||
|
originalBySlot := make(map[EquipmentSlot][]GearFamily, len(gearBySlot))
|
||||||
|
for slot, families := range gearBySlot {
|
||||||
|
originalBySlot[slot] = append([]GearFamily(nil), families...)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
GearCatalog = originalCatalog
|
||||||
|
gearBySlot = originalBySlot
|
||||||
|
}()
|
||||||
|
|
||||||
|
dbOnlyMainHand := []GearFamily{
|
||||||
|
{
|
||||||
|
Slot: SlotMainHand,
|
||||||
|
FormID: "gear.form.main_hand.test",
|
||||||
|
Name: "Test Blade",
|
||||||
|
BasePrimary: 10,
|
||||||
|
StatType: "attack",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
SetGearCatalog(dbOnlyMainHand)
|
||||||
|
|
||||||
|
if got := len(gearBySlot[SlotMainHand]); got != 1 {
|
||||||
|
t.Fatalf("expected main hand to keep only db families, got %d", got)
|
||||||
|
}
|
||||||
|
if gearBySlot[SlotMainHand][0].Name != "Test Blade" {
|
||||||
|
t.Fatalf("expected db main hand family to be used, got %q", gearBySlot[SlotMainHand][0].Name)
|
||||||
|
}
|
||||||
|
if got := len(gearBySlot[SlotHead]); got == 0 {
|
||||||
|
t.Fatalf("expected missing slot to be filled from defaults, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TownPausePersisted mirrors HeroMovement fields needed to resume resting or an in-town NPC tour
|
||||||
|
// after reconnect or during offline catch-up.
|
||||||
|
// Stored in heroes.town_pause (JSONB).
|
||||||
|
type TownPausePersisted struct {
|
||||||
|
RestUntil *time.Time `json:"restUntil,omitempty"`
|
||||||
|
RestKind string `json:"restKind,omitempty"`
|
||||||
|
TownRestHealRemainder float64 `json:"townRestHealRemainder,omitempty"`
|
||||||
|
|
||||||
|
NPCQueue []int64 `json:"npcQueue,omitempty"`
|
||||||
|
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
|
||||||
|
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
|
||||||
|
TownVisitNPCName string `json:"townVisitNPCName,omitempty"`
|
||||||
|
TownVisitNPCType string `json:"townVisitNPCType,omitempty"`
|
||||||
|
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
|
||||||
|
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuffDebuffConfigStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBuffDebuffConfigStore(pool *pgxpool.Pool) *BuffDebuffConfigStore {
|
||||||
|
return &BuffDebuffConfigStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BuffDebuffConfigStore) LoadBuffDebuffConfigPayload(ctx context.Context) ([]byte, error) {
|
||||||
|
var payload []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT payload FROM buff_debuff_config WHERE id = TRUE`).Scan(&payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load buff/debuff config payload: %w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BuffDebuffConfigStore) SaveBuffDebuffConfigPayload(ctx context.Context, payload []byte) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE buff_debuff_config
|
||||||
|
SET payload = $1::jsonb, updated_at = now()
|
||||||
|
WHERE id = TRUE
|
||||||
|
`, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save buff/debuff config payload: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContentStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContentStore(pool *pgxpool.Pool) *ContentStore {
|
||||||
|
return &ContentStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyType]model.Enemy, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT type, name, hp, max_hp, attack, defense, speed, crit_chance,
|
||||||
|
min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite
|
||||||
|
FROM enemies
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load enemies from db: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make(map[model.EnemyType]model.Enemy)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
t string
|
||||||
|
e model.Enemy
|
||||||
|
specialAbilities []string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance,
|
||||||
|
&e.MinLevel, &e.MaxLevel, &e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan enemy row: %w", err)
|
||||||
|
}
|
||||||
|
e.Type = model.EnemyType(t)
|
||||||
|
e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities))
|
||||||
|
for _, a := range specialAbilities {
|
||||||
|
e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a))
|
||||||
|
}
|
||||||
|
out[e.Type] = e
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("enemy rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEquipmentSlot(raw string) model.EquipmentSlot {
|
||||||
|
v := strings.TrimSpace(strings.ToLower(raw))
|
||||||
|
v = strings.TrimPrefix(v, "gear.slot.")
|
||||||
|
switch v {
|
||||||
|
case "weapon", "mainhand", "main_hand":
|
||||||
|
return model.SlotMainHand
|
||||||
|
case "armor", "chest":
|
||||||
|
return model.SlotChest
|
||||||
|
case "head":
|
||||||
|
return model.SlotHead
|
||||||
|
case "feet":
|
||||||
|
return model.SlotFeet
|
||||||
|
case "neck":
|
||||||
|
return model.SlotNeck
|
||||||
|
case "hands":
|
||||||
|
return model.SlotHands
|
||||||
|
case "legs":
|
||||||
|
return model.SlotLegs
|
||||||
|
case "cloak":
|
||||||
|
return model.SlotCloak
|
||||||
|
case "finger", "ring":
|
||||||
|
return model.SlotFinger
|
||||||
|
case "wrist":
|
||||||
|
return model.SlotWrist
|
||||||
|
default:
|
||||||
|
return model.EquipmentSlot(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily, error) {
|
||||||
|
out := make([]model.GearFamily, 0, 128)
|
||||||
|
|
||||||
|
weaponRows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT name, type, damage, speed, crit_chance, special_effect
|
||||||
|
FROM weapons
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load weapons from db: %w", err)
|
||||||
|
}
|
||||||
|
for weaponRows.Next() {
|
||||||
|
var name, typ, special string
|
||||||
|
var damage int
|
||||||
|
var speed, crit float64
|
||||||
|
if err := weaponRows.Scan(&name, &typ, &damage, &speed, &crit, &special); err != nil {
|
||||||
|
weaponRows.Close()
|
||||||
|
return nil, fmt.Errorf("scan weapon row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, model.GearFamily{
|
||||||
|
Slot: model.SlotMainHand,
|
||||||
|
FormID: "gear.form.main_hand." + typ,
|
||||||
|
Name: name,
|
||||||
|
Subtype: typ,
|
||||||
|
BasePrimary: damage,
|
||||||
|
StatType: "attack",
|
||||||
|
SpeedModifier: speed,
|
||||||
|
BaseCrit: crit,
|
||||||
|
SpecialEffect: special,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
weaponRows.Close()
|
||||||
|
|
||||||
|
armorRows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT name, type, defense, speed_modifier, agility_bonus, set_name, special_effect
|
||||||
|
FROM armor
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load armor from db: %w", err)
|
||||||
|
}
|
||||||
|
for armorRows.Next() {
|
||||||
|
var name, typ, setName, special string
|
||||||
|
var defense, agi int
|
||||||
|
var speed float64
|
||||||
|
if err := armorRows.Scan(&name, &typ, &defense, &speed, &agi, &setName, &special); err != nil {
|
||||||
|
armorRows.Close()
|
||||||
|
return nil, fmt.Errorf("scan armor row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, model.GearFamily{
|
||||||
|
Slot: model.SlotChest,
|
||||||
|
FormID: "gear.form.chest." + typ,
|
||||||
|
Name: name,
|
||||||
|
Subtype: typ,
|
||||||
|
BasePrimary: defense,
|
||||||
|
StatType: "defense",
|
||||||
|
SpeedModifier: speed,
|
||||||
|
AgilityBonus: agi,
|
||||||
|
SetName: setName,
|
||||||
|
SpecialEffect: special,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
armorRows.Close()
|
||||||
|
|
||||||
|
eqRows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT slot, form_id, name, base_primary, stat_type
|
||||||
|
FROM equipment_items
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load equipment_items from db: %w", err)
|
||||||
|
}
|
||||||
|
for eqRows.Next() {
|
||||||
|
var slot, formID, name, statType string
|
||||||
|
var basePrimary int
|
||||||
|
if err := eqRows.Scan(&slot, &formID, &name, &basePrimary, &statType); err != nil {
|
||||||
|
eqRows.Close()
|
||||||
|
return nil, fmt.Errorf("scan equipment_item row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, model.GearFamily{
|
||||||
|
Slot: normalizeEquipmentSlot(slot),
|
||||||
|
FormID: formID,
|
||||||
|
Name: name,
|
||||||
|
BasePrimary: basePrimary,
|
||||||
|
StatType: statType,
|
||||||
|
SpeedModifier: 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
eqRows.Close()
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuntimeConfigStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRuntimeConfigStore(pool *pgxpool.Pool) *RuntimeConfigStore {
|
||||||
|
return &RuntimeConfigStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RuntimeConfigStore) LoadRuntimeConfigPayload(ctx context.Context) ([]byte, error) {
|
||||||
|
var payload []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT payload FROM runtime_config WHERE id = TRUE`).Scan(&payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load runtime config payload: %w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RuntimeConfigStore) SaveRuntimeConfigPayload(ctx context.Context, payload []byte) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE runtime_config
|
||||||
|
SET payload = $1::jsonb, updated_at = now()
|
||||||
|
WHERE id = TRUE
|
||||||
|
`, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save runtime config payload: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpClient is a shared client with sensible timeouts for Telegram Bot API calls.
|
||||||
|
var httpClient = &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiResponse is the generic envelope returned by every Bot API method.
|
||||||
|
type apiResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
ErrorCode int `json:"error_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallBotAPI sends a JSON request to the Telegram Bot API and returns the result field.
|
||||||
|
func CallBotAPI(botToken, method string, payload any) (json.RawMessage, error) {
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", botToken, method)
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram: POST %s: %w", method, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram: read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp apiResponse
|
||||||
|
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram: unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apiResp.OK {
|
||||||
|
return nil, fmt.Errorf("telegram: %s failed (%d): %s", method, apiResp.ErrorCode, apiResp.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiResp.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LabeledAmount represents one price component in a Telegram invoice.
|
||||||
|
type LabeledAmount struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Amount int `json:"amount"` // smallest currency unit (kopecks for RUB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceLinkParams holds the parameters for createInvoiceLink.
|
||||||
|
type InvoiceLinkParams struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
ProviderToken string `json:"provider_token"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Prices []LabeledAmount `json:"prices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvoiceLink calls the Telegram Bot API createInvoiceLink method
|
||||||
|
// and returns the HTTPS invoice URL the client can pass to openInvoice().
|
||||||
|
func CreateInvoiceLink(botToken string, params InvoiceLinkParams) (string, error) {
|
||||||
|
raw, err := CallBotAPI(botToken, "createInvoiceLink", params)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var link string
|
||||||
|
if err := json.Unmarshal(raw, &link); err != nil {
|
||||||
|
return "", fmt.Errorf("telegram: unmarshal invoice link: %w", err)
|
||||||
|
}
|
||||||
|
return link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerPreCheckoutQuery responds to a Telegram pre_checkout_query.
|
||||||
|
// ok=true approves; ok=false + errorMsg declines.
|
||||||
|
func AnswerPreCheckoutQuery(botToken, queryID string, ok bool, errorMsg string) error {
|
||||||
|
payload := map[string]any{
|
||||||
|
"pre_checkout_query_id": queryID,
|
||||||
|
"ok": ok,
|
||||||
|
}
|
||||||
|
if !ok && errorMsg != "" {
|
||||||
|
payload["error_message"] = errorMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := CallBotAPI(botToken, "answerPreCheckoutQuery", payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWebhook configures the Telegram webhook URL for payment callbacks.
|
||||||
|
func SetWebhook(botToken, webhookURL string) error {
|
||||||
|
payload := map[string]string{
|
||||||
|
"url": webhookURL,
|
||||||
|
}
|
||||||
|
_, err := CallBotAPI(botToken, "setWebhook", payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -0,0 +1,322 @@
|
|||||||
|
package tuning
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Values contains runtime-tunable gameplay knobs loaded from DB.
|
||||||
|
// Missing JSON fields keep default values.
|
||||||
|
type Values struct {
|
||||||
|
EncounterCooldownBaseMs int64 `json:"encounterCooldownBaseMs"`
|
||||||
|
EncounterActivityBase float64 `json:"encounterActivityBase"`
|
||||||
|
|
||||||
|
BaseMoveSpeed float64 `json:"baseMoveSpeed"`
|
||||||
|
MovementTickRateMs int64 `json:"movementTickRateMs"`
|
||||||
|
PositionSyncRateMs int64 `json:"positionSyncRateMs"`
|
||||||
|
|
||||||
|
TownRestMinMs int64 `json:"townRestMinMs"`
|
||||||
|
TownRestMaxMs int64 `json:"townRestMaxMs"`
|
||||||
|
TownRestHPPerS float64 `json:"townRestHpPerSecond"`
|
||||||
|
TownArrivalRadius float64 `json:"townArrivalRadius"`
|
||||||
|
TownNPCVisitChance float64 `json:"townNpcVisitChance"`
|
||||||
|
TownNPCRollMinMs int64 `json:"townNpcRollMinMs"`
|
||||||
|
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
|
||||||
|
TownNPCRetryMs int64 `json:"townNpcRetryMs"`
|
||||||
|
TownNPCPauseMs int64 `json:"townNpcPauseMs"`
|
||||||
|
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
|
||||||
|
|
||||||
|
WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"`
|
||||||
|
MerchantCostBase int64 `json:"merchantCostBase"`
|
||||||
|
MerchantCostPerLevel int64 `json:"merchantCostPerLevel"`
|
||||||
|
MerchantTownAutoSellShare float64 `json:"merchantTownAutoSellShare"`
|
||||||
|
MonsterEncounterWeightBase float64 `json:"monsterEncounterWeightBase"`
|
||||||
|
MonsterEncounterWeightWildBonus float64 `json:"monsterEncounterWeightWildBonus"`
|
||||||
|
MerchantEncounterWeightBase float64 `json:"merchantEncounterWeightBase"`
|
||||||
|
MerchantEncounterWeightRoadBonus float64 `json:"merchantEncounterWeightRoadBonus"`
|
||||||
|
|
||||||
|
LootChanceCommon float64 `json:"lootChanceCommon"`
|
||||||
|
LootChanceUncommon float64 `json:"lootChanceUncommon"`
|
||||||
|
LootChanceRare float64 `json:"lootChanceRare"`
|
||||||
|
LootChanceEpic float64 `json:"lootChanceEpic"`
|
||||||
|
LootChanceLegendary float64 `json:"lootChanceLegendary"`
|
||||||
|
GoldLootScale float64 `json:"goldLootScale"`
|
||||||
|
PotionDropChance float64 `json:"potionDropChance"`
|
||||||
|
EquipmentDropBase float64 `json:"equipmentDropBase"`
|
||||||
|
|
||||||
|
GoldCommonMin int64 `json:"goldCommonMin"`
|
||||||
|
GoldCommonMax int64 `json:"goldCommonMax"`
|
||||||
|
GoldUncommonMin int64 `json:"goldUncommonMin"`
|
||||||
|
GoldUncommonMax int64 `json:"goldUncommonMax"`
|
||||||
|
GoldRareMin int64 `json:"goldRareMin"`
|
||||||
|
GoldRareMax int64 `json:"goldRareMax"`
|
||||||
|
GoldEpicMin int64 `json:"goldEpicMin"`
|
||||||
|
GoldEpicMax int64 `json:"goldEpicMax"`
|
||||||
|
GoldLegendaryMin int64 `json:"goldLegendaryMin"`
|
||||||
|
GoldLegendaryMax int64 `json:"goldLegendaryMax"`
|
||||||
|
|
||||||
|
AutoSellCommon int64 `json:"autoSellCommon"`
|
||||||
|
AutoSellUncommon int64 `json:"autoSellUncommon"`
|
||||||
|
AutoSellRare int64 `json:"autoSellRare"`
|
||||||
|
AutoSellEpic int64 `json:"autoSellEpic"`
|
||||||
|
AutoSellLegendary int64 `json:"autoSellLegendary"`
|
||||||
|
|
||||||
|
RESTEncounterCooldownMs int64 `json:"restEncounterCooldownMs"`
|
||||||
|
RESTEncounterNPCChance float64 `json:"restEncounterNpcChance"`
|
||||||
|
NPCCostHeal int64 `json:"npcCostHeal"`
|
||||||
|
NPCCostPotion int64 `json:"npcCostPotion"`
|
||||||
|
NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"`
|
||||||
|
|
||||||
|
CombatDamageScale float64 `json:"combatDamageScale"`
|
||||||
|
EnemyDodgeChance float64 `json:"enemyDodgeChance"`
|
||||||
|
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
|
||||||
|
EnemyBurstEveryN int64 `json:"enemyBurstEveryN"`
|
||||||
|
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
|
||||||
|
EnemyChainEveryN int64 `json:"enemyChainEveryN"`
|
||||||
|
EnemyChainMultiplier float64 `json:"enemyChainMultiplier"`
|
||||||
|
|
||||||
|
DebuffProcBurn float64 `json:"debuffProcBurn"`
|
||||||
|
DebuffProcPoison float64 `json:"debuffProcPoison"`
|
||||||
|
DebuffProcSlow float64 `json:"debuffProcSlow"`
|
||||||
|
DebuffProcStun float64 `json:"debuffProcStun"`
|
||||||
|
DebuffProcFreeze float64 `json:"debuffProcFreeze"`
|
||||||
|
DebuffProcIceSlow float64 `json:"debuffProcIceSlow"`
|
||||||
|
|
||||||
|
EnemyRegenDefault float64 `json:"enemyRegenDefault"`
|
||||||
|
EnemyRegenSkeletonKing float64 `json:"enemyRegenSkeletonKing"`
|
||||||
|
EnemyRegenForestWarden float64 `json:"enemyRegenForestWarden"`
|
||||||
|
EnemyRegenBattleLizard float64 `json:"enemyRegenBattleLizard"`
|
||||||
|
SummonCycleSeconds int64 `json:"summonCycleSeconds"`
|
||||||
|
SummonDamageDivisor int64 `json:"summonDamageDivisor"`
|
||||||
|
LuckBuffMultiplier float64 `json:"luckBuffMultiplier"`
|
||||||
|
|
||||||
|
MinAttackIntervalMs int64 `json:"minAttackIntervalMs"`
|
||||||
|
CombatPaceMultiplier int64 `json:"combatPaceMultiplier"`
|
||||||
|
|
||||||
|
PotionHealPercent float64 `json:"potionHealPercent"`
|
||||||
|
PotionAutoUseThreshold float64 `json:"potionAutoUseThreshold"`
|
||||||
|
ReviveHpPercent float64 `json:"reviveHpPercent"`
|
||||||
|
AutoReviveAfterMs int64 `json:"autoReviveAfterMs"`
|
||||||
|
|
||||||
|
XPCurveEarlyBase float64 `json:"xpCurveEarlyBase"`
|
||||||
|
XPCurveEarlyScale float64 `json:"xpCurveEarlyScale"`
|
||||||
|
XPCurveMidBase float64 `json:"xpCurveMidBase"`
|
||||||
|
XPCurveMidScale float64 `json:"xpCurveMidScale"`
|
||||||
|
XPCurveLateBase float64 `json:"xpCurveLateBase"`
|
||||||
|
XPCurveLateScale float64 `json:"xpCurveLateScale"`
|
||||||
|
|
||||||
|
LevelUpHPEvery int64 `json:"levelUpHpEvery"`
|
||||||
|
LevelUpATKEvery int64 `json:"levelUpAtkEvery"`
|
||||||
|
LevelUpDEFEvery int64 `json:"levelUpDefEvery"`
|
||||||
|
LevelUpSTREvery int64 `json:"levelUpStrEvery"`
|
||||||
|
LevelUpCONEvery int64 `json:"levelUpConEvery"`
|
||||||
|
LevelUpAGIEvery int64 `json:"levelUpAgiEvery"`
|
||||||
|
LevelUpLUCKEvery int64 `json:"levelUpLuckEvery"`
|
||||||
|
|
||||||
|
AgilityCoef float64 `json:"agilityCoef"`
|
||||||
|
MaxAttackSpeed float64 `json:"maxAttackSpeed"`
|
||||||
|
MinAttackSpeed float64 `json:"minAttackSpeed"`
|
||||||
|
|
||||||
|
IlvlFactorSlope float64 `json:"ilvlFactorSlope"`
|
||||||
|
RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"`
|
||||||
|
RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"`
|
||||||
|
RarityMultiplierRare float64 `json:"rarityMultiplierRare"`
|
||||||
|
RarityMultiplierEpic float64 `json:"rarityMultiplierEpic"`
|
||||||
|
RarityMultiplierLegendary float64 `json:"rarityMultiplierLegendary"`
|
||||||
|
RollIlvlEliteBaseChance float64 `json:"rollIlvlEliteBaseChance"`
|
||||||
|
RollIlvlElitePlusOneChance float64 `json:"rollIlvlElitePlusOneChance"`
|
||||||
|
|
||||||
|
BuffChargePeriodMs int64 `json:"buffChargePeriodMs"`
|
||||||
|
FreeBuffActivationsPerPeriod int64 `json:"freeBuffActivationsPerPeriod"`
|
||||||
|
SubscriptionDurationMs int64 `json:"subscriptionDurationMs"`
|
||||||
|
SubscriptionWeeklyPriceRUB int64 `json:"subscriptionWeeklyPriceRub"`
|
||||||
|
BuffRefillPriceRUB int64 `json:"buffRefillPriceRub"`
|
||||||
|
ResurrectionRefillPriceRUB int64 `json:"resurrectionRefillPriceRub"`
|
||||||
|
MaxRevivesFree int64 `json:"maxRevivesFree"`
|
||||||
|
MaxRevivesSubscriber int64 `json:"maxRevivesSubscriber"`
|
||||||
|
|
||||||
|
EnemyScaleBandHP float64 `json:"enemyScaleBandHp"`
|
||||||
|
EnemyScaleOvercapHP float64 `json:"enemyScaleOvercapHp"`
|
||||||
|
EnemyScaleBandATK float64 `json:"enemyScaleBandAtk"`
|
||||||
|
EnemyScaleOvercapATK float64 `json:"enemyScaleOvercapAtk"`
|
||||||
|
EnemyScaleBandDEF float64 `json:"enemyScaleBandDef"`
|
||||||
|
EnemyScaleOvercapDEF float64 `json:"enemyScaleOvercapDef"`
|
||||||
|
EnemyScaleBandXP float64 `json:"enemyScaleBandXp"`
|
||||||
|
EnemyScaleOvercapXP float64 `json:"enemyScaleOvercapXp"`
|
||||||
|
EnemyScaleBandGold float64 `json:"enemyScaleBandGold"`
|
||||||
|
EnemyScaleOvercapGold float64 `json:"enemyScaleOvercapGold"`
|
||||||
|
|
||||||
|
AutoEquipThreshold float64 `json:"autoEquipThreshold"`
|
||||||
|
LootHistoryLimit int64 `json:"lootHistoryLimit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultValues() Values {
|
||||||
|
return Values{
|
||||||
|
EncounterCooldownBaseMs: 12_000,
|
||||||
|
EncounterActivityBase: 0.035,
|
||||||
|
BaseMoveSpeed: 2.0,
|
||||||
|
MovementTickRateMs: 500,
|
||||||
|
PositionSyncRateMs: 10_000,
|
||||||
|
TownRestMinMs: 5 * 60 * 1000,
|
||||||
|
TownRestMaxMs: 20 * 60 * 1000,
|
||||||
|
TownRestHPPerS: 0.002,
|
||||||
|
TownArrivalRadius: 0.5,
|
||||||
|
TownNPCVisitChance: 0.78,
|
||||||
|
TownNPCRollMinMs: 800,
|
||||||
|
TownNPCRollMaxMs: 2600,
|
||||||
|
TownNPCRetryMs: 450,
|
||||||
|
TownNPCPauseMs: 30_000,
|
||||||
|
TownNPCLogIntervalMs: 5_000,
|
||||||
|
WanderingMerchantPromptTimeoutMs: 15_000,
|
||||||
|
MerchantCostBase: 20,
|
||||||
|
MerchantCostPerLevel: 5,
|
||||||
|
MerchantTownAutoSellShare: 0.30,
|
||||||
|
MonsterEncounterWeightBase: 0.62,
|
||||||
|
MonsterEncounterWeightWildBonus: 0.18,
|
||||||
|
MerchantEncounterWeightBase: 0.04,
|
||||||
|
MerchantEncounterWeightRoadBonus: 0.10,
|
||||||
|
LootChanceCommon: 0.40,
|
||||||
|
LootChanceUncommon: 0.10,
|
||||||
|
LootChanceRare: 0.02,
|
||||||
|
LootChanceEpic: 0.003,
|
||||||
|
LootChanceLegendary: 0.0005,
|
||||||
|
GoldLootScale: 0.5,
|
||||||
|
PotionDropChance: 0.05,
|
||||||
|
EquipmentDropBase: 0.15,
|
||||||
|
GoldCommonMin: 0,
|
||||||
|
GoldCommonMax: 5,
|
||||||
|
GoldUncommonMin: 6,
|
||||||
|
GoldUncommonMax: 20,
|
||||||
|
GoldRareMin: 21,
|
||||||
|
GoldRareMax: 50,
|
||||||
|
GoldEpicMin: 51,
|
||||||
|
GoldEpicMax: 120,
|
||||||
|
GoldLegendaryMin: 121,
|
||||||
|
GoldLegendaryMax: 300,
|
||||||
|
AutoSellCommon: 3,
|
||||||
|
AutoSellUncommon: 8,
|
||||||
|
AutoSellRare: 20,
|
||||||
|
AutoSellEpic: 60,
|
||||||
|
AutoSellLegendary: 180,
|
||||||
|
RESTEncounterCooldownMs: 16_000,
|
||||||
|
RESTEncounterNPCChance: 0.10,
|
||||||
|
NPCCostHeal: 100,
|
||||||
|
NPCCostPotion: 50,
|
||||||
|
NPCCostNearbyRadius: 3.0,
|
||||||
|
CombatDamageScale: 0.35,
|
||||||
|
EnemyDodgeChance: 0.20,
|
||||||
|
EnemyCriticalMinChance: 0.15,
|
||||||
|
EnemyBurstEveryN: 3,
|
||||||
|
EnemyBurstMultiplier: 1.5,
|
||||||
|
EnemyChainEveryN: 6,
|
||||||
|
EnemyChainMultiplier: 3.0,
|
||||||
|
DebuffProcBurn: 0.30,
|
||||||
|
DebuffProcPoison: 0.10,
|
||||||
|
DebuffProcSlow: 0.25,
|
||||||
|
DebuffProcStun: 0.25,
|
||||||
|
DebuffProcFreeze: 0.20,
|
||||||
|
DebuffProcIceSlow: 0.20,
|
||||||
|
EnemyRegenDefault: 0.02,
|
||||||
|
EnemyRegenSkeletonKing: 0.10,
|
||||||
|
EnemyRegenForestWarden: 0.05,
|
||||||
|
EnemyRegenBattleLizard: 0.02,
|
||||||
|
SummonCycleSeconds: 15,
|
||||||
|
SummonDamageDivisor: 4,
|
||||||
|
LuckBuffMultiplier: 1.75,
|
||||||
|
MinAttackIntervalMs: 250,
|
||||||
|
CombatPaceMultiplier: 5,
|
||||||
|
PotionHealPercent: 0.30,
|
||||||
|
PotionAutoUseThreshold: 0.30,
|
||||||
|
ReviveHpPercent: 0.50,
|
||||||
|
AutoReviveAfterMs: int64(time.Hour / time.Millisecond),
|
||||||
|
XPCurveEarlyBase: 180,
|
||||||
|
XPCurveEarlyScale: 1.28,
|
||||||
|
XPCurveMidBase: 1450,
|
||||||
|
XPCurveMidScale: 1.15,
|
||||||
|
XPCurveLateBase: 23000,
|
||||||
|
XPCurveLateScale: 1.10,
|
||||||
|
LevelUpHPEvery: 10,
|
||||||
|
LevelUpATKEvery: 30,
|
||||||
|
LevelUpDEFEvery: 30,
|
||||||
|
LevelUpSTREvery: 40,
|
||||||
|
LevelUpCONEvery: 50,
|
||||||
|
LevelUpAGIEvery: 60,
|
||||||
|
LevelUpLUCKEvery: 100,
|
||||||
|
AgilityCoef: 0.03,
|
||||||
|
MaxAttackSpeed: 4.0,
|
||||||
|
MinAttackSpeed: 0.1,
|
||||||
|
IlvlFactorSlope: 0.03,
|
||||||
|
RarityMultiplierCommon: 1.00,
|
||||||
|
RarityMultiplierUncommon: 1.12,
|
||||||
|
RarityMultiplierRare: 1.30,
|
||||||
|
RarityMultiplierEpic: 1.52,
|
||||||
|
RarityMultiplierLegendary: 1.78,
|
||||||
|
RollIlvlEliteBaseChance: 0.4,
|
||||||
|
RollIlvlElitePlusOneChance: 0.4,
|
||||||
|
BuffChargePeriodMs: 24 * 60 * 60 * 1000,
|
||||||
|
FreeBuffActivationsPerPeriod: 2,
|
||||||
|
SubscriptionDurationMs: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
SubscriptionWeeklyPriceRUB: 299,
|
||||||
|
BuffRefillPriceRUB: 50,
|
||||||
|
ResurrectionRefillPriceRUB: 150,
|
||||||
|
MaxRevivesFree: 1,
|
||||||
|
MaxRevivesSubscriber: 2,
|
||||||
|
EnemyScaleBandHP: 0.05,
|
||||||
|
EnemyScaleOvercapHP: 0.025,
|
||||||
|
EnemyScaleBandATK: 0.035,
|
||||||
|
EnemyScaleOvercapATK: 0.018,
|
||||||
|
EnemyScaleBandDEF: 0.035,
|
||||||
|
EnemyScaleOvercapDEF: 0.018,
|
||||||
|
EnemyScaleBandXP: 0.05,
|
||||||
|
EnemyScaleOvercapXP: 0.03,
|
||||||
|
EnemyScaleBandGold: 0.05,
|
||||||
|
EnemyScaleOvercapGold: 0.025,
|
||||||
|
AutoEquipThreshold: 1.03,
|
||||||
|
LootHistoryLimit: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var current atomic.Value
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
v := DefaultValues()
|
||||||
|
current.Store(&v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Get() Values {
|
||||||
|
p := current.Load().(*Values)
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func Set(v Values) {
|
||||||
|
current.Store(&v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PayloadLoader interface {
|
||||||
|
LoadRuntimeConfigPayload(ctx context.Context) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) error {
|
||||||
|
payload, err := loader.LoadRuntimeConfigPayload(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("runtime config reload failed", "error", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next := DefaultValues()
|
||||||
|
if len(payload) > 0 {
|
||||||
|
if err := json.Unmarshal(payload, &next); err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("runtime config payload parse failed", "error", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Set(next)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- Subscription system: weekly subscription with expiry date.
|
||||||
|
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS subscription_expires_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Payment type for subscription purchases.
|
||||||
|
-- Existing payments table is reused with type = 'subscription_weekly'.
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- Backpack: unequipped gear (max 40 slots per hero).
|
||||||
|
CREATE TABLE IF NOT EXISTS hero_inventory (
|
||||||
|
hero_id BIGINT NOT NULL REFERENCES heroes(id) ON DELETE CASCADE,
|
||||||
|
slot_index SMALLINT NOT NULL CHECK (slot_index >= 0 AND slot_index < 40),
|
||||||
|
gear_id BIGINT NOT NULL REFERENCES gear(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (hero_id, slot_index),
|
||||||
|
UNIQUE (gear_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hero_inventory_hero ON hero_inventory(hero_id);
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- Align heroes.state CHECK with model.GameState (resting / in_town used by town arrival & admin teleport).
|
||||||
|
ALTER TABLE heroes DROP CONSTRAINT IF EXISTS heroes_state_check;
|
||||||
|
ALTER TABLE heroes ADD CONSTRAINT heroes_state_check CHECK (
|
||||||
|
state IN ('walking', 'fighting', 'dead', 'resting', 'in_town')
|
||||||
|
);
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- Persist movement timers / in-town NPC tour state so offline simulation can advance resting & town visits.
|
||||||
|
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS town_pause JSONB NULL;
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS runtime_config (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT TRUE,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT runtime_config_single_row CHECK (id = TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO runtime_config (id, payload)
|
||||||
|
VALUES (TRUE, '{}'::jsonb)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS buff_debuff_config (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT TRUE,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT buff_debuff_config_single_row CHECK (id = TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO buff_debuff_config (id, payload)
|
||||||
|
VALUES (TRUE, '{}'::jsonb)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
-- Migration 000026: Town buildings — server-driven layout for towns.
|
||||||
|
-- Each NPC gets an assigned building; buildings have typed appearances per NPC role.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Town buildings: persistent structures placed in towns.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS town_buildings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
town_id BIGINT NOT NULL REFERENCES towns(id) ON DELETE CASCADE,
|
||||||
|
building_type TEXT NOT NULL CHECK (building_type IN (
|
||||||
|
'house.quest_giver', 'house.merchant', 'house.healer',
|
||||||
|
'decoration.well', 'decoration.stall', 'decoration.signpost'
|
||||||
|
)),
|
||||||
|
offset_x DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
offset_y DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
facing TEXT NOT NULL DEFAULT 'south' CHECK (facing IN ('north','south','east','west')),
|
||||||
|
footprint_w DOUBLE PRECISION NOT NULL DEFAULT 2.0,
|
||||||
|
footprint_h DOUBLE PRECISION NOT NULL DEFAULT 2.0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_town_buildings_town ON town_buildings(town_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Link NPCs to their buildings (nullable for migration transition).
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE npcs ADD COLUMN IF NOT EXISTS building_id BIGINT REFERENCES town_buildings(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Seed buildings for all existing towns, then link NPCs.
|
||||||
|
-- Layout strategy per town:
|
||||||
|
-- - NPC buildings are placed in a semicircle around the town center
|
||||||
|
-- - quest_giver at ~10 o'clock, merchant at ~2 o'clock, healer at ~6 o'clock
|
||||||
|
-- - A well decoration at center, signpost near entrance
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Helper: create buildings for each town with NPCs, using deterministic offsets by NPC type.
|
||||||
|
-- quest_giver houses: upper-left zone
|
||||||
|
-- merchant houses: upper-right zone
|
||||||
|
-- healer houses: lower-center zone
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
t RECORD;
|
||||||
|
n RECORD;
|
||||||
|
new_building_id BIGINT;
|
||||||
|
btype TEXT;
|
||||||
|
ox DOUBLE PRECISION;
|
||||||
|
oy DOUBLE PRECISION;
|
||||||
|
npc_idx INTEGER;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN SELECT id, radius FROM towns ORDER BY id LOOP
|
||||||
|
npc_idx := 0;
|
||||||
|
FOR n IN SELECT id, type FROM npcs WHERE town_id = t.id ORDER BY id LOOP
|
||||||
|
-- Determine building type from NPC type
|
||||||
|
btype := 'house.' || n.type;
|
||||||
|
|
||||||
|
-- Spread NPCs in a semicircle; scale offset by town radius
|
||||||
|
-- Each NPC gets a distinct angular position
|
||||||
|
CASE n.type
|
||||||
|
WHEN 'quest_giver' THEN
|
||||||
|
ox := -0.45 * t.radius;
|
||||||
|
oy := -0.25 * t.radius;
|
||||||
|
WHEN 'merchant' THEN
|
||||||
|
ox := 0.45 * t.radius;
|
||||||
|
oy := -0.25 * t.radius;
|
||||||
|
WHEN 'healer' THEN
|
||||||
|
ox := 0.0;
|
||||||
|
oy := 0.45 * t.radius;
|
||||||
|
ELSE
|
||||||
|
ox := npc_idx * 2.0;
|
||||||
|
oy := 0.0;
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
-- Stagger if multiple NPCs of same type (add small offset per index)
|
||||||
|
ox := ox + (npc_idx % 3) * 1.5;
|
||||||
|
|
||||||
|
INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h)
|
||||||
|
VALUES (t.id, btype, ox, oy, 'south', 2.5, 2.0)
|
||||||
|
RETURNING id INTO new_building_id;
|
||||||
|
|
||||||
|
-- Link NPC to their building
|
||||||
|
UPDATE npcs SET building_id = new_building_id WHERE id = n.id;
|
||||||
|
|
||||||
|
-- Move NPC offset to be at the building entrance (slightly in front)
|
||||||
|
UPDATE npcs SET offset_x = ox, offset_y = oy + 1.2 WHERE id = n.id;
|
||||||
|
|
||||||
|
npc_idx := npc_idx + 1;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Add a well decoration at town center
|
||||||
|
INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h)
|
||||||
|
VALUES (t.id, 'decoration.well', 0, 0, 'south', 1.5, 1.5);
|
||||||
|
|
||||||
|
-- Add a signpost near the entrance (south edge)
|
||||||
|
INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h)
|
||||||
|
VALUES (t.id, 'decoration.signpost', 0, 0.6 * t.radius, 'south', 0.5, 0.5);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
-- Migration 000027: Cross-roads — add shortcut roads between non-adjacent towns
|
||||||
|
-- so that from some towns there are multiple destination choices.
|
||||||
|
|
||||||
|
-- Shortcut 1: Willowdale <-> Ashengard (bypasses Mossharbor + Thornwatch + Emberwell)
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1500.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Willowdale' AND t.name = 'Ashengard'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1500.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Ashengard' AND t.name = 'Willowdale'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
-- Shortcut 2: Thornwatch <-> Frostmark (bypasses Emberwell + Ashengard)
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1200.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Thornwatch' AND t.name = 'Frostmark'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1200.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Frostmark' AND t.name = 'Thornwatch'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
-- Shortcut 3: Redcliff <-> Cinderkeep (bypasses Duskwatch + Boghollow)
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1400.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Redcliff' AND t.name = 'Cinderkeep'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1400.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Cinderkeep' AND t.name = 'Redcliff'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
-- Shortcut 4: Mossharbor <-> Emberwell (bypasses Thornwatch)
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1100.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Mossharbor' AND t.name = 'Emberwell'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
INSERT INTO roads (from_town_id, to_town_id, distance)
|
||||||
|
SELECT f.id, t.id, 1100.0
|
||||||
|
FROM towns f, towns t
|
||||||
|
WHERE f.name = 'Emberwell' AND t.name = 'Mossharbor'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id);
|
||||||
|
|
||||||
|
-- Generate waypoints for the new cross-roads (same rule as migration 000019).
|
||||||
|
INSERT INTO road_waypoints (road_id, seq, x, y)
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
gs.seq,
|
||||||
|
CASE
|
||||||
|
WHEN gs.seq = 0 THEN f.world_x
|
||||||
|
WHEN gs.seq = seg.nseg THEN t.world_x
|
||||||
|
ELSE f.world_x + (t.world_x - f.world_x) * (gs.seq::double precision / seg.nseg::double precision)
|
||||||
|
END,
|
||||||
|
CASE
|
||||||
|
WHEN gs.seq = 0 THEN f.world_y
|
||||||
|
WHEN gs.seq = seg.nseg THEN t.world_y
|
||||||
|
ELSE f.world_y + (t.world_y - f.world_y) * (gs.seq::double precision / seg.nseg::double precision)
|
||||||
|
END
|
||||||
|
FROM roads r
|
||||||
|
INNER JOIN towns f ON f.id = r.from_town_id
|
||||||
|
INNER JOIN towns t ON t.id = r.to_town_id
|
||||||
|
LEFT JOIN road_waypoints rw ON rw.road_id = r.id
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT GREATEST(
|
||||||
|
1,
|
||||||
|
FLOOR(
|
||||||
|
SQRT(POWER(t.world_x - f.world_x, 2) + POWER(t.world_y - f.world_y, 2)) / 20.0
|
||||||
|
)::integer
|
||||||
|
) AS nseg
|
||||||
|
) seg
|
||||||
|
CROSS JOIN LATERAL generate_series(0, seg.nseg) AS gs(seq)
|
||||||
|
WHERE rw.road_id IS NULL;
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
const n = 12
|
||||||
|
a := 900.0
|
||||||
|
b := 2600.0
|
||||||
|
theta0 := 0.42
|
||||||
|
dtheta := 0.58
|
||||||
|
pts := make([]struct{ x, y float64 }, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
th := theta0 + float64(i)*dtheta
|
||||||
|
r := a + b*th
|
||||||
|
pts[i].x = r * math.Cos(th)
|
||||||
|
pts[i].y = r * math.Sin(th)
|
||||||
|
fmt.Printf("%d: %d, %d (r=%.0f)\n", i, int(math.Round(pts[i].x)), int(math.Round(pts[i].y)), r)
|
||||||
|
}
|
||||||
|
fmt.Println("--- distances ---")
|
||||||
|
var sum float64
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
j := (i + 1) % n
|
||||||
|
d := math.Hypot(pts[j].x-pts[i].x, pts[j].y-pts[i].y)
|
||||||
|
sum += d
|
||||||
|
fmt.Printf("%d->%d: %.0f\n", i, j, d)
|
||||||
|
}
|
||||||
|
fmt.Println("avg:", sum/float64(n))
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import type { AdventureLogEntry } from './types';
|
||||||
|
import type { LogEntry } from '../network/api';
|
||||||
|
|
||||||
|
/** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */
|
||||||
|
export function adventureEntriesFromServerLog(serverLog: LogEntry[]): {
|
||||||
|
entries: AdventureLogEntry[];
|
||||||
|
maxId: number;
|
||||||
|
} {
|
||||||
|
const sorted = [...serverLog].sort(
|
||||||
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
const entries: AdventureLogEntry[] = sorted.map((entry) => ({
|
||||||
|
id: Number(entry.id),
|
||||||
|
message: entry.message,
|
||||||
|
timestamp: new Date(entry.createdAt).getTime(),
|
||||||
|
}));
|
||||||
|
const maxId = entries.reduce((m, e) => Math.max(m, e.id), 0);
|
||||||
|
return { entries, maxId };
|
||||||
|
}
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
export const en = {
|
||||||
|
// General
|
||||||
|
loading: 'Loading hero...',
|
||||||
|
close: 'Close',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
empty: 'Empty',
|
||||||
|
none: 'None',
|
||||||
|
error: 'Error',
|
||||||
|
back: 'Back',
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
hp: 'HP',
|
||||||
|
atk: 'ATK',
|
||||||
|
def: 'DEF',
|
||||||
|
spd: 'Speed',
|
||||||
|
moveSpd: 'Move SPD',
|
||||||
|
str: 'STR',
|
||||||
|
con: 'CON',
|
||||||
|
agi: 'AGI',
|
||||||
|
luck: 'LUCK',
|
||||||
|
xp: 'XP',
|
||||||
|
gold: 'Gold',
|
||||||
|
level: 'Lv',
|
||||||
|
stat: 'STAT',
|
||||||
|
|
||||||
|
// Hero Panel
|
||||||
|
heroStats: 'Hero Stats',
|
||||||
|
experience: 'Experience',
|
||||||
|
activeBuffs: 'Active Buffs',
|
||||||
|
activeDebuffs: 'Active Debuffs',
|
||||||
|
|
||||||
|
// Equipment
|
||||||
|
equipment: 'Equipment',
|
||||||
|
slotWeapon: 'Weapon',
|
||||||
|
slotOffHand: 'Off Hand',
|
||||||
|
slotHead: 'Head',
|
||||||
|
slotChest: 'Chest',
|
||||||
|
slotLegs: 'Legs',
|
||||||
|
slotFeet: 'Feet',
|
||||||
|
slotCloak: 'Cloak',
|
||||||
|
slotNeck: 'Neck',
|
||||||
|
slotRing: 'Ring',
|
||||||
|
slotWrist: 'Wrist',
|
||||||
|
slotHands: 'Hands',
|
||||||
|
slotQuiver: 'Quiver',
|
||||||
|
inventory: 'Inventory',
|
||||||
|
|
||||||
|
// Rarity
|
||||||
|
common: 'Common',
|
||||||
|
uncommon: 'Uncommon',
|
||||||
|
rare: 'Rare',
|
||||||
|
epic: 'Epic',
|
||||||
|
legendary: 'Legendary',
|
||||||
|
|
||||||
|
// Buff names
|
||||||
|
buffRush: 'Rush',
|
||||||
|
buffRage: 'Rage',
|
||||||
|
buffShield: 'Shield',
|
||||||
|
buffLuck: 'Luck',
|
||||||
|
buffResurrection: 'Resurrect',
|
||||||
|
buffHeal: 'Heal',
|
||||||
|
buffPowerPotion: 'Power',
|
||||||
|
buffWarCry: 'War Cry',
|
||||||
|
|
||||||
|
// Buff descriptions
|
||||||
|
buffRushDesc: '+50% movement speed',
|
||||||
|
buffRageDesc: '+100% damage',
|
||||||
|
buffShieldDesc: '-50% incoming damage',
|
||||||
|
buffLuckDesc: 'x2.5 loot drops',
|
||||||
|
buffResurrectionDesc: 'Revive at 50% HP',
|
||||||
|
buffHealDesc: '+50% HP instant',
|
||||||
|
buffPowerPotionDesc: '+150% damage',
|
||||||
|
buffWarCryDesc: '+100% attack speed',
|
||||||
|
|
||||||
|
// Buff UI
|
||||||
|
charges: 'Charges',
|
||||||
|
refillsAt: 'Refills at',
|
||||||
|
refill: 'Refill',
|
||||||
|
refillQuestion: 'Refill {label}?',
|
||||||
|
noChargesLeft: 'No charges left for {label}',
|
||||||
|
|
||||||
|
// Debuff names
|
||||||
|
debuffPoison: 'Poison',
|
||||||
|
debuffFreeze: 'Freeze',
|
||||||
|
debuffBurn: 'Burn',
|
||||||
|
debuffStun: 'Stun',
|
||||||
|
debuffSlow: 'Slow',
|
||||||
|
debuffWeaken: 'Weaken',
|
||||||
|
|
||||||
|
// Quest system
|
||||||
|
questLog: 'Quest Log',
|
||||||
|
noActiveQuests: 'No active quests. Visit an NPC to accept quests!',
|
||||||
|
claimRewards: 'Claim Rewards',
|
||||||
|
abandon: 'Abandon',
|
||||||
|
acceptQuest: 'Accept',
|
||||||
|
questAccepted: 'Quest accepted!',
|
||||||
|
questRewardsClaimed: 'Quest rewards claimed!',
|
||||||
|
questAbandoned: 'Quest abandoned',
|
||||||
|
failedToAcceptQuest: 'Failed to accept quest',
|
||||||
|
failedToClaimRewards: 'Failed to claim rewards',
|
||||||
|
failedToAbandonQuest: 'Failed to abandon quest',
|
||||||
|
completed: 'Completed',
|
||||||
|
|
||||||
|
// NPC
|
||||||
|
questGiver: 'Quest Giver',
|
||||||
|
merchant: 'Merchant',
|
||||||
|
healer: 'Healer',
|
||||||
|
npc: 'NPC',
|
||||||
|
buyPotion: 'Buy Potion',
|
||||||
|
healToFull: 'Heal to Full',
|
||||||
|
boughtPotion: 'Bought a potion for {cost} gold',
|
||||||
|
healedToFull: 'Healed to full HP!',
|
||||||
|
notEnoughGold: 'Not enough gold!',
|
||||||
|
failedToBuyPotion: 'Failed to buy potion',
|
||||||
|
failedToHeal: 'Failed to heal',
|
||||||
|
|
||||||
|
// Wandering NPC
|
||||||
|
giveGoldForItem: 'Give {cost} gold for a mysterious item?',
|
||||||
|
accept: 'Accept',
|
||||||
|
decline: 'Decline',
|
||||||
|
giving: 'Giving...',
|
||||||
|
|
||||||
|
// Death screen
|
||||||
|
youDied: 'YOU DIED',
|
||||||
|
reviveNow: 'REVIVE NOW',
|
||||||
|
freeRevivesLeft: 'Free revives left: {count}',
|
||||||
|
autoReviveIn: 'Auto-revive in {timer}s',
|
||||||
|
noFreeRevives: 'No free revives left \u2014 subscription required',
|
||||||
|
|
||||||
|
// Name entry
|
||||||
|
chooseHeroName: 'Choose Your Hero Name',
|
||||||
|
enterName: 'Enter a name...',
|
||||||
|
continue: 'Continue',
|
||||||
|
saving: 'Saving...',
|
||||||
|
nameTaken: 'Name already taken, try another',
|
||||||
|
invalidName: 'Invalid name',
|
||||||
|
serverError: 'Server error ({status})',
|
||||||
|
connectionFailed: 'Connection failed, please retry',
|
||||||
|
|
||||||
|
// Offline report
|
||||||
|
whileYouWereAway: 'While you were away...',
|
||||||
|
killedMonsters: 'Killed {count} monster(s)',
|
||||||
|
gainedXP: '+{xp} XP',
|
||||||
|
gainedGold: '+{gold} gold',
|
||||||
|
gainedLevels: 'Gained {levels} level(s)!',
|
||||||
|
tapToDismiss: 'Tap anywhere to dismiss',
|
||||||
|
|
||||||
|
// Toasts
|
||||||
|
levelUp: 'Level up! Now level {level}',
|
||||||
|
heroRevived: 'Hero revived!',
|
||||||
|
entering: 'Entering {townName}',
|
||||||
|
newEquipment: 'New {slot}: {itemName}',
|
||||||
|
potionsCollected: '+{count} potion(s)',
|
||||||
|
questProgress: '{title} ({current}/{target})',
|
||||||
|
questCompleted: 'Quest completed: {title}!',
|
||||||
|
buffLimitReached: 'Buff limit reached',
|
||||||
|
reviveNotAllowed: 'Revive not allowed',
|
||||||
|
dailyTaskClaimed: 'Daily task reward claimed!',
|
||||||
|
failedToClaimReward: 'Failed to claim reward',
|
||||||
|
|
||||||
|
// Minimap
|
||||||
|
map: 'MAP',
|
||||||
|
|
||||||
|
// Adventure log
|
||||||
|
noEventsYet: 'No events yet...',
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
adventureLog: 'Adventure Log',
|
||||||
|
shopLabel: 'Shop',
|
||||||
|
healerLabel: 'Healer',
|
||||||
|
questLabel: 'Quest',
|
||||||
|
|
||||||
|
// Hero Sheet tabs
|
||||||
|
heroSheetQuestBadgeAria: 'Quests ready to turn in: {count}',
|
||||||
|
stats: 'Stats',
|
||||||
|
character: 'Char',
|
||||||
|
journal: 'Journal',
|
||||||
|
quests: 'Quests',
|
||||||
|
hero: 'Hero',
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: 'Settings',
|
||||||
|
language: 'Language',
|
||||||
|
english: 'English',
|
||||||
|
russian: 'Russian',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TranslationKey = keyof typeof en;
|
||||||
|
export type Translations = Record<TranslationKey, string>;
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import { en, type TranslationKey, type Translations } from './en';
|
||||||
|
import { ru } from './ru';
|
||||||
|
|
||||||
|
export type Locale = 'en' | 'ru';
|
||||||
|
|
||||||
|
const bundles: Record<Locale, Translations> = { en, ru };
|
||||||
|
|
||||||
|
/** Detect locale from Telegram WebApp or browser */
|
||||||
|
export function detectLocale(): Locale {
|
||||||
|
// Check localStorage first (user override)
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('autohero_locale');
|
||||||
|
if (saved === 'en' || saved === 'ru') return saved;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Telegram Mini App language
|
||||||
|
try {
|
||||||
|
const tg = (window as any).Telegram?.WebApp;
|
||||||
|
const lang: string | undefined =
|
||||||
|
tg?.initDataUnsafe?.user?.language_code ?? tg?.language_code;
|
||||||
|
if (lang?.startsWith('ru')) return 'ru';
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Browser language fallback
|
||||||
|
const nav = navigator.language ?? (navigator as any).userLanguage ?? '';
|
||||||
|
if (nav.startsWith('ru')) return 'ru';
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Context ----
|
||||||
|
|
||||||
|
interface I18nValue {
|
||||||
|
tr: Translations;
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (l: Locale) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const I18nContext = createContext<I18nValue>({
|
||||||
|
tr: en,
|
||||||
|
locale: 'en',
|
||||||
|
setLocale: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Hook: returns the full translation object for the current locale */
|
||||||
|
export function useT(): Translations {
|
||||||
|
return useContext(I18nContext).tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook: returns locale + setter for the settings UI */
|
||||||
|
export function useLocale(): { locale: Locale; setLocale: (l: Locale) => void } {
|
||||||
|
const { locale, setLocale } = useContext(I18nContext);
|
||||||
|
return { locale, setLocale };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate {placeholders} in a translation string.
|
||||||
|
* Usage: t(translations.levelUp, { level: 5 }) => "Level up! Now level 5"
|
||||||
|
*/
|
||||||
|
export function t(template: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, key) =>
|
||||||
|
vars[key] != null ? String(vars[key]) : `{${key}}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get translations bundle for a locale */
|
||||||
|
export function getTranslations(locale: Locale): Translations {
|
||||||
|
return bundles[locale] ?? en;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { TranslationKey, Translations };
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
import type { Translations } from './en';
|
||||||
|
|
||||||
|
export const ru: Translations = {
|
||||||
|
// General
|
||||||
|
loading: '\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0433\u0435\u0440\u043e\u044f...',
|
||||||
|
close: '\u0417\u0430\u043a\u0440\u044b\u0442\u044c',
|
||||||
|
cancel: '\u041e\u0442\u043c\u0435\u043d\u0430',
|
||||||
|
confirm: '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c',
|
||||||
|
empty: '\u041f\u0443\u0441\u0442\u043e',
|
||||||
|
none: '\u041d\u0435\u0442',
|
||||||
|
error: '\u041e\u0448\u0438\u0431\u043a\u0430',
|
||||||
|
back: '\u041d\u0430\u0437\u0430\u0434',
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
hp: 'HP',
|
||||||
|
atk: '\u0410\u0422\u041a',
|
||||||
|
def: '\u0417\u0410\u0429',
|
||||||
|
spd: '\u0421\u043a\u043e\u0440.',
|
||||||
|
moveSpd: '\u0421\u043a\u043e\u0440. \u0434\u0432\u0438\u0436.',
|
||||||
|
str: '\u0421\u0418\u041b',
|
||||||
|
con: '\u0412\u042b\u041d',
|
||||||
|
agi: '\u041b\u041e\u0412',
|
||||||
|
luck: '\u0423\u0414\u0410\u0427',
|
||||||
|
xp: '\u041e\u041f',
|
||||||
|
gold: '\u0417\u043e\u043b\u043e\u0442\u043e',
|
||||||
|
level: '\u0423\u0440',
|
||||||
|
stat: '\u0421\u0422\u0410\u0422',
|
||||||
|
|
||||||
|
// Hero Panel
|
||||||
|
heroStats: '\u0421\u0442\u0430\u0442\u044b \u0433\u0435\u0440\u043e\u044f',
|
||||||
|
experience: '\u041e\u043f\u044b\u0442',
|
||||||
|
activeBuffs: '\u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0435 \u0431\u0430\u0444\u044b',
|
||||||
|
activeDebuffs: '\u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0435 \u0434\u0435\u0431\u0430\u0444\u044b',
|
||||||
|
|
||||||
|
// Equipment
|
||||||
|
equipment: '\u0421\u043d\u0430\u0440\u044f\u0436\u0435\u043d\u0438\u0435',
|
||||||
|
slotWeapon: '\u041e\u0440\u0443\u0436\u0438\u0435',
|
||||||
|
slotOffHand: '\u041b\u0435\u0432\u0430\u044f \u0440\u0443\u043a\u0430',
|
||||||
|
slotHead: '\u0413\u043e\u043b\u043e\u0432\u0430',
|
||||||
|
slotChest: '\u041d\u0430\u0433\u0440\u0443\u0434\u043d\u0438\u043a',
|
||||||
|
slotLegs: '\u041d\u043e\u0433\u0438',
|
||||||
|
slotFeet: '\u041e\u0431\u0443\u0432\u044c',
|
||||||
|
slotCloak: '\u041f\u043b\u0430\u0449',
|
||||||
|
slotNeck: '\u0428\u0435\u044f',
|
||||||
|
slotRing: '\u041a\u043e\u043b\u044c\u0446\u043e',
|
||||||
|
slotWrist: '\u0417\u0430\u043f\u044f\u0441\u0442\u044c\u0435',
|
||||||
|
slotHands: '\u0420\u0443\u043a\u0438',
|
||||||
|
slotQuiver: '\u041a\u043e\u043b\u0447\u0430\u043d',
|
||||||
|
inventory: '\u0418\u043d\u0432\u0435\u043d\u0442\u0430\u0440\u044c',
|
||||||
|
|
||||||
|
// Rarity
|
||||||
|
common: '\u041e\u0431\u044b\u0447\u043d\u043e\u0435',
|
||||||
|
uncommon: '\u041d\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0435',
|
||||||
|
rare: '\u0420\u0435\u0434\u043a\u043e\u0435',
|
||||||
|
epic: '\u042d\u043f\u0438\u0447\u0435\u0441\u043a\u043e\u0435',
|
||||||
|
legendary: '\u041b\u0435\u0433\u0435\u043d\u0434\u0430\u0440\u043d\u043e\u0435',
|
||||||
|
|
||||||
|
// Buff names
|
||||||
|
buffRush: '\u0420\u044b\u0432\u043e\u043a',
|
||||||
|
buffRage: '\u042f\u0440\u043e\u0441\u0442\u044c',
|
||||||
|
buffShield: '\u0429\u0438\u0442',
|
||||||
|
buffLuck: '\u0423\u0434\u0430\u0447\u0430',
|
||||||
|
buffResurrection: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435',
|
||||||
|
buffHeal: '\u0418\u0441\u0446\u0435\u043b\u0435\u043d\u0438\u0435',
|
||||||
|
buffPowerPotion: '\u0421\u0438\u043b\u0430',
|
||||||
|
buffWarCry: '\u041a\u043b\u0438\u0447',
|
||||||
|
|
||||||
|
// Buff descriptions
|
||||||
|
buffRushDesc: '+50% \u043a \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f',
|
||||||
|
buffRageDesc: '+100% \u043a \u0443\u0440\u043e\u043d\u0443',
|
||||||
|
buffShieldDesc: '-50% \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u0443\u0440\u043e\u043d\u0430',
|
||||||
|
buffLuckDesc: 'x2.5 \u0434\u0440\u043e\u043f \u043f\u0440\u0435\u0434\u043c\u0435\u0442\u043e\u0432',
|
||||||
|
buffResurrectionDesc: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0441 50% HP',
|
||||||
|
buffHealDesc: '+50% HP \u043c\u0433\u043d\u043e\u0432\u0435\u043d\u043d\u043e',
|
||||||
|
buffPowerPotionDesc: '+150% \u043a \u0443\u0440\u043e\u043d\u0443',
|
||||||
|
buffWarCryDesc: '+100% \u043a \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 \u0430\u0442\u0430\u043a\u0438',
|
||||||
|
|
||||||
|
// Buff UI
|
||||||
|
charges: '\u0417\u0430\u0440\u044f\u0434\u044b',
|
||||||
|
refillsAt: '\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0432',
|
||||||
|
refill: '\u041f\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u044c',
|
||||||
|
refillQuestion: '\u041f\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u044c {label}?',
|
||||||
|
noChargesLeft: '\u041d\u0435\u0442 \u0437\u0430\u0440\u044f\u0434\u043e\u0432 \u0434\u043b\u044f {label}',
|
||||||
|
|
||||||
|
// Debuff names
|
||||||
|
debuffPoison: '\u042f\u0434',
|
||||||
|
debuffFreeze: '\u0417\u0430\u043c\u043e\u0440\u043e\u0437\u043a\u0430',
|
||||||
|
debuffBurn: '\u041e\u0436\u043e\u0433',
|
||||||
|
debuffStun: '\u041e\u0433\u043b\u0443\u0448\u0435\u043d\u0438\u0435',
|
||||||
|
debuffSlow: '\u0417\u0430\u043c\u0435\u0434\u043b\u0435\u043d\u0438\u0435',
|
||||||
|
debuffWeaken: '\u041e\u0441\u043b\u0430\u0431\u043b\u0435\u043d\u0438\u0435',
|
||||||
|
|
||||||
|
// Quest system
|
||||||
|
questLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u0437\u0430\u0434\u0430\u043d\u0438\u0439',
|
||||||
|
noActiveQuests: '\u041d\u0435\u0442 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u0434\u0430\u043d\u0438\u0439. \u041f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u0442\u0435 \u0441 NPC!',
|
||||||
|
claimRewards: '\u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
|
||||||
|
abandon: '\u041e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f',
|
||||||
|
acceptQuest: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
|
||||||
|
questAccepted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043d\u044f\u0442\u043e!',
|
||||||
|
questRewardsClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
|
||||||
|
questAbandoned: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u043e',
|
||||||
|
failedToAcceptQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u0438\u043d\u044f\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
|
||||||
|
failedToClaimRewards: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
|
||||||
|
failedToAbandonQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
|
||||||
|
completed: '\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e',
|
||||||
|
|
||||||
|
// NPC
|
||||||
|
questGiver: '\u041a\u0432\u0435\u0441\u0442\u043e\u0434\u0430\u0442\u0435\u043b\u044c',
|
||||||
|
merchant: '\u0422\u043e\u0440\u0433\u043e\u0432\u0435\u0446',
|
||||||
|
healer: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
|
||||||
|
npc: 'NPC',
|
||||||
|
buyPotion: '\u041a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
|
||||||
|
healToFull: '\u0418\u0441\u0446\u0435\u043b\u0438\u0442\u044c \u043f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e',
|
||||||
|
boughtPotion: '\u041a\u0443\u043f\u043b\u0435\u043d\u043e \u0437\u0435\u043b\u044c\u0435 \u0437\u0430 {cost} \u0437\u043e\u043b\u043e\u0442\u0430',
|
||||||
|
healedToFull: '\u0417\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e!',
|
||||||
|
notEnoughGold: '\u041d\u0435\u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0437\u043e\u043b\u043e\u0442\u0430!',
|
||||||
|
failedToBuyPotion: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
|
||||||
|
failedToHeal: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0438\u0441\u0446\u0435\u043b\u0438\u0442\u044c',
|
||||||
|
|
||||||
|
// Wandering NPC
|
||||||
|
giveGoldForItem: '\u041e\u0442\u0434\u0430\u0442\u044c {cost} \u0437\u043e\u043b\u043e\u0442\u0430 \u0437\u0430 \u0437\u0430\u0433\u0430\u0434\u043e\u0447\u043d\u044b\u0439 \u043f\u0440\u0435\u0434\u043c\u0435\u0442?',
|
||||||
|
accept: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
|
||||||
|
decline: '\u041e\u0442\u043a\u043b\u043e\u043d\u0438\u0442\u044c',
|
||||||
|
giving: '\u041e\u0442\u0434\u0430\u044e...',
|
||||||
|
|
||||||
|
// Death screen
|
||||||
|
youDied: '\u0412\u042b \u041f\u041e\u0413\u0418\u0411\u041b\u0418',
|
||||||
|
reviveNow: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c',
|
||||||
|
freeRevivesLeft: '\u0411\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}',
|
||||||
|
autoReviveIn: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 {timer}\u0441',
|
||||||
|
noFreeRevives: '\u041d\u0435\u0442 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u2014 \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430',
|
||||||
|
|
||||||
|
// Name entry
|
||||||
|
chooseHeroName: '\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043c\u044f \u0433\u0435\u0440\u043e\u044f',
|
||||||
|
enterName: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f...',
|
||||||
|
continue: '\u041f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c',
|
||||||
|
saving: '\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...',
|
||||||
|
nameTaken: '\u0418\u043c\u044f \u0443\u0436\u0435 \u0437\u0430\u043d\u044f\u0442\u043e, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0434\u0440\u0443\u0433\u043e\u0435',
|
||||||
|
invalidName: '\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u043e\u0435 \u0438\u043c\u044f',
|
||||||
|
serverError: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 ({status})',
|
||||||
|
connectionFailed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430',
|
||||||
|
|
||||||
|
// Offline report
|
||||||
|
whileYouWereAway: '\u041f\u043e\u043a\u0430 \u0432\u0430\u0441 \u043d\u0435 \u0431\u044b\u043b\u043e...',
|
||||||
|
killedMonsters: '\u0423\u0431\u0438\u0442\u043e \u043c\u043e\u043d\u0441\u0442\u0440\u043e\u0432: {count}',
|
||||||
|
gainedXP: '+{xp} \u041e\u041f',
|
||||||
|
gainedGold: '+{gold} \u0437\u043e\u043b\u043e\u0442\u0430',
|
||||||
|
gainedLevels: '\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0443\u0440\u043e\u0432\u043d\u0435\u0439: {levels}!',
|
||||||
|
tapToDismiss: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f',
|
||||||
|
|
||||||
|
// Toasts
|
||||||
|
levelUp: '\u041d\u043e\u0432\u044b\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c: {level}!',
|
||||||
|
heroRevived: '\u0413\u0435\u0440\u043e\u0439 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d!',
|
||||||
|
entering: '\u0412\u0445\u043e\u0434 \u0432 {townName}',
|
||||||
|
newEquipment: '\u041d\u043e\u0432\u043e\u0435 {slot}: {itemName}',
|
||||||
|
potionsCollected: '+{count} \u0437\u0435\u043b\u044c\u0435(\u0439)',
|
||||||
|
questProgress: '{title} ({current}/{target})',
|
||||||
|
questCompleted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e: {title}!',
|
||||||
|
buffLimitReached: '\u041b\u0438\u043c\u0438\u0442 \u0431\u0430\u0444\u043e\u0432 \u0434\u043e\u0441\u0442\u0438\u0433\u043d\u0443\u0442',
|
||||||
|
reviveNotAllowed: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e',
|
||||||
|
dailyTaskClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u0437\u0430 \u0437\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
|
||||||
|
failedToClaimReward: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
|
||||||
|
|
||||||
|
// Minimap
|
||||||
|
map: '\u041a\u0410\u0420\u0422\u0410',
|
||||||
|
|
||||||
|
// Adventure log
|
||||||
|
noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...',
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439',
|
||||||
|
shopLabel: '\u041c\u0430\u0433\u0430\u0437\u0438\u043d',
|
||||||
|
healerLabel: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
|
||||||
|
questLabel: '\u041a\u0432\u0435\u0441\u0442',
|
||||||
|
|
||||||
|
// Hero Sheet tabs
|
||||||
|
heroSheetQuestBadgeAria:
|
||||||
|
'\u041a\u0432\u0435\u0441\u0442\u044b \u043a \u0441\u0434\u0430\u0447\u0435: {count}',
|
||||||
|
stats: '\u0421\u0442\u0430\u0442\u044b',
|
||||||
|
character: '\u041f\u0435\u0440\u0441.',
|
||||||
|
journal: '\u0416\u0443\u0440\u043d\u0430\u043b',
|
||||||
|
quests: '\u041a\u0432\u0435\u0441\u0442\u044b',
|
||||||
|
hero: '\u0413\u0435\u0440\u043e\u0439',
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
|
||||||
|
language: '\u042f\u0437\u044b\u043a',
|
||||||
|
english: 'English',
|
||||||
|
russian: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439',
|
||||||
|
};
|
||||||
@ -0,0 +1,173 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import type { EquipmentItem } from '../game/types';
|
||||||
|
import { RARITY_COLORS, RARITY_GLOW } from '../shared/constants';
|
||||||
|
import { useT } from '../i18n';
|
||||||
|
import type { Translations } from '../i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid layout (row-major):
|
||||||
|
* finger | head | neck
|
||||||
|
* weapon | chest (2 rows) | cloak
|
||||||
|
* hands | ↑ | wrist
|
||||||
|
* feet | . | legs
|
||||||
|
*/
|
||||||
|
const SLOT_LAYOUT: Array<{
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
labelKey: keyof Translations;
|
||||||
|
area: 'finger' | 'head' | 'neck' | 'weapon' | 'chest' | 'cloak' | 'hands' | 'wrist' | 'feet' | 'legs';
|
||||||
|
}> = [
|
||||||
|
{ key: 'finger', icon: '\uD83D\uDCBF', labelKey: 'slotRing', area: 'finger' },
|
||||||
|
{ key: 'head', icon: '\u26D1\uFE0F', labelKey: 'slotHead', area: 'head' },
|
||||||
|
{ key: 'neck', icon: '\uD83D\uDCBF', labelKey: 'slotNeck', area: 'neck' },
|
||||||
|
{ key: 'main_hand', icon: '\u2694\uFE0F', labelKey: 'slotWeapon', area: 'weapon' },
|
||||||
|
{ key: 'chest', icon: '\uD83D\uDEE1\uFE0F', labelKey: 'slotChest', area: 'chest' },
|
||||||
|
{ key: 'cloak', icon: '\uD83D\uDEE1\uFE0F', labelKey: 'slotCloak', area: 'cloak' },
|
||||||
|
{ key: 'hands', icon: '\uD83D\uDC42', labelKey: 'slotHands', area: 'hands' },
|
||||||
|
{ key: 'wrist', icon: '\uD83D\uDC42', labelKey: 'slotWrist', area: 'wrist' },
|
||||||
|
{ key: 'feet', icon: '\uD83D\uDC62', labelKey: 'slotFeet', area: 'feet' },
|
||||||
|
{ key: 'legs', icon: '\uD83D\uDC62', labelKey: 'slotLegs', area: 'legs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dollWrap: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateAreas: `
|
||||||
|
"finger head neck"
|
||||||
|
"weapon chest cloak"
|
||||||
|
"hands chest wrist"
|
||||||
|
"feet . legs"
|
||||||
|
`,
|
||||||
|
gridTemplateColumns: 'minmax(72px, 1fr) minmax(100px, 1.2fr) minmax(72px, 1fr)',
|
||||||
|
gap: 6,
|
||||||
|
alignItems: 'stretch',
|
||||||
|
justifyItems: 'center',
|
||||||
|
padding: '8px 4px',
|
||||||
|
minHeight: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chestBackdrop: CSSProperties = {
|
||||||
|
gridArea: 'chest',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 132,
|
||||||
|
minHeight: 120,
|
||||||
|
borderRadius: '45% 45% 40% 40% / 55% 55% 45% 45%',
|
||||||
|
background: 'linear-gradient(180deg, rgba(70,80,110,0.4) 0%, rgba(35,40,55,0.55) 100%)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
boxShadow: 'inset 0 -24px 48px rgba(0,0,0,0.4)',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
justifySelf: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotBox: CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: 96,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid rgba(255,255,255,0.14)',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.42)',
|
||||||
|
padding: '6px 6px',
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#bbb',
|
||||||
|
textAlign: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotLabel: CSSProperties = {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'rgba(200,210,230,0.55)',
|
||||||
|
marginBottom: 4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconRow: CSSProperties = {
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemNameStyle: CSSProperties = {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statTiny: CSSProperties = {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#888',
|
||||||
|
marginTop: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function rarityColor(rarity: string): string {
|
||||||
|
return RARITY_COLORS[rarity.toLowerCase()] ?? '#9d9d9d';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rarityGlow(rarity: string): string {
|
||||||
|
return RARITY_GLOW[rarity.toLowerCase()] ?? 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statLabel(statType: string, tr: Translations): string {
|
||||||
|
switch (statType) {
|
||||||
|
case 'attack': return tr.atk;
|
||||||
|
case 'defense': return tr.def;
|
||||||
|
case 'speed': return tr.spd;
|
||||||
|
default: return tr.stat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentPaperDollProps {
|
||||||
|
equipment: Record<string, EquipmentItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentPaperDoll({ equipment }: EquipmentPaperDollProps) {
|
||||||
|
const tr = useT();
|
||||||
|
return (
|
||||||
|
<div style={dollWrap}>
|
||||||
|
<div style={chestBackdrop} aria-hidden />
|
||||||
|
|
||||||
|
{SLOT_LAYOUT.map((def) => {
|
||||||
|
const item = equipment?.[def.key];
|
||||||
|
const gridArea = def.area;
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<div key={def.key} style={{ ...slotBox, gridArea }}>
|
||||||
|
<div style={slotLabel}>{tr[def.labelKey]}</div>
|
||||||
|
<div style={iconRow}>{def.icon}</div>
|
||||||
|
<div style={{ color: '#555', fontStyle: 'italic', fontSize: 9 }}>{tr.empty}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = rarityColor(item.rarity);
|
||||||
|
const glow = rarityGlow(item.rarity);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={def.key}
|
||||||
|
style={{
|
||||||
|
...slotBox,
|
||||||
|
gridArea,
|
||||||
|
borderColor: `${color}55`,
|
||||||
|
boxShadow:
|
||||||
|
glow !== 'none'
|
||||||
|
? `0 0 10px ${color}33, inset 0 0 8px ${color}18`
|
||||||
|
: `inset 0 0 6px ${color}15`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={slotLabel}>{tr[def.labelKey]}</div>
|
||||||
|
<div style={iconRow}>{def.icon}</div>
|
||||||
|
<div style={{ ...itemNameStyle, color }}>{item.name}</div>
|
||||||
|
<div style={statTiny}>
|
||||||
|
{statLabel(item.statType, tr)} {item.primaryStat} · ilvl {item.ilvl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,276 @@
|
|||||||
|
import { useEffect, useState, type CSSProperties } from 'react';
|
||||||
|
import type { AdventureLogEntry, EquipmentItem, HeroQuest, HeroState } from '../game/types';
|
||||||
|
import { EquipmentPaperDoll } from './EquipmentPaperDoll';
|
||||||
|
import { InventoryGrid } from './InventoryGrid';
|
||||||
|
import { HeroStatsContent } from './HeroPanel';
|
||||||
|
import { AdventureLogEntries } from './AdventureLog';
|
||||||
|
import { QuestLogList } from './QuestLog';
|
||||||
|
import { useT, useLocale, type Locale } from '../i18n';
|
||||||
|
|
||||||
|
export type HeroSheetTab = 'stats' | 'character' | 'inventory' | 'journal' | 'quests' | 'settings';
|
||||||
|
|
||||||
|
const overlay: CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 800,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 12,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
const backdrop: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const panel: CSSProperties = {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 420,
|
||||||
|
height: 'min(88vh, 640px)',
|
||||||
|
maxHeight: 'min(88vh, 640px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||||
|
backgroundColor: 'rgba(12, 14, 22, 0.94)',
|
||||||
|
boxShadow: '0 12px 48px rgba(0,0,0,0.55)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
const header: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle: CSSProperties = {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#e8e8e8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBtn: CSSProperties = {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#888',
|
||||||
|
fontSize: 22,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px 8px',
|
||||||
|
lineHeight: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabsRow: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabBtn = (active: boolean): CSSProperties => ({
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 2px',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
background: active ? 'rgba(80, 120, 200, 0.22)' : 'transparent',
|
||||||
|
color: active ? '#c8d8ff' : '#778',
|
||||||
|
borderBottom: active ? '2px solid #6a9eef' : '2px solid transparent',
|
||||||
|
WebkitTapHighlightColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '12px 12px 16px',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#ccc',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
};
|
||||||
|
|
||||||
|
const goldBar: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'rgba(255, 215, 0, 0.08)',
|
||||||
|
border: '1px solid rgba(255, 215, 0, 0.2)',
|
||||||
|
color: '#ffd700',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeroSheetModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialTab?: HeroSheetTab;
|
||||||
|
hero: HeroState;
|
||||||
|
nowMs: number;
|
||||||
|
equipment: Record<string, EquipmentItem>;
|
||||||
|
logEntries: AdventureLogEntry[];
|
||||||
|
quests: HeroQuest[];
|
||||||
|
onQuestClaim: (heroQuestId: number) => void;
|
||||||
|
onQuestAbandon: (heroQuestId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroSheetModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
initialTab = 'stats',
|
||||||
|
hero,
|
||||||
|
nowMs,
|
||||||
|
equipment,
|
||||||
|
logEntries,
|
||||||
|
quests,
|
||||||
|
onQuestClaim,
|
||||||
|
onQuestAbandon,
|
||||||
|
}: HeroSheetModalProps) {
|
||||||
|
const [tab, setTab] = useState<HeroSheetTab>(initialTab);
|
||||||
|
const tr = useT();
|
||||||
|
const { locale, setLocale } = useLocale();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setTab(initialTab);
|
||||||
|
}, [open, initialTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', h);
|
||||||
|
return () => window.removeEventListener('keydown', h);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={backdrop} onClick={onClose} aria-hidden />
|
||||||
|
<div style={panel} role="dialog" aria-modal aria-labelledby="hero-sheet-title">
|
||||||
|
<div style={header}>
|
||||||
|
<span id="hero-sheet-title" style={titleStyle}>
|
||||||
|
{tr.hero} · {tr.level}.{hero.level}
|
||||||
|
</span>
|
||||||
|
<button type="button" style={closeBtn} onClick={onClose} aria-label="Close">
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={tabsRow}>
|
||||||
|
<button type="button" style={tabBtn(tab === 'stats')} onClick={() => setTab('stats')}>
|
||||||
|
{tr.stats}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={tabBtn(tab === 'character')} onClick={() => setTab('character')}>
|
||||||
|
{tr.character}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={tabBtn(tab === 'inventory')} onClick={() => setTab('inventory')}>
|
||||||
|
{tr.inventory}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={tabBtn(tab === 'journal')} onClick={() => setTab('journal')}>
|
||||||
|
{tr.journal}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={tabBtn(tab === 'quests')} onClick={() => setTab('quests')}>
|
||||||
|
{tr.quests}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={tabBtn(tab === 'settings')} onClick={() => setTab('settings')}>
|
||||||
|
{'\u2699\uFE0F'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={body}>
|
||||||
|
{tab === 'stats' && <HeroStatsContent hero={hero} nowMs={nowMs} />}
|
||||||
|
{tab === 'character' && <EquipmentPaperDoll equipment={equipment} />}
|
||||||
|
{tab === 'inventory' && (
|
||||||
|
<>
|
||||||
|
<div style={goldBar}>
|
||||||
|
<span style={{ fontSize: 18 }}>{'\uD83E\uDE99'}</span>
|
||||||
|
<span>{hero.gold.toLocaleString()} {tr.gold.toLowerCase()}</span>
|
||||||
|
</div>
|
||||||
|
<InventoryGrid items={hero.inventory} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === 'journal' && <AdventureLogEntries entries={logEntries} />}
|
||||||
|
{tab === 'quests' && (
|
||||||
|
<QuestLogList
|
||||||
|
quests={quests}
|
||||||
|
onClaim={onQuestClaim}
|
||||||
|
onAbandon={onQuestAbandon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'settings' && (
|
||||||
|
<SettingsContent locale={locale} setLocale={setLocale} tr={tr} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings Tab ----
|
||||||
|
|
||||||
|
const settingRow: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const langBtn = (active: boolean): CSSProperties => ({
|
||||||
|
padding: '6px 16px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: active ? '2px solid #ffd700' : '1px solid rgba(255,255,255,0.15)',
|
||||||
|
background: active ? 'rgba(255, 215, 0, 0.15)' : 'rgba(255,255,255,0.04)',
|
||||||
|
color: active ? '#ffd700' : '#aaa',
|
||||||
|
fontWeight: active ? 700 : 400,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 150ms ease',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SettingsContentProps {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (l: Locale) => void;
|
||||||
|
tr: { settings: string; language: string; english: string; russian: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsContent({ locale, setLocale, tr }: SettingsContentProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#e8e8e8', marginBottom: 12 }}>
|
||||||
|
{tr.settings}
|
||||||
|
</div>
|
||||||
|
<div style={settingRow}>
|
||||||
|
<span style={{ color: '#ccc', fontSize: 13 }}>{tr.language}</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={langBtn(locale === 'en')}
|
||||||
|
onClick={() => setLocale('en')}
|
||||||
|
>
|
||||||
|
{tr.english}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={langBtn(locale === 'ru')}
|
||||||
|
onClick={() => setLocale('ru')}
|
||||||
|
>
|
||||||
|
{tr.russian}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue