new admin rest methods + art bible
parent
d2d7cc88ab
commit
b5544c04f4
@ -0,0 +1,89 @@
|
|||||||
|
# =========================
|
||||||
|
# Node / TypeScript
|
||||||
|
# =========================
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
.npm/
|
||||||
|
.yarn/
|
||||||
|
.yarn-cache/
|
||||||
|
.yarn/unplugged/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
.svelte-kit/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Go
|
||||||
|
# =========================
|
||||||
|
# Binaries
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Go workspace / modules
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Vendor (optional — include if large)
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage.out
|
||||||
|
*.coverprofile
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# General / Shared
|
||||||
|
# =========================
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors / IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Cache / temp
|
||||||
|
.cache/
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
|
||||||
|
# Archives / large files
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.gz
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.bak
|
||||||
|
*.log
|
||||||
@ -0,0 +1,537 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
"github.com/denisovdennis/autohero/internal/tuning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testGraph builds a minimal two-town road graph for movement tests.
|
||||||
|
func testGraph() *RoadGraph {
|
||||||
|
townA := &model.Town{ID: 1, Name: "TownA", WorldX: 0, WorldY: 0, Radius: 0.5}
|
||||||
|
townB := &model.Town{ID: 2, Name: "TownB", WorldX: 100, WorldY: 0, Radius: 0.5}
|
||||||
|
road := &Road{
|
||||||
|
ID: 1, FromTownID: 1, ToTownID: 2,
|
||||||
|
Waypoints: []Point{{0, 0}, {50, 0}, {100, 0}},
|
||||||
|
Distance: 100,
|
||||||
|
}
|
||||||
|
roadBack := &Road{
|
||||||
|
ID: 2, FromTownID: 2, ToTownID: 1,
|
||||||
|
Waypoints: []Point{{100, 0}, {50, 0}, {0, 0}},
|
||||||
|
Distance: 100,
|
||||||
|
}
|
||||||
|
return &RoadGraph{
|
||||||
|
Roads: map[int64]*Road{1: road, 2: roadBack},
|
||||||
|
TownRoads: map[int64][]*Road{1: {road}, 2: {roadBack}},
|
||||||
|
Towns: map[int64]*model.Town{1: townA, 2: townB},
|
||||||
|
TownOrder: []int64{1, 2},
|
||||||
|
TownNPCs: map[int64][]TownNPC{},
|
||||||
|
NPCByID: map[int64]TownNPC{},
|
||||||
|
TownBuildings: map[int64][]TownBuilding{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero {
|
||||||
|
townID := int64(1)
|
||||||
|
destID := int64(2)
|
||||||
|
return &model.Hero{
|
||||||
|
ID: id, Level: 5,
|
||||||
|
MaxHP: maxHP, HP: hp,
|
||||||
|
Attack: 50, Defense: 30, Speed: 1.0,
|
||||||
|
Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
|
||||||
|
State: model.StateWalking,
|
||||||
|
CurrentTownID: &townID,
|
||||||
|
DestinationTownID: &destID,
|
||||||
|
PositionX: 50, PositionY: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Roadside rest ---
|
||||||
|
|
||||||
|
func TestRoadsideRest_TriggersOnLowHP(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
cfg := tuning.Get()
|
||||||
|
threshold := cfg.LowHpThreshold
|
||||||
|
maxHP := 1000
|
||||||
|
lowHP := int(float64(maxHP)*threshold) - 1
|
||||||
|
|
||||||
|
hero := testHeroOnRoad(1, lowHP, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.State = model.StateWalking
|
||||||
|
hm.Hero.State = model.StateWalking
|
||||||
|
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.State != model.StateResting {
|
||||||
|
t.Fatalf("expected StateResting, got %s", hm.State)
|
||||||
|
}
|
||||||
|
if hm.ActiveRestKind != model.RestKindRoadside {
|
||||||
|
t.Fatalf("expected RestKindRoadside, got %s", hm.ActiveRestKind)
|
||||||
|
}
|
||||||
|
if hm.RestUntil.IsZero() {
|
||||||
|
t.Fatal("expected RestUntil to be set for roadside rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoadsideRest_DoesNotTriggerAboveThreshold(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
cfg := tuning.Get()
|
||||||
|
maxHP := 1000
|
||||||
|
safeHP := int(float64(maxHP)*cfg.LowHpThreshold) + 10
|
||||||
|
|
||||||
|
hero := testHeroOnRoad(1, safeHP, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.State = model.StateWalking
|
||||||
|
hm.Hero.State = model.StateWalking
|
||||||
|
hm.LastEncounterAt = now
|
||||||
|
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
|
||||||
|
t.Fatal("should not trigger roadside rest above threshold")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoadsideRest_HealsHP(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
cfg := tuning.Get()
|
||||||
|
maxHP := 10000
|
||||||
|
hero := testHeroOnRoad(1, 100, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginRoadsideRest(now)
|
||||||
|
|
||||||
|
hpBefore := hm.Hero.HP
|
||||||
|
tick := now.Add(10 * time.Second)
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.Hero.HP <= hpBefore {
|
||||||
|
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
|
||||||
|
}
|
||||||
|
expectedGain := int(float64(maxHP) * cfg.RoadsideRestHpPerS * 10)
|
||||||
|
actualGain := hm.Hero.HP - hpBefore
|
||||||
|
if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
|
||||||
|
t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoadsideRest_ExitsByTimer(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
maxHP := 10000
|
||||||
|
hero := testHeroOnRoad(1, 1, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginRoadsideRest(now)
|
||||||
|
|
||||||
|
pastTimer := hm.RestUntil.Add(time.Second)
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected StateWalking after timer, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoadsideRest_ExitsByHPThreshold(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)
|
||||||
|
|
||||||
|
tick := now.Add(time.Second)
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected StateWalking after HP threshold, got %s", hm.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoadsideRest_DisplayOffset(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
maxHP := 1000
|
||||||
|
hero := testHeroOnRoad(1, 100, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginRoadsideRest(now)
|
||||||
|
|
||||||
|
ox, oy := hm.displayOffset(now)
|
||||||
|
if ox == 0 && oy == 0 {
|
||||||
|
t.Fatal("expected non-zero display offset during roadside rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Adventure inline rest ---
|
||||||
|
|
||||||
|
func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
cfg := tuning.Get()
|
||||||
|
maxHP := 1000
|
||||||
|
lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1
|
||||||
|
|
||||||
|
hero := testHeroOnRoad(1, lowHP, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.State = model.StateWalking
|
||||||
|
hm.Hero.State = model.StateWalking
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
|
||||||
|
tick := now.Add(time.Duration(cfg.AdventureOutDurationMs+1000) * time.Millisecond)
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.State != model.StateResting {
|
||||||
|
t.Fatalf("expected StateResting, got %s", hm.State)
|
||||||
|
}
|
||||||
|
if hm.ActiveRestKind != model.RestKindAdventureInline {
|
||||||
|
t.Fatalf("expected RestKindAdventureInline, got %s", hm.ActiveRestKind)
|
||||||
|
}
|
||||||
|
if !hm.Excursion.Active() {
|
||||||
|
t.Fatal("excursion should remain active during inline rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdventureInlineRest_HealsHP(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
cfg := tuning.Get()
|
||||||
|
maxHP := 10000
|
||||||
|
hero := testHeroOnRoad(1, 100, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
hm.Excursion.Phase = model.ExcursionWild
|
||||||
|
hm.beginAdventureInlineRest(now)
|
||||||
|
|
||||||
|
hpBefore := hm.Hero.HP
|
||||||
|
tick := now.Add(10 * time.Second)
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.Hero.HP <= hpBefore {
|
||||||
|
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
|
||||||
|
}
|
||||||
|
expectedGain := int(float64(maxHP) * cfg.AdventureRestHpPerS * 10)
|
||||||
|
actualGain := hm.Hero.HP - hpBefore
|
||||||
|
if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
|
||||||
|
t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdventureInlineRest_ExitsByHPTarget(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)
|
||||||
|
|
||||||
|
tick := now.Add(time.Second)
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected StateWalking after HP target, got %s", hm.State)
|
||||||
|
}
|
||||||
|
if !hm.Excursion.Active() {
|
||||||
|
t.Fatal("excursion should still be active after inline rest exits by HP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
maxHP := 10000
|
||||||
|
hero := testHeroOnRoad(1, 1, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
hm.beginAdventureInlineRest(now)
|
||||||
|
|
||||||
|
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
|
||||||
|
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected StateWalking after excursion end, got %s", hm.State)
|
||||||
|
}
|
||||||
|
if hm.Excursion.Active() {
|
||||||
|
t.Fatal("excursion should be cleared after return phase ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdventureInlineRest_NoTimerFieldSet(t *testing.T) {
|
||||||
|
hero := testHeroOnRoad(1, 100, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
graph := testGraph()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
hm.beginAdventureInlineRest(now)
|
||||||
|
|
||||||
|
if !hm.RestUntil.IsZero() {
|
||||||
|
t.Fatal("adventure inline rest should not set RestUntil (HP-based only)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Persistence ---
|
||||||
|
|
||||||
|
func TestRoadsideRest_Persistence(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
maxHP := 1000
|
||||||
|
hero := testHeroOnRoad(1, 100, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginRoadsideRest(now)
|
||||||
|
hm.SyncToHero()
|
||||||
|
|
||||||
|
if hero.RestKind != model.RestKindRoadside {
|
||||||
|
t.Fatalf("expected hero.RestKind = roadside, got %s", hero.RestKind)
|
||||||
|
}
|
||||||
|
if hero.TownPause == nil {
|
||||||
|
t.Fatal("expected TownPause to be set")
|
||||||
|
}
|
||||||
|
if hero.TownPause.RestKind != model.RestKindRoadside {
|
||||||
|
t.Fatalf("expected TownPause.RestKind = roadside, got %s", hero.TownPause.RestKind)
|
||||||
|
}
|
||||||
|
if hero.TownPause.RestUntil == nil || hero.TownPause.RestUntil.IsZero() {
|
||||||
|
t.Fatal("expected TownPause.RestUntil to be set for roadside rest")
|
||||||
|
}
|
||||||
|
|
||||||
|
hero2 := *hero
|
||||||
|
hm2 := NewHeroMovement(&hero2, graph, now)
|
||||||
|
if hm2.State != model.StateResting {
|
||||||
|
t.Fatalf("expected restored state = resting, got %s", hm2.State)
|
||||||
|
}
|
||||||
|
if hm2.ActiveRestKind != model.RestKindRoadside {
|
||||||
|
t.Fatalf("expected restored rest kind = roadside, got %s", hm2.ActiveRestKind)
|
||||||
|
}
|
||||||
|
if hm2.RestUntil.IsZero() {
|
||||||
|
t.Fatal("expected restored RestUntil to be non-zero")
|
||||||
|
}
|
||||||
|
if hm2.Road == nil {
|
||||||
|
t.Fatal("expected road to be assigned for roadside rest reconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdventureInlineRest_Persistence(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
maxHP := 1000
|
||||||
|
hero := testHeroOnRoad(1, 100, maxHP)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
hm.Excursion.Phase = model.ExcursionWild
|
||||||
|
hm.beginAdventureInlineRest(now)
|
||||||
|
hm.SyncToHero()
|
||||||
|
|
||||||
|
if hero.RestKind != model.RestKindAdventureInline {
|
||||||
|
t.Fatalf("expected hero.RestKind = adventure_inline, got %s", hero.RestKind)
|
||||||
|
}
|
||||||
|
if hero.TownPause == nil {
|
||||||
|
t.Fatal("expected TownPause to be set")
|
||||||
|
}
|
||||||
|
if hero.TownPause.RestKind != model.RestKindAdventureInline {
|
||||||
|
t.Fatalf("expected TownPause.RestKind = adventure_inline, got %s", hero.TownPause.RestKind)
|
||||||
|
}
|
||||||
|
if hero.TownPause.Excursion == nil {
|
||||||
|
t.Fatal("expected TownPause.Excursion to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
hero2 := *hero
|
||||||
|
hm2 := NewHeroMovement(&hero2, graph, now)
|
||||||
|
if hm2.State != model.StateResting {
|
||||||
|
t.Fatalf("expected restored state = resting, got %s", hm2.State)
|
||||||
|
}
|
||||||
|
if hm2.ActiveRestKind != model.RestKindAdventureInline {
|
||||||
|
t.Fatalf("expected restored rest kind = adventure_inline, got %s", hm2.ActiveRestKind)
|
||||||
|
}
|
||||||
|
if !hm2.Excursion.Active() {
|
||||||
|
t.Fatal("expected excursion to be restored")
|
||||||
|
}
|
||||||
|
if hm2.Road == nil {
|
||||||
|
t.Fatal("expected road to be assigned for adventure inline rest reconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin ---
|
||||||
|
|
||||||
|
func TestAdminStartRoadsideRest(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
|
||||||
|
if !hm.AdminStartRoadsideRest(now) {
|
||||||
|
t.Fatal("AdminStartRoadsideRest should succeed")
|
||||||
|
}
|
||||||
|
if hm.State != model.StateResting {
|
||||||
|
t.Fatalf("expected resting, got %s", hm.State)
|
||||||
|
}
|
||||||
|
if hm.ActiveRestKind != model.RestKindRoadside {
|
||||||
|
t.Fatalf("expected roadside, got %s", hm.ActiveRestKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStartRoadsideRest_RejectsDead(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 0, 1000)
|
||||||
|
hero.State = model.StateDead
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
|
||||||
|
if hm.AdminStartRoadsideRest(now) {
|
||||||
|
t.Fatal("AdminStartRoadsideRest should reject dead hero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStopRest_Roadside(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 100, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginRoadsideRest(now)
|
||||||
|
|
||||||
|
if !hm.AdminStopRest(now) {
|
||||||
|
t.Fatal("AdminStopRest should succeed for roadside rest")
|
||||||
|
}
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected walking, got %s", hm.State)
|
||||||
|
}
|
||||||
|
if hm.ActiveRestKind != model.RestKindNone {
|
||||||
|
t.Fatalf("expected rest kind none, got %s", hm.ActiveRestKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStopRest_AdventureInline(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 100, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
hm.beginAdventureInlineRest(now)
|
||||||
|
|
||||||
|
if !hm.AdminStopRest(now) {
|
||||||
|
t.Fatal("AdminStopRest should succeed for adventure inline rest")
|
||||||
|
}
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected walking, got %s", hm.State)
|
||||||
|
}
|
||||||
|
if hm.Excursion.Active() {
|
||||||
|
t.Fatal("excursion should be cleared after admin stop rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStopRest_RejectsTownRest(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.AdminStartRest(now, graph)
|
||||||
|
|
||||||
|
if hm.AdminStopRest(now) {
|
||||||
|
t.Fatal("AdminStopRest should reject town rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStopRest_RejectsWalking(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
|
||||||
|
if hm.AdminStopRest(now) {
|
||||||
|
t.Fatal("AdminStopRest should reject walking hero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStartExcursion(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
|
||||||
|
if !hm.AdminStartExcursion(now) {
|
||||||
|
t.Fatal("AdminStartExcursion should succeed")
|
||||||
|
}
|
||||||
|
if !hm.Excursion.Active() {
|
||||||
|
t.Fatal("excursion should be active")
|
||||||
|
}
|
||||||
|
if hm.Excursion.Phase != model.ExcursionOut {
|
||||||
|
t.Fatalf("expected phase out, got %s", hm.Excursion.Phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStartExcursion_RejectsWhenAlreadyActive(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
|
||||||
|
if hm.AdminStartExcursion(now) {
|
||||||
|
t.Fatal("AdminStartExcursion should reject when excursion already active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStartExcursion_RejectsNotWalking(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginRoadsideRest(now)
|
||||||
|
|
||||||
|
if hm.AdminStartExcursion(now) {
|
||||||
|
t.Fatal("AdminStartExcursion should reject when not walking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStopExcursion_WhileWalking(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
|
||||||
|
if !hm.AdminStopExcursion(now) {
|
||||||
|
t.Fatal("AdminStopExcursion should succeed")
|
||||||
|
}
|
||||||
|
if hm.Excursion.Active() {
|
||||||
|
t.Fatal("excursion should be cleared")
|
||||||
|
}
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected walking, got %s", hm.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 100, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
hm.beginExcursion(now)
|
||||||
|
hm.beginAdventureInlineRest(now)
|
||||||
|
|
||||||
|
if !hm.AdminStopExcursion(now) {
|
||||||
|
t.Fatal("AdminStopExcursion should succeed from inline rest")
|
||||||
|
}
|
||||||
|
if hm.Excursion.Active() {
|
||||||
|
t.Fatal("excursion should be cleared")
|
||||||
|
}
|
||||||
|
if hm.State != model.StateWalking {
|
||||||
|
t.Fatalf("expected walking, got %s", hm.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStopExcursion_RejectsNone(t *testing.T) {
|
||||||
|
graph := testGraph()
|
||||||
|
hero := testHeroOnRoad(1, 500, 1000)
|
||||||
|
now := time.Now()
|
||||||
|
hm := NewHeroMovement(hero, graph, now)
|
||||||
|
|
||||||
|
if hm.AdminStopExcursion(now) {
|
||||||
|
t.Fatal("AdminStopExcursion should reject when no excursion")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ExcursionPhase tracks where the hero is within a mini-adventure session.
|
||||||
|
// The lifecycle is: Out → Wild → Return → (back to road, phase cleared).
|
||||||
|
type ExcursionPhase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExcursionNone ExcursionPhase = ""
|
||||||
|
ExcursionOut ExcursionPhase = "out" // moving off-road into the forest
|
||||||
|
ExcursionWild ExcursionPhase = "wild" // in the wilderness (encounters happen here)
|
||||||
|
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestKind discriminates the context of a StateResting period.
|
||||||
|
type RestKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RestKindNone RestKind = ""
|
||||||
|
RestKindTown RestKind = "town"
|
||||||
|
RestKindRoadside RestKind = "roadside"
|
||||||
|
RestKindAdventureInline RestKind = "adventure_inline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion).
|
||||||
|
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
|
||||||
|
type ExcursionSession struct {
|
||||||
|
Phase ExcursionPhase
|
||||||
|
StartedAt time.Time
|
||||||
|
|
||||||
|
// WildUntil marks the end of the wild phase; once reached the hero begins returning.
|
||||||
|
WildUntil time.Time
|
||||||
|
// ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
|
||||||
|
ReturnUntil time.Time
|
||||||
|
|
||||||
|
// DepthWorldUnits is the max perpendicular distance from the road spine for this session.
|
||||||
|
DepthWorldUnits float64
|
||||||
|
|
||||||
|
// RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero
|
||||||
|
// left the road, so it can be restored exactly when the excursion ends.
|
||||||
|
RoadFreezeWaypoint int
|
||||||
|
RoadFreezeFraction float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active reports whether an excursion session is in progress.
|
||||||
|
func (s *ExcursionSession) Active() bool {
|
||||||
|
return s.Phase != ExcursionNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the
|
||||||
|
// heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
|
||||||
|
type ExcursionPersisted struct {
|
||||||
|
Phase string `json:"phase,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
WildUntil *time.Time `json:"wildUntil,omitempty"`
|
||||||
|
ReturnUntil *time.Time `json:"returnUntil,omitempty"`
|
||||||
|
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
|
||||||
|
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
|
||||||
|
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
-- Seed excursion / roadside-rest tuning into runtime_config.payload (merged with existing keys).
|
||||||
|
UPDATE runtime_config
|
||||||
|
SET
|
||||||
|
payload = payload || '{
|
||||||
|
"adventureStartChance": 0.0001,
|
||||||
|
"adventureCooldownMs": 300000,
|
||||||
|
"adventureOutDurationMs": 20000,
|
||||||
|
"adventureWildMinMs": 560000,
|
||||||
|
"adventureWildMaxMs": 2960000,
|
||||||
|
"adventureReturnDurationMs": 20000,
|
||||||
|
"adventureDepthWorldUnits": 20,
|
||||||
|
"adventureEncounterCooldownMs": 6000,
|
||||||
|
"adventureReturnEncounterEnabled": true,
|
||||||
|
"lowHpThreshold": 0.25,
|
||||||
|
"roadsideRestExitHp": 0.7,
|
||||||
|
"adventureRestTargetHp": 0.7,
|
||||||
|
"roadsideRestMinMs": 240000,
|
||||||
|
"roadsideRestMaxMs": 600000,
|
||||||
|
"roadsideRestHpPerSecond": 0.003,
|
||||||
|
"adventureRestHpPerSecond": 0.004
|
||||||
|
}'::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = TRUE;
|
||||||
Loading…
Reference in New Issue