master
Denis Ranneft 1 month ago
parent 9a6762d0ba
commit 8ecaf3895a

@ -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.

@ -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

@ -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
}

@ -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).

@ -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<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');
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()

@ -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;

Loading…
Cancel
Save