From 8ecaf3895ab686f5f68d235650033277238fa833 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 30 Mar 2026 17:47:27 +0300 Subject: [PATCH] fix --- backend/internal/game/movement.go | 37 +++- backend/internal/handler/auth.go | 20 ++- backend/internal/handler/game.go | 44 ++++- backend/internal/storage/hero_store.go | 231 +++++++++++++++++++------ frontend/src/App.tsx | 50 +++++- frontend/src/network/api.ts | 4 +- 6 files changed, 310 insertions(+), 76 deletions(-) diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 22b1736..088298d 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -217,11 +217,38 @@ func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 { return 0 } +// avoidSelfLoopDestination forces a different destination than CurrentTownID when the world +// has multiple towns. Roads are always between distinct towns; dest == current yields no road +// and the hero never moves (common after DB fallback or mis-picked nearest town at 0,0). +func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) { + if graph == nil || len(graph.TownOrder) <= 1 { + return + } + if hm.CurrentTownID == 0 || hm.DestinationTownID != hm.CurrentTownID { + return + } + if d := hm.firstOutgoingDestination(graph); d != 0 && d != hm.CurrentTownID { + hm.DestinationTownID = d + return + } + if nxt := graph.NextTownInChain(hm.CurrentTownID); nxt != 0 && nxt != hm.CurrentTownID { + hm.DestinationTownID = nxt + } +} + // pickDestination selects the next town the hero should walk toward. // Only towns connected by a roads row are chosen — TownOrder alone is not enough. func (hm *HeroMovement) pickDestination(graph *RoadGraph) { + defer hm.avoidSelfLoopDestination(graph) + if hm.CurrentTownID == 0 { - hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) + // Fresh heroes are inserted at (0,0). NearestTown(0,0) is often the wrong ring vertex; + // TownOrder[0] is lowest level_min (progression start), matching narrative and ring exits. + if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 { + hm.CurrentTownID = graph.TownOrder[0] + } else { + hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) + } } n := len(graph.TownOrder) @@ -1522,10 +1549,16 @@ func ProcessSingleHeroMovementTick( case model.StateWalking: cfg := tuning.Get() hm.expireAdventureIfNeeded(now) - if hm.Road == nil || len(hm.Road.Waypoints) < 2 { + hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2 + if hadNoRoad { hm.Road = nil hm.pickDestination(graph) hm.assignRoad(graph) + if sender != nil && hm.Road != nil && len(hm.Road.Waypoints) >= 2 { + if route := hm.RoutePayload(); route != nil { + sender.SendToHero(heroID, "route_assigned", route) + } + } } // Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout. diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 46ef05b..c56b913 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" ) @@ -66,21 +67,26 @@ func (h *AuthHandler) TelegramAuth(w http.ResponseWriter, r *http.Request) { return } - hero, err := h.store.GetOrCreate(r.Context(), telegramID, "Hero") + hero, err := h.store.GetByTelegramID(r.Context(), telegramID) if err != nil { - h.logger.Error("failed to get or create hero", "telegram_id", telegramID, "error", err) + h.logger.Error("failed to load hero", "telegram_id", telegramID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load hero", }) return } - h.logger.Info("telegram auth success", "telegram_id", telegramID, "hero_id", hero.ID) + resp := map[string]any{"heroId": int64(0), "hero": nil} + if hero != nil { + hero.XPToNext = model.XPToNextLevel(hero.Level) + resp["heroId"] = hero.ID + resp["hero"] = hero + h.logger.Info("telegram auth success", "telegram_id", telegramID, "hero_id", hero.ID) + } else { + h.logger.Info("telegram auth success, no hero row yet", "telegram_id", telegramID) + } - writeJSON(w, http.StatusOK, map[string]any{ - "heroId": hero.ID, - "hero": hero, - }) + writeJSON(w, http.StatusOK, resp) } // TelegramAuthMiddleware validates the Telegram initData on every request diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 2f6d7e3..3573d07 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -1002,7 +1002,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { return } - hero, err := h.store.GetOrCreate(r.Context(), telegramID, "Hero") + hero, err := h.store.GetByTelegramID(r.Context(), telegramID) if err != nil { h.logger.Error("failed to init hero", "telegram_id", telegramID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ @@ -1011,6 +1011,20 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { return } + if hero == nil { + townsWithNPCs := h.buildTownsWithNPCs(r.Context()) + writeJSON(w, http.StatusOK, map[string]any{ + "hero": nil, + "needsName": true, + "offlineReport": nil, + "mapRef": h.world.RefForLevel(1), + "towns": townsWithNPCs, + }) + return + } + + hero.XPToNext = model.XPToNextLevel(hero.Level) + now := time.Now() simFrozen := h.engine != nil && h.engine.IsTimePaused() if !simFrozen { @@ -1184,9 +1198,31 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) { return } if hero == nil { - writeJSON(w, http.StatusNotFound, map[string]string{ - "error": "hero not found", - }) + hero, err = h.store.CreateHeroWithSpawn(r.Context(), telegramID, req.Name) + if err != nil { + errStr := err.Error() + if containsUniqueViolation(errStr) { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "HERO_NAME_TAKEN", + }) + return + } + h.logger.Error("failed to create hero", "telegram_id", telegramID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to create hero", + }) + return + } + now := time.Now() + chargesInit := hero.EnsureBuffChargesPopulated(now) + if chargesInit { + if err := h.store.Save(r.Context(), hero); err != nil { + h.logger.Warn("failed to persist buff charges after create", "hero_id", hero.ID, "error", err) + } + } + hero.RefreshDerivedCombatStats(now) + h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name) + writeJSON(w, http.StatusOK, hero) return } diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index 1425872..60122fb 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -3,8 +3,10 @@ package storage import ( "context" "encoding/json" + "errors" "fmt" "log/slog" + "math/rand" "strconv" "strings" "time" @@ -51,11 +53,14 @@ func NewHeroStore(pool *pgxpool.Pool, logger *slog.Logger) *HeroStore { } // GetHeroIDByTelegramID returns the DB hero ID for a given Telegram user ID. -// Returns 0 if not found. +// Returns (0, nil) if not found. func (s *HeroStore) GetHeroIDByTelegramID(ctx context.Context, telegramID int64) (int64, error) { var id int64 err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } return 0, err } return id, nil @@ -253,13 +258,11 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) return hero, nil } -// Create inserts a new hero into the database. -// The hero.ID field is populated from the RETURNING clause. -// Default weapon_id=1 (Rusty Dagger) and armor_id=1 (Leather Armor). -func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { +// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear. +// Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables. +func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error { now := time.Now() - // Default equipment IDs. var weaponID int64 = 1 var armorID int64 = 1 hero.WeaponID = &weaponID @@ -267,6 +270,10 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { hero.CreatedAt = now hero.UpdatedAt = now + if hero.MoveState == "" { + hero.MoveState = string(model.StateWalking) + } + buffChargesJSON := marshalBuffCharges(hero.BuffCharges) query := ` @@ -281,7 +288,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { position_x, position_y, potions, total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops, last_online_at, - created_at, updated_at + created_at, updated_at, + current_town_id, destination_town_id, move_state ) VALUES ( $1, $2, $3, $4, $5, $6, $7, @@ -293,7 +301,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { $24, $25, $26, $27, $28, $29, $30, $31, $32, - $33, $34 + $33, $34, + $35, $36, $37 ) RETURNING id ` @@ -309,15 +318,173 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops, hero.LastOnlineAt, hero.CreatedAt, hero.UpdatedAt, + hero.CurrentTownID, hero.DestinationTownID, hero.MoveState, ).Scan(&hero.ID) if err != nil { return fmt.Errorf("insert hero: %w", err) } - // Create default starter gear and equip it. + return nil +} + +// Create inserts a new hero into the database with legacy default starter gear (dagger + leather). +func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { + if err := s.insertNewHeroRow(ctx, hero); err != nil { + return err + } if err := s.createDefaultGear(ctx, hero.ID); err != nil { return fmt.Errorf("create default gear: %w", err) } + return nil +} + +func (s *HeroStore) randomTownCoords(ctx context.Context) (id int64, wx, wy float64, err error) { + err = s.pool.QueryRow(ctx, ` + SELECT id, world_x, world_y FROM towns ORDER BY random() LIMIT 1`, + ).Scan(&id, &wx, &wy) + if err != nil { + return 0, 0, 0, fmt.Errorf("random town: %w", err) + } + return id, wx, wy, nil +} + +func (s *HeroStore) randomOutgoingTownID(ctx context.Context, fromTownID int64) (int64, error) { + var to int64 + err := s.pool.QueryRow(ctx, ` + SELECT to_town_id FROM roads WHERE from_town_id = $1 ORDER BY random() LIMIT 1`, fromTownID, + ).Scan(&to) + return to, err +} + +func (s *HeroStore) pickBirthTownAndDestination(ctx context.Context) (birthID, destID int64, bx, by float64, err error) { + for i := 0; i < 12; i++ { + birthID, bx, by, err = s.randomTownCoords(ctx) + if err != nil { + return 0, 0, 0, 0, err + } + destID, err = s.randomOutgoingTownID(ctx, birthID) + if err == nil && destID != 0 && destID != birthID { + return birthID, destID, bx, by, nil + } + } + err = s.pool.QueryRow(ctx, ` + SELECT r.from_town_id, r.to_town_id, tf.world_x, tf.world_y + FROM roads r + JOIN towns tf ON tf.id = r.from_town_id + ORDER BY random() LIMIT 1`, + ).Scan(&birthID, &destID, &bx, &by) + if err != nil { + return 0, 0, 0, 0, fmt.Errorf("pick spawn on random road: %w", err) + } + return birthID, destID, bx, by, nil +} + +// CreateHeroWithSpawn creates a new hero after the player chose a name: random birth town, +// 100 gold, random common ilvl-1 sword and armor, destination a town reachable by road. +func (s *HeroStore) CreateHeroWithSpawn(ctx context.Context, telegramID int64, name string) (*model.Hero, error) { + birthID, destID, bx, by, err := s.pickBirthTownAndDestination(ctx) + if err != nil { + return nil, err + } + + birth := birthID + dest := destID + hero := &model.Hero{ + TelegramID: telegramID, + Name: name, + HP: 100, + MaxHP: 100, + Attack: 10, + Defense: 5, + Speed: 1.0, + Strength: 1, + Constitution: 1, + Agility: 1, + Luck: 1, + State: model.StateWalking, + Gold: 100, + XP: 0, + Level: 1, + PositionX: bx, + PositionY: by, + CurrentTownID: &birth, + DestinationTownID: &dest, + MoveState: string(model.StateWalking), + BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(), + } + + if err := s.insertNewHeroRow(ctx, hero); err != nil { + return nil, err + } + if err := s.createRandomStarterGear(ctx, hero.ID); err != nil { + return nil, fmt.Errorf("create starter gear: %w", err) + } + + return s.GetByID(ctx, hero.ID) +} + +func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) error { + swords := []struct { + name string + }{ + {"Worn Shortsword"}, + {"Traveler's Blade"}, + {"Notched Sword"}, + {"Training Sword"}, + } + sw := swords[rand.Intn(len(swords))] + starterWeapon := &model.GearItem{ + Slot: model.SlotMainHand, + FormID: "gear.form.main_hand.sword", + Name: sw.name, + Subtype: "sword", + Rarity: model.RarityCommon, + Ilvl: 1, + BasePrimary: 4, + PrimaryStat: 4, + StatType: "attack", + SpeedModifier: 1.0, + CritChance: 0.05, + } + if err := s.gearStore.CreateItem(ctx, starterWeapon); err != nil { + return fmt.Errorf("create starter sword: %w", err) + } + if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil { + return fmt.Errorf("equip starter sword: %w", err) + } + + armors := []struct { + name string + formID string + subtype string + speed float64 + agilityBon int + defense int + }{ + {"Worn Leather Jack", "gear.form.chest.leather", "light", 1.05, 2, 3}, + {"Rusty Hauberk", "gear.form.chest.mail", "medium", 1.0, 0, 4}, + {"Worn Plate", "gear.form.chest.plate", "heavy", 0.7, 0, 5}, + } + ar := armors[rand.Intn(len(armors))] + starterArmor := &model.GearItem{ + Slot: model.SlotChest, + FormID: ar.formID, + Name: ar.name, + Subtype: ar.subtype, + Rarity: model.RarityCommon, + Ilvl: 1, + BasePrimary: ar.defense, + PrimaryStat: ar.defense, + StatType: "defense", + SpeedModifier: ar.speed, + AgilityBonus: ar.agilityBon, + } + if err := s.gearStore.CreateItem(ctx, starterArmor); err != nil { + return fmt.Errorf("create starter armor: %w", err) + } + if err := s.gearStore.EquipItem(ctx, heroID, model.SlotChest, starterArmor.ID); err != nil { + return fmt.Errorf("equip starter armor: %w", err) + } return nil } @@ -449,52 +616,6 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64 return nil } -// GetOrCreate loads a hero by Telegram ID, creating one with default stats if not found. -// This is the main entry point used by auth and hero init flows. -func (s *HeroStore) GetOrCreate(ctx context.Context, telegramID int64, name string) (*model.Hero, error) { - hero, err := s.GetByTelegramID(ctx, telegramID) - if err != nil { - return nil, fmt.Errorf("get or create hero: %w", err) - } - - if hero != nil { - hero.XPToNext = model.XPToNextLevel(hero.Level) - return hero, nil - } - - // Create a new hero with default stats. - hero = &model.Hero{ - TelegramID: telegramID, - Name: name, - HP: 100, - MaxHP: 100, - Attack: 10, - Defense: 5, - Speed: 1.0, - Strength: 1, - Constitution: 1, - Agility: 1, - Luck: 1, - State: model.StateWalking, - Gold: 0, - XP: 0, - Level: 1, - BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(), - } - - if err := s.Create(ctx, hero); err != nil { - return nil, fmt.Errorf("get or create hero: %w", err) - } - - // Reload to get the gear and buff data. - hero, err = s.GetByID(ctx, hero.ID) - if err != nil { - return nil, fmt.Errorf("get or create hero reload: %w", err) - } - - return hero, nil -} - // ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map, // or heroes resting / in town whose DB row has not been updated recently (offline town timers). // Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05dcf4b..af7b746 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -414,19 +414,55 @@ export function App() { }); engine.init(container).then(async () => { + let shouldOpenWS = false; try { const telegramId = getTelegramUserId() ?? 1; const initRes = await initHero(telegramId); - // Gate game start behind name entry + // Gate game start behind name entry — no hero row until POST /hero/name if (initRes.needsName) { setNeedsName(true); - const heroState = heroResponseToState(initRes.hero); - engine.initFromServer(heroState, initRes.hero.state); + if (initRes.hero) { + const heroState = heroResponseToState(initRes.hero); + engine.initFromServer(heroState, initRes.hero.state); + } + getTowns() + .then(async (t) => { + setTowns(t); + townsRef.current = t; + const townNPCMap = new Map(); + try { + const npcResults = await Promise.allSettled( + t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs }))), + ); + for (const result of npcResults) { + if (result.status === 'fulfilled') { + townNPCMap.set(result.value.townId, result.value.npcs); + } + } + } catch { + /* ignore */ + } + const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id))); + engine.setTowns(townDataList); + const allNPCs: NPCData[] = []; + for (const td of townDataList) { + if (td.npcs) allNPCs.push(...td.npcs); + } + engine.setNPCs(allNPCs); + }) + .catch(() => console.warn('[App] Could not fetch towns (name gate)')); console.info('[App] Hero needs name, showing name entry screen'); return; } + if (!initRes.hero) { + console.error('[App] init: missing hero without needsName'); + setConnectionError('Invalid server response.'); + return; + } + + shouldOpenWS = true; const heroState = heroResponseToState(initRes.hero); engine.initFromServer(heroState, initRes.hero.state); engine.setHeroName(initRes.hero.name); @@ -513,6 +549,9 @@ export function App() { return; } engine.start(); + if (shouldOpenWS) { + ws.connect(); + } }).catch((err) => { console.error('[App] Failed to initialize game engine:', err); }); @@ -705,8 +744,6 @@ export function App() { }, }); - ws.connect(); - // ---- Telegram Theme Listener ---- const unsubTheme = onThemeChanged(); @@ -907,11 +944,10 @@ export function App() { const engine = engineRef.current; if (engine) { const heroState = heroResponseToState(hero); - const pos = engine.gameState.hero?.position; - if (pos) heroState.position = pos; engine.initFromServer(heroState, hero.state); engine.setHeroName(hero.name); engine.start(); + wsRef.current?.connect(); const telegramId = getTelegramUserId() ?? 1; getTowns() diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 8aa4cf4..a75094d 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -155,6 +155,7 @@ export interface HeroResponse { export interface AuthResponse { token: string; + /** 0 if the player has not created a hero yet. */ heroId: number; } @@ -179,7 +180,8 @@ export interface OfflineReport { } export interface InitHeroResponse { - hero: HeroResponse; + /** Null until the player submits a valid name (no DB row until then). */ + hero: HeroResponse | null; offlineReport: OfflineReport | null; mapRef: MapRefResponse; needsName?: boolean;