missing tests and sql

master
Denis Ranneft 1 month ago
parent 988ac55d92
commit 220418c4c6

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

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

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

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

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

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

@ -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;
Loading…
Cancel
Save