From 220418c4c684f39c1e801b6fd9b1ec5c6778c7ba Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Tue, 31 Mar 2026 02:00:54 +0300 Subject: [PATCH] missing tests and sql --- backend/internal/game/fsm_excursion_test.go | 178 ++++++++++++++++++ backend/internal/storage/quest_offers.go | 33 ++++ backend/internal/storage/quest_offers_test.go | 50 +++++ .../internal/storage/town_session_redis.go | 96 ++++++++++ .../000029_additional_cross_roads.sql | 72 +++++++ .../000030_runtime_config_combat_rolls.sql | 13 ++ .../000031_runtime_config_enemy_regen.sql | 8 + 7 files changed, 450 insertions(+) create mode 100644 backend/internal/game/fsm_excursion_test.go create mode 100644 backend/internal/storage/quest_offers.go create mode 100644 backend/internal/storage/quest_offers_test.go create mode 100644 backend/internal/storage/town_session_redis.go create mode 100644 backend/migrations/000029_additional_cross_roads.sql create mode 100644 backend/migrations/000030_runtime_config_combat_rolls.sql create mode 100644 backend/migrations/000031_runtime_config_enemy_regen.sql diff --git a/backend/internal/game/fsm_excursion_test.go b/backend/internal/game/fsm_excursion_test.go new file mode 100644 index 0000000..ea370b6 --- /dev/null +++ b/backend/internal/game/fsm_excursion_test.go @@ -0,0 +1,178 @@ +package game + +import ( + "testing" + "time" + + "github.com/denisovdennis/autohero/internal/model" + "github.com/denisovdennis/autohero/internal/tuning" +) + +// Phase 2 FSM: road spine freeze during excursion, HP-based exits, no rest while fighting. + +func TestFSM_ExcursionFreezesRoadProgress(t *testing.T) { + graph := testGraph() + hero := testHeroOnRoad(1, 500, 1000) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + + hm.WaypointIndex = 0 + hm.WaypointFraction = 0.5 + from := hm.Road.Waypoints[0] + to := hm.Road.Waypoints[1] + hm.CurrentX = from.X + (to.X-from.X)*0.5 + hm.CurrentY = from.Y + (to.Y-from.Y)*0.5 + hm.LastMoveTick = now + + hm.beginExcursion(now) + if hm.Excursion.RoadFreezeWaypoint != 0 || hm.Excursion.RoadFreezeFraction != 0.5 { + t.Fatalf("unexpected freeze snapshot: wp=%d frac=%v", hm.Excursion.RoadFreezeWaypoint, hm.Excursion.RoadFreezeFraction) + } + + later := now.Add(30 * time.Second) + reached := hm.AdvanceTick(later, graph) + if reached { + t.Fatal("AdvanceTick should not reach town while excursion is active") + } + if hm.WaypointIndex != 0 || hm.WaypointFraction != 0.5 { + t.Fatalf("waypoint progress should stay frozen during excursion, got idx=%d frac=%v", hm.WaypointIndex, hm.WaypointFraction) + } + ps := hm.PositionSyncPayload(later) + if ps.WaypointIndex != 0 || ps.WaypointFraction != 0.5 { + t.Fatalf("PositionSync should reflect frozen road PB, got idx=%d frac=%v", ps.WaypointIndex, ps.WaypointFraction) + } +} + +func TestFSM_NormalWalking_AdvanceTickMovesAlongRoad(t *testing.T) { + graph := testGraph() + hero := testHeroOnRoad(1, 500, 1000) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + + hm.WaypointIndex = 0 + hm.WaypointFraction = 0.5 + from := hm.Road.Waypoints[0] + to := hm.Road.Waypoints[1] + hm.CurrentX = from.X + (to.X-from.X)*0.5 + hm.CurrentY = from.Y + (to.Y-from.Y)*0.5 + hm.LastMoveTick = now + if hm.Excursion.Active() { + t.Fatal("excursion should not be active") + } + + later := now.Add(5 * time.Second) + reached := hm.AdvanceTick(later, graph) + if reached { + t.Fatal("should not reach town from mid-segment in 5s") + } + if hm.WaypointIndex == 0 && hm.WaypointFraction == 0.5 { + t.Fatal("expected road progress to advance without active excursion") + } +} + +func TestFSM_RoadsideRest_HPExit_ForcesReturnBeforeWildTimer(t *testing.T) { + graph := testGraph() + cfg := tuning.Get() + maxHP := 1000 + hero := testHeroOnRoad(1, int(float64(maxHP)*cfg.RoadsideRestExitHp), maxHP) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + hm.beginRoadsideRest(now) + origWildUntil := hm.Excursion.WildUntil + + // Skip "out" leg: test HP exit from wild (campfire) phase. + hm.Excursion.Phase = model.ExcursionWild + hm.Excursion.OutUntil = now.Add(-time.Second) + tick := now.Add(time.Second) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) + + if hm.Excursion.Phase != model.ExcursionReturn { + t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase) + } + if !tick.Before(origWildUntil) { + t.Fatal("HP exit should force return before original WildUntil timer") + } +} + +func TestFSM_AdventureInlineRest_HPExit_ExcursionStillActive(t *testing.T) { + graph := testGraph() + cfg := tuning.Get() + maxHP := 1000 + targetHP := int(float64(maxHP) * cfg.AdventureRestTargetHp) + hero := testHeroOnRoad(1, targetHP, maxHP) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + hm.beginExcursion(now) + hm.Excursion.Phase = model.ExcursionWild + hm.beginAdventureInlineRest(now) + + ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil) + + if hm.State != model.StateWalking { + t.Fatalf("expected back to walking after HP target, got %s", hm.State) + } + if !hm.Excursion.Active() { + t.Fatal("excursion session should continue after adventure-inline HP exit") + } +} + +func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) { + graph := testGraph() + cfg := tuning.Get() + maxHP := 1000 + lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1 + + hero := testHeroOnRoad(1, lowHP, maxHP) + hero.State = model.StateFighting + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + + ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil) + + if hm.State != model.StateFighting { + t.Fatalf("expected StateFighting unchanged, got %s", hm.State) + } + if hm.State == model.StateResting { + t.Fatal("must not enter rest while fighting") + } +} + +func TestFSM_AdminStartRoadsideRest_RejectsFighting(t *testing.T) { + graph := testGraph() + hero := testHeroOnRoad(1, 100, 1000) + hero.State = model.StateFighting + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + hm.State = model.StateFighting + + if hm.AdminStartRoadsideRest(now) { + t.Fatal("AdminStartRoadsideRest must reject fighting hero") + } +} + +func TestFSM_AdminStartExcursion_RejectsFighting(t *testing.T) { + graph := testGraph() + hero := testHeroOnRoad(1, 500, 1000) + hero.State = model.StateFighting + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + hm.State = model.StateFighting + + if hm.AdminStartExcursion(now) { + t.Fatal("AdminStartExcursion must reject fighting hero") + } +} + +func TestFSM_AdminStopExcursion_RejectsFighting(t *testing.T) { + graph := testGraph() + hero := testHeroOnRoad(1, 500, 1000) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + hm.beginExcursion(now) + hm.State = model.StateFighting + hm.Hero.State = model.StateFighting + + if hm.AdminStopExcursion(now) { + t.Fatal("AdminStopExcursion must reject fighting hero") + } +} diff --git a/backend/internal/storage/quest_offers.go b/backend/internal/storage/quest_offers.go new file mode 100644 index 0000000..667c92c --- /dev/null +++ b/backend/internal/storage/quest_offers.go @@ -0,0 +1,33 @@ +package storage + +import ( + "math/rand/v2" + "slices" + + "github.com/denisovdennis/autohero/internal/model" +) + +// FilterCapOfferableQuests drops quest templates whose id is in taken, then shuffles the rest +// deterministically from seed and returns at most limit entries. If limit <= 0, returns all offerable quests (still filtered). +func FilterCapOfferableQuests(all []model.Quest, taken map[int64]struct{}, limit int, seed int64) []model.Quest { + var offer []model.Quest + for _, q := range all { + if _, skip := taken[q.ID]; skip { + continue + } + offer = append(offer, q) + } + if len(offer) == 0 { + return offer + } + if limit <= 0 || len(offer) <= limit { + return offer + } + shuffled := slices.Clone(offer) + rng := rand.New(rand.NewPCG(uint64(seed), uint64(seed>>32)^0x9e3779b97f4a7c15)) + for i := len(shuffled) - 1; i > 0; i-- { + j := rng.IntN(i + 1) + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + } + return shuffled[:limit] +} diff --git a/backend/internal/storage/quest_offers_test.go b/backend/internal/storage/quest_offers_test.go new file mode 100644 index 0000000..b9eefdb --- /dev/null +++ b/backend/internal/storage/quest_offers_test.go @@ -0,0 +1,50 @@ +package storage + +import ( + "testing" + + "github.com/denisovdennis/autohero/internal/model" +) + +func TestFilterCapOfferableQuests_filtersTaken(t *testing.T) { + all := []model.Quest{ + {ID: 1, Title: "a"}, + {ID: 2, Title: "b"}, + {ID: 3, Title: "c"}, + } + taken := map[int64]struct{}{2: {}} + out := FilterCapOfferableQuests(all, taken, 10, 42) + if len(out) != 2 { + t.Fatalf("len=%d want 2", len(out)) + } + for _, q := range out { + if q.ID == 2 { + t.Fatal("taken quest should be removed") + } + } +} + +func TestFilterCapOfferableQuests_capDeterministic(t *testing.T) { + all := []model.Quest{ + {ID: 10, Title: "a"}, + {ID: 20, Title: "b"}, + {ID: 30, Title: "c"}, + } + out1 := FilterCapOfferableQuests(all, nil, 2, 999) + out2 := FilterCapOfferableQuests(all, nil, 2, 999) + if len(out1) != 2 || len(out2) != 2 { + t.Fatalf("cap: len1=%d len2=%d", len(out1), len(out2)) + } + if out1[0].ID != out2[0].ID || out1[1].ID != out2[1].ID { + t.Fatalf("same seed should produce same order: %#v vs %#v", out1, out2) + } + _ = FilterCapOfferableQuests(all, nil, 2, 1000) +} + +func TestFilterCapOfferableQuests_limitZeroReturnsAll(t *testing.T) { + all := []model.Quest{{ID: 1}, {ID: 2}} + out := FilterCapOfferableQuests(all, nil, 0, 1) + if len(out) != 2 { + t.Fatalf("len=%d want 2", len(out)) + } +} diff --git a/backend/internal/storage/town_session_redis.go b/backend/internal/storage/town_session_redis.go new file mode 100644 index 0000000..381e2ee --- /dev/null +++ b/backend/internal/storage/town_session_redis.go @@ -0,0 +1,96 @@ +package storage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/denisovdennis/autohero/internal/model" +) + +const heroTownSessionKeyFmt = "autohero:v1:hero:%d:town_session" + +// HeroTownSessionRedis is the last persisted in-town NPC tour snapshot (reconnect / crash recovery). +type HeroTownSessionRedis struct { + SavedAtUnixNano int64 `json:"savedAtUnixNano"` + State model.GameState `json:"state"` + CurrentTownID int64 `json:"currentTownId,omitempty"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` + TownPause *model.TownPausePersisted `json:"townPause,omitempty"` +} + +// TownSessionStore mirrors in-town hero state to Redis for faster/stale-DB-safe reconnect. +type TownSessionStore struct { + rdb *redis.Client +} + +// NewTownSessionStore returns a store backed by Redis, or nil if rdb is nil. +func NewTownSessionStore(rdb *redis.Client) *TownSessionStore { + if rdb == nil { + return nil + } + return &TownSessionStore{rdb: rdb} +} + +func (s *TownSessionStore) key(heroID int64) string { + return fmt.Sprintf(heroTownSessionKeyFmt, heroID) +} + +// Save stores the hero's in-town session. Caller must set hero.TownPause (e.g. after SyncToHero). +func (s *TownSessionStore) Save(ctx context.Context, heroID int64, h *model.Hero) error { + if s == nil || s.rdb == nil || h == nil { + return nil + } + if h.State != model.StateInTown { + return nil + } + var townID int64 + if h.CurrentTownID != nil { + townID = *h.CurrentTownID + } + payload := HeroTownSessionRedis{ + SavedAtUnixNano: time.Now().UnixNano(), + State: h.State, + CurrentTownID: townID, + PositionX: h.PositionX, + PositionY: h.PositionY, + TownPause: h.TownPause, + } + b, err := json.Marshal(payload) + if err != nil { + return err + } + return s.rdb.Set(ctx, s.key(heroID), b, 72*time.Hour).Err() +} + +// Delete removes the in-town session key (hero left town or state no longer in_town). +func (s *TownSessionStore) Delete(ctx context.Context, heroID int64) error { + if s == nil || s.rdb == nil { + return nil + } + return s.rdb.Del(ctx, s.key(heroID)).Err() +} + +// Load returns the stored session, or (nil, nil) if missing. +func (s *TownSessionStore) Load(ctx context.Context, heroID int64) (*HeroTownSessionRedis, error) { + if s == nil || s.rdb == nil { + return nil, nil + } + b, err := s.rdb.Get(ctx, s.key(heroID)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, nil + } + return nil, err + } + var p HeroTownSessionRedis + if err := json.Unmarshal(b, &p); err != nil { + return nil, err + } + return &p, nil +} diff --git a/backend/migrations/000029_additional_cross_roads.sql b/backend/migrations/000029_additional_cross_roads.sql new file mode 100644 index 0000000..046c2f0 --- /dev/null +++ b/backend/migrations/000029_additional_cross_roads.sql @@ -0,0 +1,72 @@ +-- Migration 000029: More cross-roads so every town has at least three direct neighbors +-- (ring + shortcuts). Complements 000027 for hubs that still had only two outgoing roads +-- (Starfall, Duskwatch, Boghollow). + +-- Starfall <-> Mossharbor (Starfall otherwise only: Cinderkeep, Willowdale) +INSERT INTO roads (from_town_id, to_town_id, distance) +SELECT f.id, t.id, 1600.0 +FROM towns f, towns t +WHERE f.name = 'Starfall' AND t.name = 'Mossharbor' + AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id); + +INSERT INTO roads (from_town_id, to_town_id, distance) +SELECT f.id, t.id, 1600.0 +FROM towns f, towns t +WHERE f.name = 'Mossharbor' AND t.name = 'Starfall' + AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id); + +-- Duskwatch <-> Frostmark (Duskwatch otherwise only: Redcliff, Boghollow) +INSERT INTO roads (from_town_id, to_town_id, distance) +SELECT f.id, t.id, 1400.0 +FROM towns f, towns t +WHERE f.name = 'Duskwatch' AND t.name = 'Frostmark' + AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id); + +INSERT INTO roads (from_town_id, to_town_id, distance) +SELECT f.id, t.id, 1400.0 +FROM towns f, towns t +WHERE f.name = 'Frostmark' AND t.name = 'Duskwatch' + AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id); + +-- Boghollow <-> Ashengard (Boghollow otherwise only: Duskwatch, Cinderkeep) +INSERT INTO roads (from_town_id, to_town_id, distance) +SELECT f.id, t.id, 1500.0 +FROM towns f, towns t +WHERE f.name = 'Boghollow' AND t.name = 'Ashengard' + AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id); + +INSERT INTO roads (from_town_id, to_town_id, distance) +SELECT f.id, t.id, 1500.0 +FROM towns f, towns t +WHERE f.name = 'Ashengard' AND t.name = 'Boghollow' + AND NOT EXISTS (SELECT 1 FROM roads r WHERE r.from_town_id = f.id AND r.to_town_id = t.id); + +-- Waypoints for new roads only (same rule as 000019 / 000027). +INSERT INTO road_waypoints (road_id, seq, x, y) +SELECT + r.id, + gs.seq, + CASE + WHEN gs.seq = 0 THEN f.world_x + WHEN gs.seq = seg.nseg THEN t.world_x + ELSE f.world_x + (t.world_x - f.world_x) * (gs.seq::double precision / seg.nseg::double precision) + END, + CASE + WHEN gs.seq = 0 THEN f.world_y + WHEN gs.seq = seg.nseg THEN t.world_y + ELSE f.world_y + (t.world_y - f.world_y) * (gs.seq::double precision / seg.nseg::double precision) + END +FROM roads r +INNER JOIN towns f ON f.id = r.from_town_id +INNER JOIN towns t ON t.id = r.to_town_id +LEFT JOIN road_waypoints rw ON rw.road_id = r.id +CROSS JOIN LATERAL ( + SELECT GREATEST( + 1, + FLOOR( + SQRT(POWER(t.world_x - f.world_x, 2) + POWER(t.world_y - f.world_y, 2)) / 20.0 + )::integer + ) AS nseg +) seg +CROSS JOIN LATERAL generate_series(0, seg.nseg) AS gs(seq) +WHERE rw.road_id IS NULL; diff --git a/backend/migrations/000030_runtime_config_combat_rolls.sql b/backend/migrations/000030_runtime_config_combat_rolls.sql new file mode 100644 index 0000000..7753b42 --- /dev/null +++ b/backend/migrations/000030_runtime_config_combat_rolls.sql @@ -0,0 +1,13 @@ +-- Seed combat roll + crit/block tuning into runtime_config.payload (merged with existing keys). +UPDATE runtime_config +SET + payload = payload || '{ + "combatDamageRollMin": 0.6, + "combatDamageRollMax": 1.1, + "enemyCritChanceCap": 0.2, + "heroCritChanceCap": 0.12, + "heroBlockChancePerDefense": 0.0025, + "heroBlockChanceCap": 0.2 + }'::jsonb, + updated_at = now() +WHERE id = TRUE; diff --git a/backend/migrations/000031_runtime_config_enemy_regen.sql b/backend/migrations/000031_runtime_config_enemy_regen.sql new file mode 100644 index 0000000..e500245 --- /dev/null +++ b/backend/migrations/000031_runtime_config_enemy_regen.sql @@ -0,0 +1,8 @@ +-- Adjust battle lizard regen in runtime_config.payload. +UPDATE runtime_config +SET + payload = payload || '{ + "enemyRegenBattleLizard": 0.01 + }'::jsonb, + updated_at = now() +WHERE id = TRUE;