master
Denis Ranneft 2 months ago
parent 9a6762d0ba
commit 8ecaf3895a

@ -217,11 +217,38 @@ func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 {
return 0 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. // pickDestination selects the next town the hero should walk toward.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough. // Only towns connected by a roads row are chosen — TownOrder alone is not enough.
func (hm *HeroMovement) pickDestination(graph *RoadGraph) { func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
defer hm.avoidSelfLoopDestination(graph)
if hm.CurrentTownID == 0 { 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) n := len(graph.TownOrder)
@ -1522,10 +1549,16 @@ func ProcessSingleHeroMovementTick(
case model.StateWalking: case model.StateWalking:
cfg := tuning.Get() cfg := tuning.Get()
hm.expireAdventureIfNeeded(now) 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.Road = nil
hm.pickDestination(graph) hm.pickDestination(graph)
hm.assignRoad(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. // Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout.

@ -13,6 +13,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
) )
@ -66,21 +67,26 @@ func (h *AuthHandler) TelegramAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
hero, err := h.store.GetOrCreate(r.Context(), telegramID, "Hero") hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil { 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{ writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero", "error": "failed to load hero",
}) })
return 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{ writeJSON(w, http.StatusOK, resp)
"heroId": hero.ID,
"hero": hero,
})
} }
// TelegramAuthMiddleware validates the Telegram initData on every request // TelegramAuthMiddleware validates the Telegram initData on every request

