Compare commits

..

7 Commits

@ -8,6 +8,7 @@ import (
"sort"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
)
@ -96,10 +97,10 @@ func runOneGridScenario(tmpl model.Enemy, base *model.Hero, enemyLv int, n int,
if maxH <= 0 {
maxH = 1
}
e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv)
e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv, nil)
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
if survived {
wins++

@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
@ -626,7 +627,7 @@ func runSeries(base model.Enemy, level int, hpScale, atkScale float64, n int, se
e := enemy
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
allDur = append(allDur, elapsed)
if survived {

@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
@ -156,11 +157,11 @@ func main() {
if hero.HP <= 0 {
hero.HP = hero.MaxHP
}
enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv)
enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv, nil)
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
WallClockDelay: time.Duration(*delayMs) * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
if survived {
wins++

@ -0,0 +1,45 @@
package main
import (
"flag"
"fmt"
"math"
)
func main() {
var (
gearAtk = flag.Int("gearAtk", 10, "gear attack rate")
maxLvl = flag.Int("maxLvl", 50, "max hero level")
startAtk = flag.Int("startAtk", 10, "max hero level")
)
flag.Parse()
effectiveStrength := 1
baseAtk := *startAtk
for i := 1; i <= *maxLvl; i++ {
if i%2 == 0 {
effectiveStrength++
}
if i%3 == 0 {
baseAtk++
}
//multb := 1 - float64(effectiveStrength*(1 - i/(i+1)))/(float64(effectiveStrength+100))
//mult1 := 1 + (1 - 0.15*math.Pow(float64(effectiveStrength), 0.1))
mult2 := 1 + math.Exp(-0.05*float64(effectiveStrength-1))
mult1 := 1.1 - 0.002*math.Log(float64(effectiveStrength))
effectiveAtk1 := int(math.Round(float64(baseAtk + *gearAtk) * mult1))
effectiveAtk2 := int(math.Round(float64(baseAtk + *gearAtk) * mult2))
fmt.Println(fmt.Sprintf("Atk1 %d Atk2 %d base %d gear %d Lvl %d Str %d Mult %.3f, %.3f", effectiveAtk1, effectiveAtk2, baseAtk, *gearAtk, i, effectiveStrength, mult1, mult2))
}
}

@ -0,0 +1,11 @@
package constants
const (
OfflineAutoPotionChance = 0.10
OfflineAutoPotionHPThresh = 0.60
// CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests).
CombatSimMaxStepsDefault = 200_000
// CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early.
CombatSimMaxStepsLong = 3_000_000
)

@ -5,6 +5,7 @@ import (
"sort"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/model"
)
@ -63,7 +64,7 @@ func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile Ref
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
if survived {
wins++

@ -13,6 +13,7 @@ const (
attackOutcomeDodge = "dodge"
attackOutcomeBlock = "block"
attackOutcomeStun = "stun"
attackOutcomeHeal = "heal"
)
type DamageBreakdown struct {

@ -11,6 +11,8 @@ func combatLogPhraseKey(source, outcome string) string {
return model.LogPhraseCombatHeroStun
case attackOutcomeDodge:
return model.LogPhraseCombatHeroDodge
case attackOutcomeHeal:
return model.LogPhraseCombatHeroHeal
default:
return model.LogPhraseCombatHeroHit
}

@ -1,22 +1,12 @@
package game
import (
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/constants"
)
const (
offlineAutoPotionChance = 0.02
offlineAutoPotionHPThresh = 0.40
// CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests).
CombatSimMaxStepsDefault = 200_000
// CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early.
CombatSimMaxStepsLong = 3_000_000
)
// CombatSimDeterministicStart is the fixed combat timeline origin for balance tools and admin sim parity (avoids wall-clock drift in tests).
var CombatSimDeterministicStart = time.Unix(1_700_000_000, 0)
@ -27,7 +17,7 @@ type CombatSimOptions struct {
TickRate time.Duration
// AutoUsePotion decides whether to consume a potion after damage ticks/attacks.
// It should return true when a potion was used.
AutoUsePotion func(hero *model.Hero, now time.Time) bool
AutoUsePotion func(hero *model.Hero, force bool) int
// WallClockDelay adds optional real-time delay between simulation steps.
// 0 means instant simulation (default).
WallClockDelay time.Duration
@ -69,7 +59,7 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
step := 0
maxSteps := opts.MaxSteps
if maxSteps <= 0 {
maxSteps = CombatSimMaxStepsDefault
maxSteps = constants.CombatSimMaxStepsDefault
}
for step < maxSteps {
@ -144,7 +134,7 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
return false, now.Sub(start)
}
if opts.AutoUsePotion != nil {
_ = opts.AutoUsePotion(hero, now)
_ = opts.AutoUsePotion(hero, false)
}
enemyNext = now.Add(attackIntervalEnemy(enemy.Speed))
}
@ -166,26 +156,4 @@ func simStepDelay(opts CombatSimOptions) {
}
}
// OfflineAutoPotionHook is a low-probability offline-only potion usage policy.
func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool {
if hero == nil || hero.Potions <= 0 || hero.HP <= 0 {
return false
}
hpThresh := int(float64(hero.MaxHP) * offlineAutoPotionHPThresh)
if hero.HP >= hpThresh {
return false
}
if rand.Float64() >= offlineAutoPotionChance {
return false
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
return true
}

@ -9,6 +9,7 @@ import (
"sync"
"time"
hero_actions "github.com/denisovdennis/autohero/internal/hero"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
@ -614,11 +615,6 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
hero := hm.Hero
// Validate: hero is in combat, has potions, is alive.
if hm.State != model.StateFighting {
e.sendError(msg.HeroID, "not_fighting", "hero is not in combat")
return
}
if hero.Potions <= 0 {
e.sendError(msg.HeroID, "no_potions", "no potions available")
return
@ -628,15 +624,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
return
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
healAmount := hero_actions.UsePotionOnHero(hero, true)
hm.SyncToHero()
@ -653,7 +641,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
}
if e.adventureLog != nil {
if e.adventureLog != nil && healAmount > 0 {
e.adventureLog(msg.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseUsedHealingPotion,
@ -676,8 +664,6 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
HeroHP: hero.HP,
EnemyHP: enemyHP,
})
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now())
e.sender.SendToHero(msg.HeroID, "hero_state", hero)
}
}
@ -1619,6 +1605,8 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
dotDmg := ProcessDebuffDamage(cs.Hero, tickDur, now)
regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder)
summonDmg := ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
cs.LastTickAt = now
if e.sender != nil {
if dotDmg > 0 {
@ -1643,6 +1631,7 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
EnemyHP: cs.Enemy.HP,
})
}
}
if CheckDeath(cs.Hero, now) {
@ -1667,7 +1656,6 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
}
}
// Process all attacks that are due.
for e.queue.Len() > 0 {
next := e.queue[0]
if next.NextAttackAt.After(now) {
@ -1766,11 +1754,36 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
}
combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now)
healAmount := hero_actions.UsePotionOnHero(cs.Hero, false)
// Process all attacks that are due.
e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt)
// Push attack envelope.
if e.sender != nil {
if healAmount > 0 {
e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
Source: "potion",
Damage: -healAmount, // negative = heal
HeroHP: cs.Hero.HP,
EnemyHP: cs.Enemy.HP,
})
usePotionEvent := model.CombatEvent{
Type: "attack",
HeroID: cs.HeroID,
Damage: healAmount,
Source: "potion",
Outcome: attackOutcomeHeal,
HeroHP: cs.Hero.HP,
EnemyHP: cs.Enemy.HP,
Timestamp: now,
}
e.emitEvent(usePotionEvent)
e.logUsePotion(cs, usePotionEvent)
}
e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
Source: combatEvt.Source,
Damage: combatEvt.Damage,
@ -1883,6 +1896,20 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
})
}
func (e *Engine) logUsePotion(cs *model.CombatState, evt model.CombatEvent) {
if e.adventureLog == nil || cs == nil {
return
}
e.adventureLog(cs.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseUsedHealingPotion,
Args: map[string]any{"amount": evt.Damage}},
})
}
func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
hero := cs.Hero
enemy := &cs.Enemy

