missing tests and sql
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…
Reference in New Issue