@ -1002,7 +1002,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
return return
} }
hero, err := h.store.GetOrCreate(r.Context(), telegramID, "Hero") hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil { if err != nil {
h.logger.Error("failed to init hero", "telegram_id", telegramID, "error", err) h.logger.Error("failed to init hero", "telegram_id", telegramID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -1011,6 +1011,20 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
return 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() now := time.Now()
simFrozen := h.engine != nil && h.engine.IsTimePaused() simFrozen := h.engine != nil && h.engine.IsTimePaused()
if !simFrozen { if !simFrozen {
@ -1184,9 +1198,31 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
return return
} }
if hero == nil { if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{ hero, err = h.store.CreateHeroWithSpawn(r.Context(), telegramID, req.Name)
"error": "hero not found", 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 return
} }

@ -3,8 +3,10 @@ package storage
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand"
"strconv" "strconv"
"strings" "strings"
"time" "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. // 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) { func (s *HeroStore) GetHeroIDByTelegramID(ctx context.Context, telegramID int64) (int64, error) {
var id int64 var id int64
err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id) err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return 0, err return 0, err
} }
return id, nil return id, nil
@ -253,13 +258,11 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error)
return hero, nil return hero, nil
} }
// Create inserts a new hero into the database. // insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
// The hero.ID field is populated from the RETURNING clause. // Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables.
// Default weapon_id=1 (Rusty Dagger) and armor_id=1 (Leather Armor). func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error {
func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
now := time.Now() now := time.Now()
// Default equipment IDs.
var weaponID int64 = 1 var weaponID int64 = 1
var armorID int64 = 1 var armorID int64 = 1
hero.WeaponID = &weaponID hero.WeaponID = &weaponID
@ -267,6 +270,10 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
hero.CreatedAt = now hero.CreatedAt = now
hero.UpdatedAt = now hero.UpdatedAt = now
if hero.MoveState == "" {
hero.MoveState = string(model.StateWalking)
}
buffChargesJSON := marshalBuffCharges(hero.BuffCharges) buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
query := ` query := `
@ -281,7 +288,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
position_x, position_y, potions, position_x, position_y, potions,
total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops, total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops,
last_online_at, last_online_at,
created_at, updated_at created_at, updated_at,
current_town_id, destination_town_id, move_state
) VALUES ( ) VALUES (
$1, $2, $1, $2,
$3, $4, $5, $6, $7, $3, $4, $5, $6, $7,
@ -293,7 +301,8 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
$24, $25, $26, $24, $25, $26,
$27, $28, $29, $30, $31, $27, $28, $29, $30, $31,
$32, $32,
$33, $34 $33, $34,
$35, $36, $37
) RETURNING id ) 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.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops,
hero.LastOnlineAt, hero.LastOnlineAt,
hero.CreatedAt, hero.UpdatedAt, hero.CreatedAt, hero.UpdatedAt,
hero.CurrentTownID, hero.DestinationTownID, hero.MoveState,
).Scan(&hero.ID) ).Scan(&hero.ID)
if err != nil { if err != nil {
return fmt.Errorf("insert hero: %w", err) 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 { if err := s.createDefaultGear(ctx, hero.ID); err != nil {
return fmt.Errorf("create default gear: %w", err) 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 return nil
} }
@ -449,52 +616,6 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
return nil 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, // 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). // 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). // Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).

@ -414,19 +414,55 @@ export function App() {
}); });
engine.init(container).then(async () => { engine.init(container).then(async () => {
let shouldOpenWS = false;
try { try {
const telegramId = getTelegramUserId() ?? 1; const telegramId = getTelegramUserId() ?? 1;
const initRes = await initHero(telegramId); 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) { if (initRes.needsName) {
setNeedsName(true); setNeedsName(true);
const heroState = heroResponseToState(initRes.hero); if (initRes.hero) {
engine.initFromServer(heroState, initRes.hero.state); const heroState = heroResponseToState(initRes.hero);
engine.initFromServer(heroState, initRes.hero.state);
}
getTowns()
.then(async (t) => {
setTowns(t);
townsRef.current = t;
const townNPCMap = new Map<number, NPC[]>();
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'); console.info('[App] Hero needs name, showing name entry screen');
return; return;
} }
if (!initRes.hero) {
console.error('[App] init: missing hero without needsName');
setConnectionError('Invalid server response.');
return;
}
shouldOpenWS = true;
const heroState = heroResponseToState(initRes.hero); const heroState = heroResponseToState(initRes.hero);
engine.initFromServer(heroState, initRes.hero.state); engine.initFromServer(heroState, initRes.hero.state);
engine.setHeroName(initRes.hero.name); engine.setHeroName(initRes.hero.name);
@ -513,6 +549,9 @@ export function App() {
return; return;
} }
engine.start(); engine.start();
if (shouldOpenWS) {
ws.connect();
}
}).catch((err) => { }).catch((err) => {
console.error('[App] Failed to initialize game engine:', err); console.error('[App] Failed to initialize game engine:', err);
}); });
@ -705,8 +744,6 @@ export function App() {
}, },
}); });
ws.connect();
// ---- Telegram Theme Listener ---- // ---- Telegram Theme Listener ----
const unsubTheme = onThemeChanged(); const unsubTheme = onThemeChanged();
@ -907,11 +944,10 @@ export function App() {
const engine = engineRef.current; const engine = engineRef.current;
if (engine) { if (engine) {
const heroState = heroResponseToState(hero); const heroState = heroResponseToState(hero);
const pos = engine.gameState.hero?.position;
if (pos) heroState.position = pos;
engine.initFromServer(heroState, hero.state); engine.initFromServer(heroState, hero.state);
engine.setHeroName(hero.name); engine.setHeroName(hero.name);
engine.start(); engine.start();
wsRef.current?.connect();
const telegramId = getTelegramUserId() ?? 1; const telegramId = getTelegramUserId() ?? 1;
getTowns() getTowns()

@ -155,6 +155,7 @@ export interface HeroResponse {
export interface AuthResponse { export interface AuthResponse {
token: string; token: string;
/** 0 if the player has not created a hero yet. */
heroId: number; heroId: number;
} }
@ -179,7 +180,8 @@ export interface OfflineReport {
} }
export interface InitHeroResponse { export interface InitHeroResponse {
hero: HeroResponse; /** Null until the player submits a valid name (no DB row until then). */
hero: HeroResponse | null;
offlineReport: OfflineReport | null; offlineReport: OfflineReport | null;
mapRef: MapRefResponse; mapRef: MapRefResponse;
needsName?: boolean; needsName?: boolean;

Loading…
Cancel
Save