diff --git a/.cursor/.cursorignore b/.cursor/.cursorignore new file mode 100644 index 0000000..6975b46 --- /dev/null +++ b/.cursor/.cursorignore @@ -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 \ No newline at end of file diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go new file mode 100644 index 0000000..e840fb4 --- /dev/null +++ b/backend/internal/game/rest_test.go @@ -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") + } +} diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 686c33f..ffddedc 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -84,7 +84,7 @@ type adminHeroDetailResponse struct { AdminLiveMovement *adminLiveMovementJSON `json:"adminLiveMovement,omitempty"` } -func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLiveMovementJSON { +func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON { if hm == nil { return nil } diff --git a/backend/internal/model/excursion.go b/backend/internal/model/excursion.go new file mode 100644 index 0000000..34da908 --- /dev/null +++ b/backend/internal/model/excursion.go @@ -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"` +} diff --git a/backend/migrations/000028_runtime_config_adventure_excursion.sql b/backend/migrations/000028_runtime_config_adventure_excursion.sql new file mode 100644 index 0000000..54128e9 --- /dev/null +++ b/backend/migrations/000028_runtime_config_adventure_excursion.sql @@ -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; diff --git a/docs/art-bible.md b/docs/art-bible.md new file mode 100644 index 0000000..80cd70f --- /dev/null +++ b/docs/art-bible.md @@ -0,0 +1,128 @@ +# AutoHero — Art Bible (Sprite Pipeline) + +**Purpose:** Single source of truth for mood, stylization, palette, and perspective so terrain tiles, props, buildings, heroes, NPCs, and enemies read as **one game**, not mixed asset packs. Use this for hand-off to artists, AI prompts, and internal reviews. + +**Scope:** Static sprite art (phase 1). Animation adds frames later; this bible still governs silhouette, lighting, and palette on every frame. + +**Related:** Content IDs remain defined in [`specification-content-catalog.md`](specification-content-catalog.md). Technical export sizes and keys for generation live in the sprite migration plan (repository plans; do not treat this file as the prompt bank). + +--- + +## 1. Creative direction + +| Axis | Direction | +|------|-----------| +| **Genre & mood** | Dark fantasy: dangerous, heavy, lived-in. Not a bright fairy tale. Hope exists as small sparks (lanterns, embers), not candy colors. | +| **Tone** | Brutal but readable: heroes and monsters feel physically convincing and threatening. Environment supports hardness (ruins, metal, harsh nature) without cute simplification. | +| **Stylization** | **Arcane-like:** painterly treatment, **strong silhouettes**, cinematic light and shadow, volume from large forms and careful shading—not from noisy micro-detail on tiny sprites. | +| **Clarity** | Icons and combat readability first: silhouette and value separation beat texture noise at small sizes. | + +**English prompt prefix (for consistency across generation briefs):** + +```text +Dark fantasy, brutal mood, stylized painterly look inspired by Arcane series, +cinematic lighting, strong readable silhouette, muted desaturated colors with rare accent highlights, +single game asset, transparent background, no text, no watermark, no border. +Isometric 45° or three-quarter view, consistent with tile-based RPG ground plane. +``` + +**Краткий бриф (RU):** мрачное фэнтези, брутально, стилизация в духе Arcane, приглушённая палитра, один объект, прозрачный фон, изометрия или три четверти, единый ракурс со сценой. + +--- + +## 2. Visual pillars (non-negotiables) + +1. **Silhouette first** — Every unit and large prop must read clearly in grayscale; interior line detail is secondary. +2. **One lighting story** — Key light feels directional (often top-left or front-top); rim or bounce sparingly. Avoid flat ambient fill that kills depth. +3. **Painterly, not noisy** — Broad strokes, controlled edges. No high-frequency speckle that turns to mush when scaled down. +4. **Unified detail density** — Tile, prop, and character art share the same “resolution of idea”: no hyper-detailed hero next to blob ground. +5. **Restrained VFX** — Magic, fire, blood, lightning are **accents** (see palette), not full-neon coverage. + +--- + +## 3. Color & palette + +### 3.1 Base (majority of each asset) + +- **Neutrals:** Deep desaturated browns, cold grays, blue-grays, muted olive and pine greens. +- **Value range:** Favor mid-to-dark; highlights are **narrow** and purposeful (metal edge, wet surface, spell core). +- **Saturation:** Globally **low**; increase saturation only for intentional focal points. + +### 3.2 Accents (use sparingly) + +Deploy accents to guide the eye: magic (teal, violet, cold blue), fire and embers (warm orange-red, not neon), blood (deep crimson, not bright cherry), metal (cool specular), bioluminescence (subtle cyan-green in swamps). + +**Rule:** If the whole sprite feels “colorful,” reduce saturation until the piece matches the base row, then add **one** accent zone. + +### 3.3 Avoid + +- Acid or toy primaries across large areas. +- Rainbow gradients on materials. +- Pure white (#FFFFFF) or pure black (#000000) except tiny specular hits or deep creases. + +--- + +## 4. Camera, perspective, and ground contact + +### 4.1 World alignment + +- The game world uses an isometric diamond grid: **96×48 px** per cell (`TILE_WIDTH` × `TILE_HEIGHT` in `frontend/src/shared/constants.ts`). +- Ground tiles are **2:1 rhombus** fills; props and buildings sit on that ground plane. + +### 4.2 Single viewpoint + +- **Units and buildings:** **Three-quarter / isometric-style 3/4** view, consistent “camera-right” or three-quarter toward the camera (match existing renderer facing conventions; do not mix side-scroller flat with isometric props in the same set). +- **Tiles:** Top-down isometric diamond; texture fills the rhombus; corners may be transparent on a slightly larger canvas if needed. +- **UI weapon icons (HUD):** Not world-isometric; **side or slight top** for readability at 48–64 px (see migration plan). + +### 4.3 Anchor + +- **Bottom-center** anchor for standing entities and vertical props: feet, trunk base, or building foundation centered on the contact point. Keeps `zIndex` sorting by screen `y` meaningful. + +--- + +## 5. Scale cues (relative to grid) + +These are **art targets**, not engine constraints: + +| Category | Note | +|----------|------| +| **Tiles** | Art fits the 2:1 rhombus; target canvas per prompt bank (often 96×48 export). | +| **Tall props (trees, ruins)** | Extend above the tile; anchor at ground. | +| **Buildings** | Footprint roughly **~2 tiles wide**; height typically **220–280 px** range in doc prompts. | +| **Player / NPC** | ~**128×160** canvas class; consistent with each other. | +| **Base enemies** | Slightly larger than a human (~10–20%); **elites** ~**+15%** height and stronger silhouette/VFX read. | + +Adjust only within the migration plan’s size tables; do not invent new keys without updating the content catalog. + +--- + +## 6. Do / don’t (review checklist) + +| Do | Don’t | +|----|--------| +| Match silhouette and value to Arcane-like painterly discipline | Mix unrelated styles (anime flat + gritty realistic) in one asset set | +| Keep ground tiles seamless at edges (where applicable) | Add readable text, logos, or watermarks on textures | +| Use muted palette with rare accents | Cover surfaces with loud saturated gradients | +| Align perspective with §4 | Rotate the same prop to contradictory viewpoints across variants | +| Export **PNG with alpha**, one object per file as per pipeline | Embed borders or frames in the texture | + +--- + +## 7. References (workflow) + +- **Primary:** *Arcane* (Netflix)—lighting, silhouette, painterly surfaces, restrained saturation. +- **Use references for:** mood boards and lighting—not for copying identifiable characters or logos. +- **Internal:** Place approved reference stills or links in team channels / design wiki; this repo file stays text-only so it stays easy to diff and version. + +--- + +## 8. Sign-off + +Art direction is **locked** for phase-1 sprite replacement when: + +- All new sprites are reviewed against §§1–6, +- Palette and perspective exceptions are documented per asset only when technically required (e.g. HUD icons), +- Placeholders (if used) follow the same muted palette and anchor rules until final art lands. + +**Document owner:** Art / creative direction (update this file when palette or perspective rules change).