@ -8,6 +8,7 @@ import (
"math/rand"
"time"
hero_actions "github.com/denisovdennis/autohero/internal/hero"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
@ -456,7 +457,7 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{
TickRate: tickRate,
AutoUsePotion: OfflineAutoPotionHook,
AutoUsePotion: hero_actions.UsePotionOnHero,
})
if !survived || hero.HP <= 0 {

@ -124,7 +124,7 @@ func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) {
HP: 100,
Potions: 3,
}
if used := OfflineAutoPotionHook(hero, time.Now()); used {
if used := OfflineAutoPotionHook(hero); used {
t.Fatal("expected no potion usage when hero is above threshold")
}
if hero.Potions != 3 {

@ -7,6 +7,7 @@ import (
"sort"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/model"
)
@ -368,7 +369,7 @@ func estimateLevelUpSeconds(heroLevel int, p ProgressionSimParams) (seconds floa
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
})
cycle := elapsed.Seconds() + p.RestAfterCombat.Seconds()
xp := float64(enemy.XPReward)

@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@ -2976,7 +2977,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
opts := game.CombatSimOptions{
TickRate: tickRate,
WallClockDelay: wallClockDelay,
MaxSteps: game.CombatSimMaxStepsLong,
MaxSteps: constants.CombatSimMaxStepsLong,
OnEvent: func(evt model.CombatEvent) {
if len(events) < maxEvents {
events = append(events, evt)

@ -13,6 +13,7 @@ import (
"time"
"unicode/utf8"
hero_actions "github.com/denisovdennis/autohero/internal/hero"
"github.com/go-chi/chi/v5"
"golang.org/x/text/unicode/norm"
@ -1465,15 +1466,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
}
// Heal 30% of maxHP, capped at maxHP.
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
hero.Potions--
healAmount := hero_actions.UsePotionOnHero(hero,true)
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)

@ -0,0 +1,35 @@
package hero
import (
"math/rand"
"github.com/denisovdennis/autohero/internal/constants"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
func UsePotionOnHero(hero *model.Hero, force bool) int {
if force != true {
if hero == nil || hero.Potions <= 0 || hero.HP <= 0 {
return 0
}
hpThresh := int(float64(hero.MaxHP) * constants.OfflineAutoPotionHPThresh)
if hero.HP >= hpThresh {
return 0
}
if rand.Float64() >= (1.0 - (1.0 - float64(hero.HP / hpThresh))) / 1.2 + constants.OfflineAutoPotionChance {
return 0
}
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
return healAmount;
}

@ -37,6 +37,7 @@ const (
LogPhraseQuestAccepted = "log.quest_accepted"
LogPhraseCombatHeroHit = "log.combat.hero_hit"
LogPhraseCombatHeroDodge = "log.combat.hero_dodge"
LogPhraseCombatHeroHeal = "log.combat.hero_heal"
LogPhraseCombatHeroStun = "log.combat.hero_stun"
LogPhraseCombatEnemyHit = "log.combat.enemy_hit"
LogPhraseCombatEnemyBlock = "log.combat.enemy_block"

@ -75,7 +75,7 @@ func (q *AttackQueue) Pop() any {
// CombatEvent is broadcast over WebSocket to clients observing combat.
type CombatEvent struct {
Type string `json:"type"` // "attack", "death", "buff_applied", "debuff_applied", "combat_start", "combat_end"
Type string `json:"type"` // "heal", "attack", "death", "buff_applied", "debuff_applied", "combat_start", "combat_end"
HeroID int64 `json:"heroId"`
Damage int `json:"damage,omitempty"`
Source string `json:"source"` // "hero" or "enemy"

@ -261,13 +261,9 @@ func (h *Hero) EffectiveAttack() int {
func (h *Hero) EffectiveAttackAt(now time.Time) int {
bonuses := h.activeStatBonuses(now)
effectiveStrength := h.Strength + bonuses.strengthBonus
effectiveAgility := h.Agility + bonuses.agilityBonus
if chest := h.Gear[SlotChest]; chest != nil {
effectiveAgility += chest.AgilityBonus
}
gearAttack, _ := h.gearPrimaryBonuses()
atk := h.Attack + effectiveStrength*2 + effectiveAgility/4 + gearAttack
mult := tuning.GetMultiplierByStrength(effectiveStrength)
atk := int(math.Round(float64(h.Attack + gearAttack) * mult))
atkF := float64(atk)
atkF *= bonuses.attackMultiplier
if atkF < 1 {

@ -0,0 +1,7 @@
package tuning
import "math"
func GetMultiplierByStrength(str int) float64 {
return 1.1 - 0.002 * math.Log(float64(str))
}

@ -323,7 +323,7 @@ func DefaultValues() Values {
MerchantCostBase: 900,
MerchantCostPerLevel: 5,
MerchantTownAutoSellShare: 0.30,
MonsterEncounterWeightBase: 0.62,
MonsterEncounterWeightBase: 0.15,
MonsterEncounterWeightWildBonus: 0.18,
MerchantEncounterWeightBase: 0.02,
MerchantEncounterWeightRoadBonus: 0.05,

@ -218,3 +218,5 @@ INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (218, 'shade_l19_20_swamp', 'shade', 'swamp', 'Bog Cursed Shade', 212, 212, 45, 3, 1.0000, 0.0500, 19, 20, 27, 1, ARRAY['slow','dodge']::text[], false, now(), 19, 0.3, 5, 8.4320, 3.0680, 1.4950, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (219, 'shade_l21_22_volcanic', 'shade', 'volcanic', 'Rogue Ember Shade', 200, 200, 43, 3, 1.0100, 0.0500, 21, 22, 29, 1, ARRAY['slow']::text[], false, now(), 21, 0.3, 5, 8.9760, 3.2240, 1.5600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (220, 'shade_l21_22_astral', 'shade', 'astral', 'Astral Rogue Shade', 221, 221, 47, 3, 1.0100, 0.0500, 21, 22, 30, 1, ARRAY['slow','dodge']::text[], false, now(), 21, 0.3, 5, 8.9760, 3.2240, 1.5600, 2.0, 1.2);
UPDATE public.enemies SET max_hero_level_diff = 2;

@ -20,7 +20,8 @@ RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder
# Vite dist: index.html, hashed chunks, public files (e.g. assets/game/manifest.json),
# and PNGs under assets/tiles|enemies|prop|building|characters|obj (see vite.config assetFileNames).
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

@ -11,6 +11,12 @@ server {
root /usr/share/nginx/html;
index index.html;
location = /assets/game/manifest.json {
default_type application/json;
add_header Cache-Control "public, max-age=300";
try_files $uri =404;
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
@ -45,7 +51,7 @@ server {
proxy_read_timeout 86400;
}
# Cache static assets aggressively
# Hashed build assets (Vite); safe to cache long-term. Excludes manifest (exact location above).
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
@ -73,6 +79,12 @@ server {
root /usr/share/nginx/html;
index index.html;
location = /assets/game/manifest.json {
default_type application/json;
add_header Cache-Control "public, max-age=300";
try_files $uri =404;
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
@ -107,7 +119,6 @@ server {
proxy_read_timeout 86400;
}
# Cache static assets aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";

@ -11,6 +11,7 @@
"pixi.js": "^8.6.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"ts-pattern": "^5.9.0",
"yaml": "^2.7.0"
},
"devDependencies": {
@ -2808,6 +2809,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ts-pattern": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz",
"integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==",
"license": "MIT"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

@ -14,6 +14,7 @@
"pixi.js": "^8.6.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"ts-pattern": "^5.9.0",
"yaml": "^2.7.0"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { match } from 'ts-pattern';
import { GameEngine } from './game/engine';
import {
GamePhase,
@ -83,7 +84,7 @@ import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels';
import type { AdventureLogLinePayload } from './game/types';
import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen';
import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay';
import {CombatOverlay, type CombatOverlayEvent, CombatOverlayTarget} from './ui/CombatOverlay';
import { CombatLogPanel } from './ui/CombatLogPanel';
import { GameToast } from './ui/GameToast';
import { OfflineReport } from './ui/OfflineReport';
@ -569,15 +570,19 @@ export function App() {
}, []);
const handleCombatAttack = useCallback((p: AttackPayload) => {
if (p.source === 'potion') {
return;
}
const defender: CombatOverlayEvent['target'] = match(p.source)
.returnType<CombatOverlayTarget>()
.with('enemy', () => 'hero')
.with('hero', () => 'enemy')
.with('potion', () => 'hero')
.with('dot', () => 'hero')
.with('summon', () => 'hero')
.exhaustive()
const defender: CombatOverlayEvent['target'] =
p.source === 'enemy' ? 'hero' : 'enemy';
const isBlocked = p.outcome === 'block';
const isEvaded = p.outcome === 'dodge';
const isStunned = p.outcome === 'stun';
const isHealed = p.outcome === 'heal';
const isCrit = Boolean(p.isCrit);
if (isStunned) {
@ -596,6 +601,15 @@ export function App() {
value: 0,
createdAt: performance.now(),
});
} else if (isHealed) {
appendCombatEvent({
id: Date.now() + Math.random(),
kind: 'heal',
target: defender,
value: p.damage,
isCrit,
createdAt: performance.now(),
});
} else {
appendCombatEvent({
id: Date.now() + Math.random(),

@ -11,25 +11,6 @@ export class GameSpriteRegistry {
private _textures = new Map<string, Texture>();
private _ready = false;
private _buildFallbackManifest(keys: string[]): GameTextureManifest {
const textures: Record<string, { file: string; kind: string }> = {};
for (const key of keys) {
let file: string | null = null;
if (key.startsWith('terrain.')) file = `tiles/${key}.png`;
else if (key.startsWith('prop.')) file = `prop/${key}.png`;
else if (key.startsWith('building.')) file = `building/${key}.png`;
else if (key.startsWith('enemy.')) file = `enemies/${key}.png`;
else if (key.startsWith('npc.') || key.startsWith('hero.')) file = `characters/${key}.png`;
if (!file) continue;
textures[key] = { file, kind: 'fallback' };
}
return {
version: 0,
note: 'Fallback manifest (generated at runtime).',
textures,
};
}
get ready(): boolean {
return this._ready;
}
@ -40,12 +21,7 @@ export class GameSpriteRegistry {
async loadAll(): Promise<void> {
const requiredKeys = getRequiredSpriteKeys();
try {
this._manifest = await fetchGameTextureManifest();
} catch (error) {
console.warn('[Assets] Manifest load failed, using fallback manifest.', error);
this._manifest = this._buildFallbackManifest(requiredKeys);
}
for (const key of requiredKeys) {
if (!this._manifest.textures[key]) {
console.warn(`[Assets] Missing manifest entry for sprite key: ${key}`);

@ -3,7 +3,11 @@
* Keep in sync with [public/assets/game/manifest.json](../../../../public/assets/game/manifest.json).
*/
const raw = import.meta.glob<string>('../../../assets/**/*.png', { eager: true, as: 'url' });
const raw = import.meta.glob<string>('../../../assets/**/*.png', {
eager: true,
query: '?url',
import: 'default',
});
function toManifestRelativePath(globModulePath: string): string {
const n = globModulePath.replace(/\\/g, '/');
@ -56,21 +60,22 @@ async function fetchManifestJson(resolvedUrl: string): Promise<GameTextureManife
const res = await fetch(resolvedUrl);
if (!res.ok) throw new Error(`Game manifest fetch failed: ${res.status}`);
const manifest = (await res.json()) as GameTextureManifest;
if (!manifest || !manifest.textures) {
throw new Error('Game manifest missing textures');
const textures = manifest?.textures;
if (
!manifest ||
textures === null ||
typeof textures !== 'object' ||
Array.isArray(textures) ||
Object.keys(textures).length === 0
) {
throw new Error('Game manifest missing or empty textures');
}
return manifest;
}
export async function fetchGameTextureManifest(url?: string): Promise<GameTextureManifest> {
const baseUrl = import.meta.env.BASE_URL ?? '/';
const resolvedUrl = url ?? `${baseUrl}assets/game/manifest.json`;
try {
return await fetchManifestJson(resolvedUrl);
} catch (error) {
if (resolvedUrl !== '/assets/game/manifest.json') {
return fetchManifestJson('/assets/game/manifest.json');
}
throw error;
}
const base = import.meta.env.BASE_URL ?? '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const resolvedUrl = url ?? `${normalizedBase}assets/game/manifest.json?version=82`;
return fetchManifestJson(resolvedUrl);
}

@ -139,8 +139,10 @@ export function resolveEnemySouthTextureKey(
): string | null {
const norm = normalizeEnemyTemplateSlug(slug);
const trySlug = (templateSlug: string): string | null => {
const k = `enemy.${templateSlug}.south`;
return getTexture(k) != null ? k : null;
const withSouth = `enemy.${templateSlug}.south`;
if (getTexture(withSouth) != null) return withSouth;
const bare = `enemy.${templateSlug}`;
return getTexture(bare) != null ? bare : null;
};
const primary = trySlug(norm);

@ -615,7 +615,7 @@ function tweakVisualForSlug(base: EnemyVisualConfig, slug: string): EnemyVisualC
const h2 = (Math.imul(h, 0x9e3779b1) >>> 0) ^ slug.length;
const bodyShape = BODY_SHAPE_ORDER[Math.abs(h) % BODY_SHAPE_ORDER.length] ?? base.bodyShape;
const headShape = HEAD_SHAPE_ORDER[Math.abs(h2) % HEAD_SHAPE_ORDER.length] ?? base.headShape;
const sizeMul = 0.86 + ((h ^ h2) % 29) / 100;
const sizeMul = 1.5 + ((h ^ h2) % 29) / 100;
return {
...base,
bodyShape,

@ -578,7 +578,7 @@ export class GameEngine {
isCrit: boolean,
heroHp: number,
enemyHp: number,
outcome?: 'hit' | 'dodge' | 'block' | 'stun',
outcome?: 'hit' | 'dodge' | 'block' | 'stun' | 'heal',
): void {
void source;
void damage;
@ -770,17 +770,6 @@ export class GameEngine {
this._notifyStateChange();
}
/** Replace debuffs from server snapshot. */
patchHeroDebuffs(debuffs: HeroState['debuffs']): void {
const hero = this._gameState.hero;
if (!hero) return;
this._gameState = {
...this._gameState,
hero: { ...hero, debuffs },
};
this._notifyStateChange();
}
/** Apply or refresh a single debuff from WS. */
applyDebuffApplied(type: DebuffType, durationMs: number, expiresAtMs?: number): void {
const hero = this._gameState.hero;
@ -813,16 +802,6 @@ export class GameEngine {
this._notifyStateChange();
}
/** Apply a full server state override (used for backward compat). */
applyServerState(state: GameState): void {
this._gameState = {
...state,
lastVictoryLoot:
state.lastVictoryLoot ?? this._gameState.lastVictoryLoot,
};
this._notifyStateChange();
}
// ---- Lifecycle ----
/** Initialize the engine and attach to the DOM */
@ -1094,10 +1073,10 @@ export class GameEngine {
this.renderer.drawEnemy(
state.enemy.position.x,
state.enemy.position.y,
state.enemy.hp,
state.enemy.maxHp,
// state.enemy.hp,
// state.enemy.maxHp,
state.enemy.enemySlug,
state.enemy.enemyArchetype,
// state.enemy.enemyArchetype,
now,
);
} else {

File diff suppressed because it is too large Load Diff

@ -570,7 +570,7 @@ export interface AttackPayload {
source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon';
damage: number;
isCrit?: boolean;
outcome?: 'hit' | 'dodge' | 'block' | 'stun';
outcome?: 'hit' | 'dodge' | 'block' | 'stun' | 'heal';
heroHp: number;
enemyHp: number;
debuffApplied?: string;

@ -5,7 +5,7 @@ const MAX_VISIBLE_LINES = 5;
const panelBase: CSSProperties = {
position: 'absolute',
top: '26%',
bottom: '26%',
zIndex: 50,
maxWidth: 200,
padding: '8px 10px',

@ -1,7 +1,7 @@
import { useMemo, type CSSProperties } from 'react';
import { getViewport } from '../shared/telegram';
export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned';
export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned' | 'heal';
export type CombatOverlayTarget = 'hero' | 'enemy';
export interface CombatOverlayEvent {
@ -13,9 +13,10 @@ export interface CombatOverlayEvent {
createdAt: number;
}
const FLOAT_DURATION_MS = 2600;
const FLOAT_DURATION_MS = 6000;
const FEEDBACK_DURATION_MS = 4800;
const CRIT_DURATION_MS = 6000;
const HEAL_DURATION_MS = 7000;
const FLOAT_RISE_PX = 96;
const FLOAT_DRIFT_PX = 44;
@ -44,6 +45,9 @@ function durationMs(evt: CombatOverlayEvent): number {
if (evt.kind === 'damage' && evt.isCrit) {
return CRIT_DURATION_MS;
}
if (evt.kind === 'heal') {
return HEAL_DURATION_MS;
}
return FLOAT_DURATION_MS;
}
@ -57,11 +61,17 @@ function overlayText(evt: CombatOverlayEvent): string {
}
if (evt.kind === 'blocked') return 'BLOCKED';
if (evt.kind === 'evaded') return 'EVADED';
if (evt.kind === 'heal') return `HEAL ${Math.round(evt.value)}`;
return 'STUNNED';
}
function overlayColor(evt: CombatOverlayEvent): string {
if (evt.kind === 'regen') return '#44dd66';
if (evt.kind === 'regen') {
return evt.target === 'hero' ? '#44dd66' : '#ff5566';
}
if (evt.kind === 'heal') {
return '#44dd66';
}
if (evt.kind === 'stunned') return '#ffaa44';
if (evt.kind === 'blocked' || evt.kind === 'evaded') {
return evt.target === 'hero' ? '#44dd66' : '#ff5566';
@ -86,6 +96,7 @@ export function CombatOverlay({ events, onExpire }: CombatOverlayProps) {
const yMid = viewport.height / 2 - 42;
return {
hero: { x: xHeroSide, y: yMid, driftDir: -1 },
potion: { x: xHeroSide, y: yMid, driftDir: -1 },
enemy: { x: xEnemySide, y: yMid, driftDir: 1 },
};
}, [viewport.height, viewport.width]);

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { buildWorldTerrainContext, proceduralTerrain, townsApiToInfluences } from '../game/procedural';
import type { Town } from '../game/types';
import { useT } from '../i18n';
@ -15,15 +15,30 @@ interface MinimapProps {
/** 0 = свернуто, 1 = маленькая, 2 = большая */
type MapMode = 0 | 1 | 2;
/** Cached terrain layer (viewport-sized); rebuilt only when view in world space meaningfully shifts */
type TerrainSliceCache = {
w: number;
h: number;
centerX: number;
centerY: number;
worldKey: string;
/** Offscreen terrain only (no UI) */
canvas: HTMLCanvasElement;
};
// ---- Constants ----
const SIZE_SMALL = 120;
const SIZE_LARGE = 240;
/** Each pixel represents this many world units */
const WORLD_UNITS_PER_PX = 10;
/** Only redraw when hero has moved at least this many world units */
const REDRAW_THRESHOLD = 5;
/** World units per minimap pixel (10× coarser than legacy 10) */
const WORLD_UNITS_PER_PX = 100;
/**
* Repaint terrain when hero moved this many world units since last bake.
* ~0.5 minimap pixel so sub-pixel pan stays cheap; full terrain bake is the hotspot.
*/
const TERRAIN_REBAKE_THRESHOLD = WORLD_UNITS_PER_PX * 0.45;
const TERRAIN_BG: Record<string, string> = {
grass: '#3a7a28',
@ -103,73 +118,163 @@ function modeLabel(mode: MapMode, mapStr: string): string {
return `${mapStr} L`;
}
function bakeTerrainSlice(
w: number,
h: number,
centerX: number,
centerY: number,
miniCtx: ReturnType<typeof buildWorldTerrainContext> | null,
): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
const cx = w / 2;
const cy = h / 2;
const scale = WORLD_UNITS_PER_PX;
if (!miniCtx || miniCtx.towns.length === 0) {
const terrain = proceduralTerrain(Math.floor(centerX), Math.floor(centerY), null);
ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG;
ctx.fillRect(0, 0, w, h);
return canvas;
}
const img = ctx.createImageData(w, h);
const data = img.data;
let q = 0;
for (let py = 0; py < h; py++) {
const wy = Math.floor(centerY + (py - cy) * scale);
for (let px = 0; px < w; px++) {
const wx = Math.floor(centerX + (px - cx) * scale);
const terrain = proceduralTerrain(wx, wy, miniCtx);
const rgb = TERRAIN_RGB.get(terrain) ?? DEFAULT_RGB;
data[q++] = rgb[0];
data[q++] = rgb[1];
data[q++] = rgb[2];
data[q++] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas;
}
function terrainCacheNeedsRebake(
cache: TerrainSliceCache | null,
w: number,
h: number,
heroX: number,
heroY: number,
worldKey: string,
): boolean {
if (!cache) return true;
if (cache.w !== w || cache.h !== h) return true;
if (cache.worldKey !== worldKey) return true;
if (
Math.abs(heroX - cache.centerX) >= TERRAIN_REBAKE_THRESHOLD ||
Math.abs(heroY - cache.centerY) >= TERRAIN_REBAKE_THRESHOLD
) {
return true;
}
return false;
}
/** Sample baked terrain while scrolling so the hero stays centered between rebakes */
function drawTerrainFromCache(
ctx: CanvasRenderingContext2D,
cache: TerrainSliceCache,
heroX: number,
heroY: number,
dw: number,
dh: number,
): void {
const u = WORLD_UNITS_PER_PX;
const { centerX: tcx, centerY: tcy, canvas: off } = cache;
const ow = off.width;
const oh = off.height;
ctx.fillStyle = DEFAULT_BG;
ctx.fillRect(0, 0, dw, dh);
const sx = (heroX - tcx) / u;
const sy = (heroY - tcy) / u;
const srcX0 = Math.max(0, Math.floor(sx));
const srcY0 = Math.max(0, Math.floor(sy));
const srcX1 = Math.min(ow, Math.ceil(sx + dw));
const srcY1 = Math.min(oh, Math.ceil(sy + dh));
if (srcX0 < srcX1 && srcY0 < srcY1) {
const destX = srcX0 - sx;
const destY = srcY0 - sy;
const iw = srcX1 - srcX0;
const ih = srcY1 - srcY0;
ctx.drawImage(off, srcX0, srcY0, iw, ih, destX, destY, iw, ih);
}
}
// ---- Component ----
export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
const tr = useT();
const [mode, setMode] = useState<MapMode>(1);
const canvasRef = useRef<HTMLCanvasElement>(null);
const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN });
const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null);
const lastRouteKey = useRef<string>('');
const lastTownsKey = useRef<string>('');
const terrainCacheRef = useRef<TerrainSliceCache | null>(null);
const size = mode === 2 ? SIZE_LARGE : SIZE_SMALL;
const collapsed = mode === 0;
useEffect(() => {
if (!collapsed) {
lastDrawPos.current = { x: NaN, y: NaN };
lastHeroTile.current = null;
lastRouteKey.current = '';
lastTownsKey.current = '';
}
}, [collapsed, size]);
useEffect(() => {
if (collapsed) return;
const routeKey =
const routeKey = useMemo(
() =>
routeWaypoints && routeWaypoints.length >= 2
? routeWaypoints.map((p) => `${p.x},${p.y}`).join(';')
: '';
const routeChanged = routeKey !== lastRouteKey.current;
: '',
[routeWaypoints],
);
const townsKey =
const townsKey = useMemo(
() =>
towns.length === 0
? ''
: towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}`).join(';');
const townsChanged = townsKey !== lastTownsKey.current;
const tileX = Math.floor(heroX);
const tileY = Math.floor(heroY);
const last = lastDrawPos.current;
const dx = Math.abs(heroX - last.x);
const dy = Math.abs(heroY - last.y);
const lt = lastHeroTile.current;
const tileChanged = !lt || lt.tx !== tileX || lt.ty !== tileY;
if (
!routeChanged &&
!townsChanged &&
!tileChanged &&
dx < REDRAW_THRESHOLD &&
dy < REDRAW_THRESHOLD
) {
return;
}
lastRouteKey.current = routeKey;
lastTownsKey.current = townsKey;
: towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}:${t.radius}:${t.biome}:${t.levelMin}`).join('|'),
[towns],
);
const worldKey = `${townsKey}#${routeKey}`;
const miniCtx = useMemo(() => {
if (towns.length === 0) return null;
const activeRoute =
routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null;
return buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute);
}, [worldKey, towns, routeWaypoints]);
useEffect(() => {
if (collapsed) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
lastDrawPos.current = { x: heroX, y: heroY };
lastHeroTile.current = { tx: tileX, ty: tileY };
const w = canvas.width;
const h = canvas.height;
let cache = terrainCacheRef.current;
if (terrainCacheNeedsRebake(cache, w, h, heroX, heroY, worldKey)) {
cache = {
w,
h,
centerX: heroX,
centerY: heroY,
worldKey,
canvas: bakeTerrainSlice(w, h, heroX, heroY, miniCtx),
};
terrainCacheRef.current = cache;
}
drawTerrainFromCache(ctx, cache!, heroX, heroY, w, h);
const cx = w / 2;
const cy = h / 2;
const minDim = Math.min(w, h);
@ -180,51 +285,26 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
const heroR = Math.max(3, Math.round(minDim * 0.035));
const glowR = Math.max(8, Math.round(minDim * 0.09));
const activeRoute =
routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null;
const miniCtx =
towns.length === 0
? null
: buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute);
if (!miniCtx || miniCtx.towns.length === 0) {
const terrain = proceduralTerrain(tileX, tileY, null);
ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG;
ctx.fillRect(0, 0, w, h);
} else {
const img = ctx.createImageData(w, h);
const data = img.data;
let q = 0;
const scale = WORLD_UNITS_PER_PX;
for (let py = 0; py < h; py++) {
const wy = Math.floor(heroY + (py - cy) * scale);
for (let px = 0; px < w; px++) {
const wx = Math.floor(heroX + (px - cx) * scale);
const terrain = proceduralTerrain(wx, wy, miniCtx);
const rgb = TERRAIN_RGB.get(terrain) ?? DEFAULT_RGB;
data[q++] = rgb[0];
data[q++] = rgb[1];
data[q++] = rgb[2];
data[q++] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
const unitsPerPx = WORLD_UNITS_PER_PX;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.lineWidth = 0.5;
for (let gx = 0; gx < w; gx += gridStep) {
ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx, 0);
ctx.lineTo(gx, h);
ctx.stroke();
}
for (let gy = 0; gy < h; gy += gridStep) {
ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(w, gy);
ctx.stroke();
}
for (const town of towns) {
const relX = (town.worldX - heroX) / WORLD_UNITS_PER_PX;
const relY = (town.worldY - heroY) / WORLD_UNITS_PER_PX;
let px = cx + relX;
let py = cy + relY;
let px = cx + (town.worldX - heroX) / unitsPerPx;
let py = cy + (town.worldY - heroY) / unitsPerPx;
const clamped =
px < margin || px > w - margin || py < margin || py > h - margin;
@ -267,7 +347,7 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.stroke();
}, [heroX, heroY, towns, routeWaypoints, collapsed, size]);
}, [heroX, heroY, towns, collapsed, size, worldKey, miniCtx]);
return (
<div style={containerStyle}>

File diff suppressed because one or more lines are too long

@ -1,5 +1,35 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
/**
* Keep emitted PNG paths aligned with manifest `file` (relative to repo `frontend/assets/`).
* Default Vite/Rollup naming flattens everything into `dist/assets/*.png`, which breaks
* inspection of the image and confuses deployment checks.
*/
function manifestRelativeAssetStem(assetInfo) {
const candidates = [
...(assetInfo.originalFileNames ?? []),
assetInfo.originalFileName,
...(assetInfo.names ?? []),
].filter((s) => typeof s === 'string' && s.length > 0);
for (const ofn of candidates) {
const n = ofn.replace(/\\/g, '/');
const marker = '/assets/';
const i = n.indexOf(marker);
if (i !== -1) {
const rel = n.slice(i + marker.length).replace(/\.png$/i, '');
if (rel)
return rel;
}
const parts = n.split('/');
const ai = parts.lastIndexOf('assets');
if (ai !== -1 && parts[ai + 1]) {
const rel = parts.slice(ai + 1).join('/').replace(/\.png$/i, '');
if (rel)
return rel;
}
}
return undefined;
}
export default defineConfig({
plugins: [react()],
resolve: {
@ -31,6 +61,15 @@ export default defineConfig({
pixi: ['pixi.js'],
react: ['react', 'react-dom'],
},
assetFileNames(assetInfo) {
if (assetInfo.names?.some((n) => n.endsWith('.png'))) {
const stem = manifestRelativeAssetStem(assetInfo);
if (stem) {
return `assets/${stem}-[hash][extname]`;
}
}
return 'assets/[name]-[hash][extname]';
},
},
},
},

@ -1,5 +1,36 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import type { PreRenderedAsset } from 'rollup';
/**
* Keep emitted PNG paths aligned with manifest `file` (relative to repo `frontend/assets/`).
* Default Vite/Rollup naming flattens everything into `dist/assets/*.png`, which breaks
* inspection of the image and confuses deployment checks.
*/
function manifestRelativeAssetStem(assetInfo: PreRenderedAsset): string | undefined {
const candidates = [
...(assetInfo.originalFileNames ?? []),
assetInfo.originalFileName,
...(assetInfo.names ?? []),
].filter((s): s is string => typeof s === 'string' && s.length > 0);
for (const ofn of candidates) {
const n = ofn.replace(/\\/g, '/');
const marker = '/assets/';
const i = n.indexOf(marker);
if (i !== -1) {
const rel = n.slice(i + marker.length).replace(/\.png$/i, '');
if (rel) return rel;
}
const parts = n.split('/');
const ai = parts.lastIndexOf('assets');
if (ai !== -1 && parts[ai + 1]) {
const rel = parts.slice(ai + 1).join('/').replace(/\.png$/i, '');
if (rel) return rel;
}
}
return undefined;
}
export default defineConfig({
plugins: [react()],
@ -32,6 +63,15 @@ export default defineConfig({
pixi: ['pixi.js'],
react: ['react', 'react-dom'],
},
assetFileNames(assetInfo) {
if (assetInfo.names?.some((n) => n.endsWith('.png'))) {
const stem = manifestRelativeAssetStem(assetInfo);
if (stem) {
return `assets/${stem}-[hash][extname]`;
}
}
return 'assets/[name]-[hash][extname]';
},
},
},
},

@ -1,5 +1,6 @@
/**
* One-shot: fill every manifest enemy.<slug>.south missing pixellabObjectId (PixelLab API v2).
* One-shot: fill every south-facing manifest enemy entry missing pixellabObjectId (PixelLab API v2).
* Keys: legacy `enemy.<slug>.south` or `enemy.<slug>` with rotation/file `*.south.png`.
* Requires PIXELLAB_API_TOKEN.
*
* node scripts/pixellab-fill-missing-south-one-shot.mjs
@ -158,9 +159,16 @@ async function runPool(concurrency, items, fn) {
await Promise.all(workers);
}
/** `enemy.<type_slug>` or legacy `enemy.<type_slug>.south` → DB `enemies.type` slug */
function textureKeyToEnemyTypeSlug(texKey) {
let rest = texKey.startsWith('enemy.') ? texKey.slice('enemy.'.length) : texKey;
if (rest.endsWith('.south')) rest = rest.slice(0, -'.south'.length);
return rest;
}
/** @returns {{ slug: string, body: object } | { error: string }} */
function prepareCreateBody(texKey, textures, byType) {
const slug = texKey.slice('enemy.'.length, -'.south'.length);
const slug = textureKeyToEnemyTypeSlug(texKey);
const row = byType.get(slug);
if (!row) return { error: `No SQL row ${slug}` };
const refKey = ARCHETYPE_REF[row.archetype];
@ -243,7 +251,14 @@ async function main() {
const { textures } = manifest;
const missing = Object.keys(textures)
.filter((k) => k.startsWith('enemy.') && k.endsWith('.south') && !textures[k].pixellabObjectId)
.filter((k) => {
if (!k.startsWith('enemy.') || textures[k].pixellabObjectId) return false;
if (k.endsWith('.south')) return true;
const e = textures[k];
if (e.rotation === 'south') return true;
const f = e.file || '';
return f.endsWith('.south.png');
})
.sort();
if (missing.length === 0) {

Loading…
Cancel
Save