diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md
index 91ad6af..d05d338 100644
--- a/src/features/editScene/AGENTS.md
+++ b/src/features/editScene/AGENTS.md
@@ -308,7 +308,10 @@ cd demos/demo-taxi
# hour reproduces positions bit-exactly (pure clock evaluation):
./demoTaxi --headless --test-motion
# ...or headless check of the NPC high-detail tier (phase 4.7.6): paused
-# clock, every population NPC spawns within the per-frame budget exactly
+# clock; the commute scrub deterministically scans candidate hours and
+# picks the one with the most spawn-eligible NPCs (the weekly whole-hour
+# arrivals turned a fixed hour into a structural dead zone, 6.8.4b);
+# then every eligible NPC spawns within the per-frame budget exactly
# at its symbolic position, nobody despawns inside the hysteresis band,
# all despawn cleanly and the symbolic positions are restored bit-exactly:
./demoTaxi --headless --test-detail
@@ -339,6 +342,35 @@ cd demos/demo-taxi
# checks the live drop-off against the claimed park pose and carjacks
# the car to verify fleet/claim release:
./demoTaxi --headless --test-parking
+# ...or headless week-rollover probe (phase 6.8.4b): the runtime runs the
+# weekly day-of-week tiling, so the pure motion evaluation must reproduce
+# positions bit-exactly across a week boundary (same hours on day 0 vs
+# day 7 — the same weekly Monday view) and the weekend template must
+# visibly differ from the workday (Monday vs Saturday at noon):
+./demoTaxi --headless --test-week-rollover
+# ...or headless DSL interior-POI probe (phase 6.8.6): previews a layout
+# whose rooms declare POIs through the :poi option, asserting the runtime
+# PoiComponent entities (stable ids merged into the town's POI database),
+# the ensured E-prompt actuator, the OnApproach behavior fire and the
+# OnArrive Lua event, plus a re-preview reproducing every poiId:
+./demoTaxi --headless --test-dsl-poi
+# ...or headless week-revision probe (phase 6.8.10): an explicit
+# mid-week revision re-resolves the REST of the current week with the
+# CURRENT stats snapshot, spliced at the current clock (in-flight
+# goals finish first), recorded as a revision marker and saved with
+# the game (save → reload keeps weeks and revisions bit-identically):
+./demoTaxi --headless --test-week-revision
+# ...or headless emergency probe (phase 6.8.14): loads the shipped
+# stats rules module through the app's Lua state (registering the
+# emergency thresholds), drops the selected NPC's health through
+# ecs.stats.set, and asserts the emergency begins (the highest-
+# priority goal), the effective schedule is the 24-entry synthetic
+# at the hospital (or home rest without a hospital), the NPC is
+# transported there symbolically, save → reload restores it,
+# discharge resumes the pure baseline with no leftover state, taxi
+# goals are refused while it lasts, and endEmergency on a
+# non-emergency id is a no-op:
+./demoTaxi --headless --test-emergency
# ...or headless animation-travel probe (phase 6.7): on the default
# interior scene, a synthetic registry tree probes the animation state
# machine travel — startState boot marker, travelTo hop-through (no
@@ -763,7 +795,10 @@ name-suffix parsing), and — per building — either the **commercial pair
inline sources and hand-built results) carries a commercial tag
(`isCommercialBuilding`: shop/commercial/office/leisure) yields BOTH a
Work location (`work:
`, tags + the job tag from
-`jobTagForBuilding`: shop→salesman, office→clerk, else worker) and a
+`jobTagForBuilding`: shop→salesman, office→clerk, else worker —
+with 6.8.7 the special service tags map FIRST (police→police,
+ambulance→medic, firefighter→firefighter, hospital→doctor) and
+join the commercial zoning set) and a
Leisure location (`leisure:`, the building's tags) — same
building, different kind, tags and audience (a shop is a workplace for
employees and a leisure venue for customers) — both anchored at the
@@ -772,7 +807,34 @@ zoned buildings join NO Interest roll (that roll was the placeholder
for exactly this), while tagless buildings keep the legacy roll
("old towns without tags keep the Interest fallback").
`NpcLocation` carries `tags` (the building's tags + the Work job tag)
-and `entranceRole` ("front" for generated locations). The
+and `entranceRole` ("front" for generated locations; "back" for
+employee Work locations when the layout declares a back door —
+6.8.8: employee Work locations anchor at the back doorstep and
+`appendEntries` emits the matching `entrance::back` POI,
+with `entryOfBuildingForRole` / `approachPosition` resolving the
+employee entry per role; customer (Leisure) and home keep the
+front). `TownBuilding` records the front/back/side doorsteps in
+TownGen from the plan's door flags, so a `(door back ...)` layout
+makes the endpoints differ while single-door buildings stay
+bit-identical. The candidate score measures against
+`TownLocations::approachPosition(loc)` (the building entry,
+6.8.12). **Reference layouts + stat effects (6.8.13):**
+`town_layouts.txt` ships `ref-home`/`ref-shop`/`ref-park` (one per
+location kind); rooms declare location-level stat effects with
+`:effects ((stat delta) ...)` (`BuildingPlan::roomStatEffects`),
+merged by `TownLocations::buildLocations` onto the Work/Leisure/
+Home locations by room-type role match (work =
+shop/store/office/commercial/work/shopfront/storage; leisure =
+park/cafe/bar/leisure; home = bedroom/living/livingroom/home/
+residence). On COMPLETED schedule entries
+`NpcMotionSystem` applies `loc.statEffects × hours` through
+`systems/StatEffectsApplier::applyLocationEffects`
+(`CharacterStatStore`, clamped), keyed
+`(npc, location, startHour)` so each entry applies exactly once
+per run (a back-scrub never re-applies; the completed set is
+runtime-only). Home entries are idle rest — no activation and no
+behavior trees at home; the home location's weaker stress
+recovery is just data. The
`LocationSet` keeps a lazy tag→indices + building→indices index
(deterministic, location order; `indicesWithTag()` /
`indicesOfBuilding()`; call `reindex()` after direct mutation of
@@ -847,7 +909,7 @@ WeekScheduleRules, stats = {}, scoreHook = {}, warnings* )`:
only) for the stat-driven choices (6.8.10).
- Fallbacks warn once per distinct line per build.
-**Runtime store (6.8.4a — runtime motion/traffic NOT flipped yet):**
+**Runtime store (6.8.4a) + day-tiling flip (6.8.4b):**
`NpcPopulationSystem::generateAreaPopulation` runs the weekly build
alongside the daily one (seed `effSeed ^ kWeekScheduleSeedXor
0x27d4eb2f`, default rules) and stores it on the area —
@@ -855,21 +917,136 @@ alongside the daily one (seed `effSeed ^ kWeekScheduleSeedXor
`weekUsageOf(areaIndex)` the build's pick tally. The scoring flows
through an adapter onto `systems/ScheduleScoring::score`, so
`ecs.schedule.set_score_hook` steers weekly picks too; stats
-snapshots stay empty until 6.8.10. **Migration:** the
+snapshots stay empty until 6.8.10. **The runtime consumes the week
+(6.8.4b):** pure `TownSchedule::splitWeekToDays(week)` cuts one
+DAILY-shaped `NpcSchedule` per day-of-week (that day's whole-hour
+[0, 24) tiling), the store keeps the views (`weekDayViews`, built at
+generation time) and `weekDayScheduleOf(registryId, day)` returns
+them; `NpcMotionSystem::update` and both traffic call sites (the
+`rebuildClaims` day-arc replay and `evalSymbolic`) evaluate
+`weekDayScheduleOf(id, absDay % 7)`, falling back to the legacy
+daily schedule when the area has no weekly build — every consumer
+keeps its daily-shaped `entryAt()` machinery, so walking and driving
+cannot disagree. **Migration:** the
`npcWork`/`npcInterest` registry columns rebind to the weekly
resolves (the first week's sticky `work:` and the first
`leisure:` pick; the old Interest placeholders remain only
-for tagless areas). Pure probes stay bit-identical; the runtime
-swap (motion `entryAt`/helpers + traffic legs) is 6.8.4b.
+for tagless areas). Pure probes stay bit-identical.
`NpcScheduleDebugOverlay` (**F7**) gained a Week pane per selected
NPC: Mon-Sun day selector (initial = the clock's day-of-week), a
"today" line, From/To/Activity/**Query -> Location** rows and the
usage tally (top-4).
+**Realism rules (6.8.5, pure defaults are no-ops):**
+`WeekScheduleRules::openHoursByTag` gives venue hours per tag
+(first tag in the location's tag order wins; unmatched = always
+open) and `softCapacity`/`capacityByTag` sink a candidate's score
+past its resolved pick limit (`TownScheduleScore::Candidate::
+capacity` + `Weights::capacity`, mirrored through
+`systems/ScheduleScoring` — one kernel; the Lua hook still works).
+The generator filters BOTH pools (the sticky-job resolve for the
+work block the member will hold, and the per-day leisure slot) to
+venues open for the slot's FULL span and falls back otherwise
+(leisure → curb → interest → home; work → binding when the town
+has no Work locations, HOME when Work exists but all are shut —
+the closing pull). Default towns keep their weekly builds
+bit-identical; `NpcPopulationSystem::demoWeeklyRules()` opts the
+demo in (shop 9-18, soft capacity 8). Realism fixture checks live
+in the week probe (`runTownWeekScheduleProbe`).
+
+**Stat-driven weekly variety (6.8.10):** `generateWeekSchedules`
+takes a stats snapshot map with ONLY members that have EXPLICIT
+stat values (defaults stay inert). Per-NPC isolated draws shape
+the week: stress > 60 → more leisure, money < 25 → fewer paid
+outings plus an extra weekend job shift, health < 30 → a
+deterministic full-home rest day, stress > 85 → a deterministic
+full-home rest day; the baseline is bit-identical with an empty
+snapshot. `NpcLocation::statEffects` rides the POI
+feed/projection into `WeekCandidate::effects` (the hook can prefer
+money-positive or stress-relieving venues while the default score
+stays stats-free). The weekly store (`weekSchedules` /
+`weekDayViews` / `weekUsage` / `weekStats` / `rosters` /
+`revisions`) rides `saveData["npcWeekSchedules"]`
+(`NpcPopulationSystem::serializeWeekSchedules` /
+`deserializeWeekSchedules`); old saves without it regenerate from
+defaults. `NpcPopulationSystem::reviseWeek(areaIndex, reason,
+graphVersion)` re-runs the build with the CURRENT stats and
+splices at the current absolute clock via
+`TownSchedule::spliceWeekAfter` — past days identical, in-flight
+entry kept whole, rest of week rewritten, revision markers
+appended. `EditorApp::loadGame` deactivates terrain before
+loading a new base scene (otherwise `m_active` stayed true with a
+stale `m_terrainEntityId`), and revision history is preserved both
+across `generateAreaPopulation` and the update's
+clear+regenerate cycle. Coverage: `runWeekVarietyProbe` in
+road_geometry_overlap_test + `demoTaxi --headless
+--test-week-revision`.
+Clock-pure verification gates worth remembering: the whole-hour
+weekly arrivals make the walk/drive windows 1-2 game-minutes wide,
+the demo `--test-traffic-schedule`/`--test-parking` sweeps probe
+~1.7 min apart, and `GameClockSystem::update` RE-ADOPTS the first
+sun on any divergence — including `timeSpeed` — taking the sun's
+TIME with it, so a test that sets the clock speed to a value the
+scene suns do not carry must align the suns' `timeSpeed` first or
+its `setTime` is silently undone (both traffic batteries do).
+`demoTaxi --headless --test-week-rollover` pins the flip: positions
+across the week boundary (day 0 vs day 7 at the same hours) are
+bit-exact and the weekend template differs from the workday.
+
+**Emergency rescheduling + hospitalization (6.8.14):** a
+stat-threshold emergency is the HIGHEST-priority goal, stored as
+per-NPC override state on `AreaPopulation`
+(`EmergencyState{reason, location, startAbs, schedule}`) with
+`beginEmergency`/`endEmergency`/`hasActiveEmergency`/
+`emergencyScheduleOf` on `NpcPopulationSystem`. ALL schedule
+consumers read `effectiveDayScheduleOf(id, day)` (= emergency →
+week day → daily): `NpcMotionSystem::update` and both
+`NpcTrafficSystem` evaluation sites. The synthetic schedule is a
+24-entry whole-hour tiling at the emergency location (activity
+"hospital"), so the clock-pure evaluation transports the NPC
+there symbolically; the destination is the town's hospital Work
+location (the `hospital` tag — the `ref-hospital` layout ships
+one, ward `:effects ((health 5))` healing over time per 6.8.13),
+falling back to `home:` rest with one logged warning
+when the town has none. `beginEmergency` cancels the taxi goal
+and despawns the live car (`NpcTrafficSystem::despawnLiveCar`);
+`NpcTaxiSystem::assignTaxiGoal` refuses during an emergency.
+Thresholds are DATA, registered from the stats rules module via
+`ecs.emergency.set_thresholds{health_below, discharge_at}`
+(`lua/LuaEmergencyApi.cpp`, registered in EditorApp next to the
+rosters API); the engine subscribes ONCE to `CharacterStatStore`
+change events (never polling) and begins/ends emergencies from
+the health crossings. `ecs.emergency.begin/end/active` allow
+scripted emergencies. State rides save/load (the `emergencies`
+section of the week store, synthetic schedule rebuilt from the
+location) and survives the clear+regenerate cycles. F7 shows a
+red EMERGENCY banner (activity, location, live health). Known
+(pre-existing, 4.x-era) robustness gap, found by the battery: a
+detail entity spawned below a road embankment can never reach a
+boarding door above it — the taxi battery rotates candidates on
+stall. Coverage: `demoTaxi --headless --test-emergency`.
+
Runtime is NOT wired yet (6.8.4): motion/traffic keep the daily
`generateSchedules()` product; the week verifier
`TownSchedule::weekEntryAt(week, day, hour)` exists for it.
+**Special-job rosters (6.8.7):** `roadlib/TownRosters` (OGRE-free;
+the weekly-build adapter is `TownRostersWeeks::weekJobMembers`)
+derives per-area rosters as an OUTPUT of the weekly build —
+special tag -> registry-id-ordered `{registryId, stableKey, home,
+work}` members. The special SERVICE roles (police, medic,
+firefighter, doctor) map from the building tags in
+`TownLocations::jobTagForBuilding` (special tags checked before
+shop/office) and count as commercial zoning, so a `police`-tagged
+lot is a Work+Leisure workplace and its adults roster. The
+runtime store: `NpcPopulationSystem::areaRosters(areaIndex)`
+(nullptr without a weekly build); Lua: `ecs.rosters.area(idx)` /
+`ecs.rosters.tags()` / `ecs.rosters.is_special(tag)`; the F7
+population section lists the per-area rosters. No AI behavior —
+DATA for later game systems. Coverage: `runTownRostersProbe`
+(road_geometry_overlap_test) + `rosters_lua_test` (CTest
+`rostersLuaTest`).
+
Verification: `NpcScheduleDebugOverlay` (game mode, toggled with **F7**) shows
the clock (pause/speed/time-of-day controls), the population NPCs grouped by
town area with the schedule entry active at the current time, and the
@@ -1046,10 +1223,13 @@ layer 1 (`layer1Mesh`; layer 0 is the bald scalp) and, when the NPC's
public), garments from layer 2 (`layer2Mesh`) — stored in the record's
`inlineSlotSelections` so buildCharacter resolves real meshes instead of
the nude layer-0 fallback. Headless verification:
-`demoTaxi --headless --test-detail` (paused clock, spawn
-budget + spawn-at-symbolic accuracy, hysteresis hold, full despawn, exact
-symbolic restore; the snapshot settles two frames first because the
-population seeds `rec->position` with the home doorstep and the
+`demoTaxi --headless --test-detail` (paused clock; the commute scrub
+scans candidate hours and picks the one with the most
+spawn-eligible NPCs — the weekly whole-hour arrivals need a
+window-anchored hour, 6.8.4b; then spawn
+budget + spawn-at-symbolic accuracy, hysteresis hold, full despawn,
+exact symbolic restore; the snapshot settles two frames first because
+the population seeds `rec->position` with the home doorstep and the
first pure evaluation overwrites it).
Character rendering performance (phase 5.5, 2026-10-18): two OGRE
@@ -1358,7 +1538,13 @@ endpoints give pure event times, then a global (time, driver-id,
leg)-ordered replay claims the destination slot at the WALK departure
(the live tier needs it to drive to) and releases a visitor origin at
the DRIVE departure (the car physically leaves then). Home spots stay
-permanently claimed. The same clock always reproduces the same
+permanently claimed. The claim-time slot tiers (free bays → free
+overflow → curb chain) and the occupancy-blind timing picks stay
+within the venue's `parkFallbackRadiusM` (80 m default, 0 =
+unlimited — 6.8.5): a full frontage falls to the venue's own curb
+instead of routing a 140-370 m town-wide detour whose live leg
+wedged `--test-traffic-schedule`. The same clock always reproduces
+the same
assignments bit-exactly, so clock scrubs, pauses and scene reloads need
no reconciliation code; the per-leg claimed endpoints are recorded in
`TrafficCar::legClaims` and the route geometry uses them while the
@@ -2102,7 +2288,8 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
overflow parking POI (" parking-ovf", nose along the
street) at the driveway mouth — a car parks there only when every
designated slot is taken (the pick tiers: free bays -> free
- overflow -> curb chain), never stacked (one exclusively-claimed
+ overflow -> curb chain, scoped to `parkFallbackRadiusM` of the
+ venue — 6.8.5), never stacked (one exclusively-claimed
pose) and never in front of the door (it sits at the road end of
the driveway). Narrow lots with neither driveway room nor a
side-wall strip are a PLAN PROBLEM / TODO for the later, more
@@ -2391,6 +2578,20 @@ authored ids — capture `rec.id` BEFORE
authored set) merges components into the area's POI set, so
runtime-added POIs appear without a full regeneration.
+**DSL-authored interior POIs (6.8.6):** the layout DSL declares
+them per room — `(room ... :poi ((name :kind k :tags (...)
+:effect (stat delta) :activation manual|approach|arrive
+:behavior action :lua path :at (x z) :level n :hours (open
+close) :entrance role :radius n :cooldown s :once ...)))`.
+`LayoutLib::planPois` materializes into `TownGenSystem::
+createPlanPois` runtime `PoiComponent` children of the building
+(id `poi..[-fN].`, grid uid
+`town-`, so rebuilds keep ids); the entity's scene node
+hangs at the ROOT at the derived world-render position because the
+proximity scan and the POI-database merge read node positions
+verbatim. Positions are footprint fractions of the shell (the
+room-rect space). Probe: `demoTaxi --headless --test-dsl-poi`.
+
`PoiSystem` (`systems/PoiSystem.hpp/.cpp`, EditorApp-owned,
`getPoiSystem()`, Game+Playing only, updated next to ZoneSystem)
is the mechanical side: it ensures an `ActuatorComponent` for
@@ -2420,7 +2621,13 @@ called from `NpcPopulationSystem` right after the feed) creates id
`entrance:`, kind `entrance`, domain Exterior, tag
`entrance`, role `front`, position = the building's Home location
(the doorstep — "one Home per building" is the enterability proxy).
-Resolution helpers (all pure): `entryId(address)`,
+**6.8.8 (entrance roles):** layouts declaring `(door back ...)` get
+`TownBuilding::hasBackDoor/backDoorstep` (and side-doorsteps), and
+employee Work locations anchor at the back doorstep instead of the
+front pickup POI; `appendEntries` then also emits
+`entrance::back`, and `entryOfBuildingForRole`/
+`approachPosition` resolve the employee (back) entry. Single-door
+buildings keep front-only behavior bit-identically. Resolution helpers (all pure): `entryId(address)`,
`entryOfBuilding(set, building, address)` (index match first, then
address) and `approachPosition(set, rec)` — Interior → the entry
position (falling back to the POI position when the building has no
diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt
index 924d3b3..f6e7dc8 100644
--- a/src/features/editScene/CMakeLists.txt
+++ b/src/features/editScene/CMakeLists.txt
@@ -238,13 +238,16 @@ set(EDITSCENE_SOURCES
lua/LuaSaveLoadApi.cpp
lua/LuaGlobalStateApi.cpp
lua/LuaCharacterStatsApi.cpp
+ lua/LuaEmergencyApi.cpp
lua/LuaScheduleScoringApi.cpp
+ lua/LuaRostersApi.cpp
lua/LuaDoorApi.cpp
lua/LuaHudApi.cpp
lua/LuaFurnitureApi.cpp
lua/LuaTerrainApi.cpp
lua/LuaSceneSwitchApi.cpp
systems/TerrainTests.cpp
+ systems/StatEffectsApplier.cpp
)
set(EDITSCENE_HEADERS
@@ -867,6 +870,31 @@ add_test(NAME scheduleScoringTest
COMMAND schedule_scoring_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
+# ---------------------------------------------------------------------------
+# Test: Phase 6.8.7 roster Lua binding (headless)
+# ---------------------------------------------------------------------------
+# The pure derivation (roadlib/TownRosters) + the ecs.rosters binding;
+# no OGRE, no flecs — the provider pattern is exercised with a fake area.
+add_executable(rosters_lua_test
+ tests/rosters_lua_test.cpp
+ roadlib/TownRosters.cpp
+ lua/LuaRostersApi.cpp
+)
+
+target_link_libraries(rosters_lua_test
+ lua
+ nlohmann_json::nlohmann_json
+)
+
+target_include_directories(rosters_lua_test PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
+)
+
+add_test(NAME rostersLuaTest
+ COMMAND rosters_lua_test
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
+
# ---------------------------------------------------------------------------
# Test: Phase 6.8.9 character stat container (headless)
# ---------------------------------------------------------------------------
@@ -988,6 +1016,10 @@ add_library(RoadGeometryLib STATIC
roadlib/TownSchedule.hpp
roadlib/TownScheduleScore.cpp
roadlib/TownScheduleScore.hpp
+ roadlib/TownRosters.cpp
+ roadlib/TownRosters.hpp
+ roadlib/TownRostersWeeks.cpp
+ roadlib/TownRostersWeeks.hpp
roadlib/PoiDatabase.cpp
roadlib/PoiDatabase.hpp
)
@@ -1033,6 +1065,8 @@ target_include_directories(RoadGeometryDemo PRIVATE
# "Ax Ay Az Bx By Bz Cx Cy Cz" analyse a single custom configuration.
add_executable(road_geometry_overlap_test
tests/road_geometry_overlap_test.cpp
+ systems/CharacterStatStore.cpp
+ systems/StatEffectsApplier.cpp
)
target_link_libraries(road_geometry_overlap_test
diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp
index 2ac3bee..4df6567 100644
--- a/src/features/editScene/EditorApp.cpp
+++ b/src/features/editScene/EditorApp.cpp
@@ -73,6 +73,8 @@
#include "lua/LuaGlobalStateApi.hpp"
#include "lua/LuaCharacterStatsApi.hpp"
#include "lua/LuaScheduleScoringApi.hpp"
+#include "lua/LuaRostersApi.hpp"
+#include "lua/LuaEmergencyApi.hpp"
#include "systems/CharacterStatStore.hpp"
#include "lua/LuaSceneSwitchApi.hpp"
#include "systems/PlayerControllerSystem.hpp"
@@ -1027,11 +1029,34 @@ void EditorApp::setup()
editScene::registerLuaGlobalStateApi(L);
editScene::registerLuaCharacterStatsApi(L);
editScene::registerLuaScheduleScoringApi(L);
+ editScene::registerLuaRostersApi(L);
+ /* 6.8.14: the emergency layer (threshold registration
+ * + begin/end/active bindings). */
+ editScene::registerLuaEmergencyApi(L);
editScene::registerLuaDoorApi(L);
editScene::registerLuaHudApi(L);
editScene::registerLuaFurnitureApi(L, &m_world);
editScene::setLuaCellGridSystem(m_cellGridSystem.get());
- editScene::setLuaHudSystem(m_gameHudSystem.get());
+ /* 6.8.7: the Lua roster binding reads the same
+ * per-area store the C++ accessor surfaces. */
+ editScene::setLuaRostersProvider(
+ [](int areaIndex, void *user,
+ TownRosters::Rosters &out) {
+ auto *pop = static_cast<
+ NpcPopulationSystem *>(
+ user);
+ if (!pop)
+ return false;
+ const TownRosters::Rosters
+ *rosters =
+ pop->areaRosters(
+ areaIndex);
+ if (!rosters)
+ return false;
+ out = *rosters;
+ return true;
+ },
+ m_npcPopulationSystem.get()); editScene::setLuaHudSystem(m_gameHudSystem.get());
editScene::setSceneSwitchEditorApp(this);
editScene::setLuaPlayerCharacterResolver(
[this]() { return getPlayerCharacterEntity(); });
@@ -1871,6 +1896,12 @@ void EditorApp::saveGame(const std::string &slotPath,
saveData["globalState"] = GlobalStateStore::getInstance().serialize();
saveData["characterStats"] =
CharacterStatStore::getInstance().serialize();
+ /* 6.8.10: the weekly schedule store (weeks, bindings, usage,
+ * the stats snapshot used, revision markers) is scene-
+ * independent state and rides the save game. */
+ if (m_npcPopulationSystem)
+ saveData["npcWeekSchedules"] =
+ m_npcPopulationSystem->serializeWeekSchedules();
/* Runtime entities — skip characters and player controller */
saveData["runtimeEntities"] = nlohmann::json::array();
@@ -1940,6 +1971,15 @@ void EditorApp::loadGame(const std::string &slotPath)
clearScene();
+ /* loadGame replaces the base scene: deactivate the terrain
+ * BEFORE loading it so TerrainSystem::update() reactivates
+ * with FRESH entity ids (m_active otherwise stays true with a
+ * stale m_terrainEntityId — the graph read-throughs after a
+ * reload then see a dead entity, as the week-revision probe
+ * exposed). */
+ if (m_terrainSystem)
+ m_terrainSystem->deactivate();
+
SceneSerializer serializer(m_world, m_sceneMgr);
if (!serializer.loadFromFile(baseScene, m_uiSystem.get())) {
Ogre::LogManager::getSingleton().logMessage(
@@ -1977,6 +2017,13 @@ void EditorApp::loadGame(const std::string &slotPath)
else
CharacterStatStore::getInstance().deserialize(
nlohmann::json::object());
+ /* 6.8.10: restore the weekly schedule store (weeks, bindings,
+ * usage, the stats snapshot used and the revision markers);
+ * old saves without the section regenerate from defaults. */
+ if (m_npcPopulationSystem &&
+ saveData.contains("npcWeekSchedules"))
+ m_npcPopulationSystem->deserializeWeekSchedules(
+ saveData["npcWeekSchedules"]);
/* Destroy ALL spawned characters so the registry spawn is the
* only source of characters. */
diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md
index 3a1ba5b..862c51f 100644
--- a/src/features/editScene/demos/demo-taxi/PLAN.md
+++ b/src/features/editScene/demos/demo-taxi/PLAN.md
@@ -4537,10 +4537,72 @@ feet sliding.
### Phase 6.8 — DSL macros, symbolic schedules and POIs
-**Status: IN PROGRESS — 6.8.9 stats foundation, 6.8.1 DSL macros,
-6.8.2 POI/tag model (a+b), 6.8.3 weekly schedule generator and
-6.8.4a weekly store/F7/migration DONE (2026-10-29); 6.8.4b
-runtime day-tiling flip + week-rollover probe next.** Rewritten
+**Task 6.8.5 — Schedule realism rules (workstream B, content).**
+
+**Status: DONE (2026-10-29).** `WeekScheduleRules` gained the tags-driven
+`openHoursByTag` + `softCapacity`/`capacityByTag` fields; the pure
+defaults are NO-OPS (always-open venues, unlimited capacity) so
+default weekly builds stay bit-identical, and the demo's weekly
+build opts in (shop 9-18, soft capacity 8, `NpcPopulationSystem::
+demoWeeklyRules()`).
+
+- **Implemented:**
+ - `openHoursByTag`: venue hours looked up by the location's FIRST
+ tag that carries a table entry; unmatched venues are always
+ open. Both the sticky-job resolve (the work block the member
+ will actually hold) and the per-day leisure slot filter their
+ pools to venues open for the slot's FULL span — no schedule
+ entry sits at a closed venue even in part; closed-at-slot town
+ falls through the usual fallback chains (leisure → curb →
+ interest → home with a warning; work → binding when the town
+ has no Work locations at all, HOME when Work locations exist
+ but every one is shut for the work window — the closing pull).
+ - `softCapacity`/`capacityByTag`: picks past the resolved limit
+ sink the candidate's score (`TownScheduleScore::Candidate::
+ capacity` + `Weights::capacity`, mirrored through
+ `systems/ScheduleScoring` — one kernel, plus the Lua hook
+ keeps working). The push is soft: a heavy distance trade can
+ still win, so no hard veto invariants break.
+- **Fleet fix found while wiring the demo (6.8.5 §parking):** the
+ claim-time `parkingFor(..., respectClaims)` fallback picked the
+ nearest free Parking TOWN-WIDE with no distance cap — under shop
+ hours a Lot 57 visit routed its claim 141 m to the driver's own
+ home-slot street (and a Lot 60 driver 370 m to Lot 13), wedging
+ the live tier against the 20000-frame observation budget
+ (`--test-traffic-schedule` "car never arrived"). All three
+ claim-time tiers (bays → overflow → curb chain) AND the
+ occupancy-blind timing picks now stay within the venue's
+ `parkFallbackRadiusM` (80 m default, 0 = the pre-6.8.5 unlimited
+ behavior); a full frontage claims the venue's own curb.
+- Verification: the week probe (`runTownWeekScheduleProbe`) grew a
+ realism fixture block — a closed-venue property sweep (no entry
+ sits at a closed venue), the tag-order lookup ({"late","shop"}
+ beats the table), the closing-pull (a fully-shut venue pulls the
+ whole workday home with zero work entries surviving), the soft
+ capacity spread (capacity 1 spreads the equal-cost leisure pair;
+ same-rules regen stays bit-identical) — and the full battery:
+ `--test-motion` / `--test-determinism` / `--test-taxi` /
+ `--test-traffic-schedule` / `--test-parking` / `--test-detail` /
+ `--test-population-switch` / `--test-week-rollover` + 8/8 CTests
+ PASS.
+- F7 spot check: the Week pane shows the clipped/filtered
+ weekly template (shop-bound evening errands now fall back to
+ curb/interest instead of sitting at a closed shop).
+
+**Status: IN PROGRESS — all of phase 6.8's task-sized work is
+DONE (2026-10-30): 6.8.9 stats foundation, 6.8.1 DSL macros,
+6.8.2 POI/tag model (a+b), 6.8.3 weekly schedule generator,
+6.8.4 runtime weekly wiring (a+b), 6.8.5 schedule realism
+rules, 6.8.6 interior POI/action DSL, 6.8.7 special-job
+rosters, 6.8.8 entrance roles, 6.8.10 stat-driven weekly
+variety + schedule persistence & revisions, 6.8.13 reference
+layouts and 6.8.14 emergency rescheduling + hospitalization.
+Deferred follow-ups: the 6.8.9 stat-definition form defined
+directly from DSL data (content pass), multi-week feedback
+simulation (beyond the 6.8.10 probes) and the detail-tier
+embankment robustness issue found by the emergency battery
+(see the 6.8.14 block).**
+Rewritten
2026-10-28: the original bullet list mixed four independent features
and contradicted the clock-pure motion core; it is now split into
workstreams with explicit design decisions, task-sized pieces and
@@ -4859,9 +4921,11 @@ symbolic schedule generator) next.**
**Task 6.8.4 — Runtime integration + migration (workstream B).**
-**Status: 6.8.4a DONE (2026-10-29) — the weekly store, the registry
-rebind migration and the F7 week pane are in (runtime motion/traffic
-NOT flipped yet); the day-tiling flip + rollover probe are 6.8.4b.**
+**Status: DONE (2026-10-29) — 6.8.4a store/F7/migration + 6.8.4b the
+motion/traffic day-tiling flip. All 8 CTests and the full demo
+battery (`--test-motion`, `--test-determinism`, `--test-taxi`,
+`--test-traffic-schedule`, `--test-parking`, `--test-detail`,
+`--test-population-switch`, `--test-week-rollover`) PASS.**
- `NpcPopulationSystem` stores weekly schedules per area and the
resolved bindings; `NpcMotionSystem` reads the day-of-week tiling
@@ -4893,29 +4957,65 @@ NOT flipped yet); the day-tiling flip + rollover probe are 6.8.4b.**
From/To/Activity/**Query -> Location** (empty queries show the
raw location) and the build's usage tally (top-4 in the pane;
the full list lives in the location inspection).
-- Indoor scheduling follows the entry contract (6.8.12): unchanged
+- **Implemented (6.8.4b):** the runtime consumes the week. New pure
+ `TownSchedule::splitWeekToDays(week)` returns one DAILY-shaped
+ `NpcSchedule` per day-of-week (the day's whole-hour tiling of
+ [0, 24), `registryId`/`stableKey` preserved), so every existing
+ consumer keeps its daily-shaped `entryAt()` machinery. The
+ population store keeps the views next to the week
+ (`weekDayViews`, built at generation time) with accessor
+ `weekDayScheduleOf(registryId, day)`; `NpcMotionSystem::update`
+ and both traffic call sites (the `rebuildClaims` day-arc replay
+ and `evalSymbolic`) resolve the schedule as `weekDayScheduleOf(id,
+ absDay % 7)`, falling back to the legacy daily schedule when the
+ area has no weekly build. The store change is anchored in the
+ existing paths, so walking and driving cannot disagree.
+ Indoor scheduling follows the entry contract (6.8.12): unchanged
this phase — the weekly entries resolve to approach-anchored
LocationSet names like the daily ones do.
+- **New probe: `--test-week-rollover`** (the 6.8.4b done-criterion):
+ the week is a day-of-week tiling that repeats every week, so the
+ pure motion evaluation must reproduce positions bit-exactly
+ across a week boundary — three probe hours on day 7 (Monday of
+ week 1) must equal the same hours on day 0 (Monday of week 0)
+ for every non-driving NPC, and Monday-noon vs Saturday-noon must
+ differ for at least one NPC (the weekend template is actually
+ visible). First run: 9 probes bit-exact, 48 NPCs shifted
+ Monday->Saturday. (The pair is (day 0, day 7) — the SAME weekly
+ day-0 view; day 6 is the Saturday variety control.)
+- **6.8.4b test-facing gotchas (documented, keep):**
+ - The weekly templates arrival times are WHOLE hours; with the
+ tests' slowed walk (0.2 m/s) the drive/walk windows sit
+ 1-2 min wide ending exactly on the hour. The traffic-schedule
+ and parking sweeps therefore densified to ~1.7 min probes
+ (576 over 6-22 h); the old 5-min grid structurally missed
+ every window ("no drive leg observed").
+ - `GameClockSystem::update` re-adopts a sun on ANY divergence —
+ including its `timeSpeed` — and re-adoption takes the sun's
+ TIME too. The phase-B observation setup of both tests sets
+ `setSpeed(1.0)` (real time) against the scene's 0.13-h/s sun:
+ the older phase-B code silently rolled the clock back to the
+ last sweep hour and free-ran away from the drive window. Both
+ tests now align the suns' `timeSpeed` to 1.0 BEFORE
+ `setTime`/`setSpeed` so no divergence fires.
+ - `--test-detail`'s fixed 9.3 scrub hour became a structural dead
+ zone under the whole-hour weekly templates (all walks finish
+ before their arrival hour; nothing is mid-flight at 9.3). The
+ scrub now deterministically scans commute-adjacent hours
+ (8.1..10.7) and picks the one with the most spawn-eligible NPCs
+ (best: 8.9 h, 48 eligible of 145).
- Migration: existing `npcWork` Interest placeholders rebind to the
new Work locations once; schedules still recompute on load at this
stage (6.8.10 turns the weekly store into saved state).
-- **Remaining (6.8.4b):** flip `NpcMotionSystem`'s active-entry
- evaluation and the shared `nextLocationChangeOf`/
- `walkTravelHours` helpers (and through them
- NpcTrafficSystem's drive-leg derivation + the claim replay) onto
- the weekly day-tiling (day-of-week = `(absoluteDay % 7)`, the
- daily schedule as the fallback), the week-rollover determinism
- probe, and the full `--test-*` battery re-run.
- Done when: motion/traffic behave as before on a default town,
weekly variety is visible in the overlay, and scrubbing the clock
- across a week boundary reproduces positions bit-exactly.
-- Verification: `--test-motion`, `--test-determinism`,
- `--test-traffic-schedule`, `--test-taxi`, `--test-parking`,
- `--test-detail`, `--test-population-switch` all PASS unchanged
- (the runtime is untouched so far — weekly variety is visible in
- the overlay; the swap comes with 6.8.4b); the week probe in
+ across a week boundary reproduces positions bit-exactly. MET.
+- Verification: all 8 demo batteries PASS (weekly variety is now the
+ runtime behavior); the week probe in
road_geometry_overlap_test grew the usage-tally identity check
- (picks == members-with-a-job + leisure picks).
+ (picks == members-with-a-job + leisure picks) plus the day-view
+ checks (per-day `splitWeekToDays` slices equal the week's day
+ rows; `entryAt` on a day view agrees with `weekEntryAt`).
**Task 6.8.5 — Schedule realism rules (workstream B, content).**
@@ -4931,49 +5031,140 @@ NOT flipped yet); the day-tiling flip + rollover probe are 6.8.4b.**
over-capacity location while a legal candidate exists; archetype
rules hold across the generated population.
- Verification: pure rules probes incl. closed-location and capacity
- fallbacks; F7 spot check.
+ fallbacks; F7 spot check. (DONE — the status + implementation sit
+ at the top of this phase.)
**Task 6.8.6 — Interior POI/action DSL (workstream C).**
-- DSL forms (usable inside layouts and macros from 6.8.1) declaring,
- per room or cell: a POI with its planner data (tags, stat effects)
- and its mechanical spec (label, activation mode, behavior tree
- reference with GOAP/HTN data or a `luaTask`/event binding) — the
- same things the node/component editors author today. Definitions
- are emitted into the unified POI database (6.8.11); runtime
- components are spawned on grid build from that definition.
-- Reuse by reference: a macro or library entry defines the fragment
- once; layouts instantiate it with parameters. Ids stay stable
- across rebuilds (same discipline as furniture POI ids).
-- Done when: a hand-authored interior grid built from DSL produces
- working actuators (E prompt → behavior tree → Lua event) identical
- to the component-authored equivalent; rebuilds keep POI ids.
-- Verification: headless interior-grid probe asserting generated
- components and one executed action; does NOT require Phase 7/8.
+**Status: DONE (2026-10-29).** The layout DSL declares interior POIs
+per room and the town generation materializes them into the same
+`PoiComponent` machinery component-authored POIs use.
+
+- **DSL form** (inside `(room ...)`): `:poi ((name opts...))` —
+ options are `:kind k`, `:tags (sym...)`, `:effect (stat delta)`
+ (repeatable), `:activation none|manual|approach|arrive`,
+ `:behavior actionName` (a behavior-tree/action name) xor
+ `:lua scriptPath` (the `poi_activated` event carries it),
+ `:at (x z)` (footprint fractions of the SHELL — the space room
+ rects live in; defaults to the owning room's rect center),
+ `:level n` (defaults to the first room instance of the name),
+ `:hours (open close)`, `:entrance front|back|side`, `:radius n`,
+ `:cooldown s`, `:once`. Hard errors: activation without a
+ handler, both :behavior and :lua, :lua without :activation,
+ :at fractions outside [0,1], unknown options. Re-declaring a
+ room name (another floor) keeps its first :poi list, like the
+ room type; macros emit decls like any other form.
+- **Plan fields**: `LayoutLib::PlanPoi` + `BuildingPlan::pois`
+ (declaration order) — pure data, headlessly evaluable.
+- **Materialization** (`TownGenSystem::createPlanPois`, called for
+ full/interiorOnly grids): one runtime `PoiComponent` entity per
+ decl, `child_of` the building, id `poi..
+ [-fN].` (the grid uid is `town-`, so ids survive
+ regeneration/page rebuilds), positioned at the decl's snapped
+ cell. The entity's scene node hangs at the ROOT at the derived
+ world-render position (with `hasWorldPosition`) — the proximity
+ scan and the POI database merge read node positions verbatim and
+ a node parented to the building would report the local grid
+ offset. Mechanical side (E-prompt actuator ensure, approach/
+ arrive firing, the `NpcPopulationSystem` POI-database merge) is
+ the shared phase-6.8.11 machinery, unchanged.
+- **Verification**: the pure `:poi` grammar block in
+ `road_geometry_overlap_test` (3 decls parsed, field-by-field,
+ macro reuse, first-wins re-declaration, 6 malformed decls
+ rejected) and the new demo battery `demoTaxi --headless
+ --test-dsl-poi`: the preview grid's POIs exist with stable ids,
+ merge into the town's POI database with the planner data, gain
+ their ensured ActuatorComponent, the OnApproach POI fires the
+ probe action when the player enters the radius, the OnArrive Lua
+ POI fires `poi_activated` after the dwell seconds, and a
+ re-preview reproduces every poiId bit-for-bit. Full battery +
+ CTest 8/8 PASS; the `--test-poi` battery gained the exterior
+ scene default it lacked (it had been reading the interior start
+ scene and timing out since 6.8.11).
**Task 6.8.7 — Special-job rosters (workstream B, tiny).**
-- NPCs whose job tags match a special set (`police`, `ambulance`,
- `firefighter`, ...) are exposed as per-area rosters (registry-id
- lists with home/work bindings) for later game systems; no AI
- behavior is added here.
-- Done when: rosters are deterministic and queryable from C++ and
- Lua; F7 lists them.
-- Verification: pure roster probe + a Lua binding smoke case.
+**Status: DONE (2026-10-29).** Special-service buildings get their
+own job tags, the population's weekly build derives per-area
+rosters (special tag -> registry-id-ordered members with home/work
+bindings), and the rosters are queryable from C++, Lua and F7.
+
+- **DATA**: `TownLocations::jobTagForBuilding` maps the special
+ building tags onto service roles before the shop/office
+ defaults (police→police, ambulance→medic, firefighter→
+ firefighter, hospital→doctor) and `isCommercialBuilding` counts
+ the special tags as commercial zoning — a police station is a
+ workplace, not an Interest point. The special ROLE vocabulary
+ lives in `TownRosters::specialJobTags()` (police, medic,
+ firefighter, doctor).
+- **Pure layer**: `roadlib/TownRosters.{hpp,cpp}` — OGRE-free
+ (`deriveRosters(members, tags)` over flattened
+ `MemberJob{registryId, stableKey, home, work, tagQuery}`
+ summaries; OGRE-free so the Lua smoke test links it) plus the
+ weekly-build adapter `TownRostersWeeks.{hpp,cpp}`
+ (`weekJobMembers(weeks)`; home = the first sleep/home entry's
+ location, job = the first work entry's query/location, empty
+ tagQuery for jobless members). Columns: only "job:"
+ queries with tags ∈ special set are rostered; the tagless Work
+ binding and non-special tags are ignored; registry-id order
+ always.
+- **Runtime**: `NpcPopulationSystem::generateAreaPopulation`
+ derives the rosters as an OUTPUT of the weekly build (same
+ determinism contract) onto the area; accessor
+ `areaRosters(areaIndex)` (nullptr when no weekly build).
+- **Lua**: `ecs.rosters.area(areaIndex)` (roster table or nil,
+ members as `{ id = registryId, key = stableKey, home = loc,
+ work = loc }`), `ecs.rosters.tags()` (the special set),
+ `ecs.rosters.is_special(tag)`; the runtime binds the provider to
+ NpcPopulationSystem in EditorApp.setup.
+- **F7**: the population section lists "special rosters:" per area
+ with the member counts.
+- **Verification**: the pure `runTownRostersProbe` in
+ road_geometry_overlap_test (a police+ambulance fixture town
+ rosters 3/3 members across the two special tags, bindings are
+ real `home:`/`work:` names, re-derivation bit-identical, an
+ unrepresented filter tag yields an empty map) and the new
+ `rosters_lua_test` (CTest `rostersLuaTest`, OGRE-free: the
+ derivation drops salesman/jobless members, the Lua binding's
+ area/tags/is_special + member fields, an unknown area → nil, a
+ cleared provider → nil). Full battery + CTest 9/9 PASS.
**Task 6.8.8 — Entrance roles (workstream D, research → small task).**
-- Proposal to validate: buildings record door roles (front =
- customer, back = employee, side = delivery) at generation time;
- a location's approach leg ends at the doorstep of the role
- matching the visitor (employee at a job location, customer at a
- leisure location). This is a *path-endpoint* choice on the
- existing road/doorstep model, not interior pathfinding.
-- Done when: the approach endpoint differs by role for buildings
- that declare both doors; single-door buildings are unchanged.
-- Verification: pure endpoint-selection probe; if door-role metadata
- proves unreliable, drop to a per-location explicit doorstep
- override and document the limitation.
+**Status: DONE (2026-10-29).** Door roles are real data: a layout's
+`(door back|left|right ...)` now records a matching approach point
+on the building at generation time (`TownBuilding::hasBackDoor/
+hasSideDoor` + `backDoorstep`/`sideDoorstep`, computed with the
+same 2 m outside clearance as the front doorstep), and Work
+locations anchor at the back door while Leisure keeps the front —
+employee vs customer endpoints differ exactly for buildings that
+declare both doors; single-door buildings stay bit-identical.
+
+- **Approach model**: `TownBuilding` records the front/back/side
+ doorsteps in TownGen (door flags of the plan, no new DSL
+ metadata needed — the `(door side ...)` verb already names the
+ side). `TownLocations::buildLocations` sets Work.position =
+ backDoorstep and `entranceRole = "back"` when declared, else the
+ previous front anchor. The motion layer and the entry POI keep
+ the existing approach contract (6.8.12): the POI entry now comes
+ in roles — `entrance:` (front) and
+ `entrance::back` (when declared), so
+ `PoiDatabase::appendEntries` emits the employee back entry too,
+ `entryOfBuildingForRole` and `approachPosition` resolve it, and
+ `projectLocations` stamps `entryPosition` per role.
+- **Distance scoring**: the 6.8.12 gap is closed —
+ `TownSchedule::scoreCandidate` now measures candidates against
+ `TownLocations::approachPosition(loc)` (the building entry,
+ for interior destinations) instead of the raw location position;
+ generated towns see identical values.
+- **Verification**: new `runEntranceRoleProbe`
+ (road_geometry_overlap_test): the back-door fixture's employee
+ approach = backDoorstep (role "back"), customer/home unchanged,
+ `appendEntries` emits front+back exactly once (re-append is
+ idempotent), `entryOfBuildingForRole` finds the back entry,
+ `approachPosition` for a Work POI returns the back position, and
+ single-door buildings keep behavior/ids unchanged. Full CTest
+ 9/9 PASS; town schedules unchanged.
**Task 6.8.9 — Character stats foundation: script-defined stats
(health/money/stress) (workstream B, foundation — do FIRST).**
@@ -5057,32 +5248,65 @@ snapshots) and `lua-examples/schedule_scoring_example.lua`.
**Task 6.8.10 — Stat-driven weekly variety + schedule persistence &
revisions (workstream B, new).**
-- `generateWeekSchedules()` takes the week-start stats snapshot
- (decision 8) read from the 6.8.9 container, with scoring through
- its `scoreLocations` hook: poor NPCs take more job blocks,
- stressed NPCs more leisure or full home-rest days, low-health
- NPCs more rest, within the realism rules of 6.8.5. Until stats
- actually evolve the defaults reproduce the 6.8.3 schedules, so
- this lands without behavior changes.
-- The weekly schedule store is scene-independent (survives scene
- switches like the population store) and SAVED with the game:
- weekIndex, entries, the stats snapshot used, resolved bindings
- and any revision markers. Old saves without it regenerate from
- defaults.
-- Mid-week revision operation: rewriting the REST of the current
- week is an explicit, recorded revision (forward-looking, in-flight
- goals finish first); revisions are part of the saved state and
- re-resolve bindings by the `(weekIndex, graphVersion, revision)`
- key.
-- Done when: next week's schedule differs from this week's only
- through the stat snapshot (no hidden drift), a mid-week save →
- reload restores the schedule, bindings and revisions exactly, and
- a revision never moves an NPC's current position or cancels an
- in-flight goal.
-- Verification: pure variety rules probes; save → scrub → reload
- round-trip determinism probe; revision persistence probe;
- `--test-motion` / `--test-determinism` updated to pin the stats
- snapshot.
+**Status: DONE (2026-10-29).** Week-start stats snapshots now shape
+the weekly templates, the weekly store survives scene switches and
+save/load, and explicit recorded revisions rewrite only the REST of
+the current week.
+
+- **Stat-driven shape (pure, inert for defaults):**
+ `generateWeekSchedules` takes a snapshot map with ONLY members
+ that have EXPLICIT stat values (defaults stay inert — the
+ baseline stays bit-identical), and applies per-NPC isolated
+ draws: stress > 60 → more leisure, money < 25 → fewer paid
+ outings and an extra weekend job shift, health < 30 → a
+ deterministic full-home rest day, stress > 85 → a deterministic
+ full-home rest day. The per-NPC mt19937 draw order is unchanged
+ when the snapshot is empty.
+- **Stat effects as DATA**: `NpcLocation::statEffects`
+ (unordered_map, empty = neutral) rides the unified POI feed and
+ the LocationSet projection; `TownSchedule::scoreCandidate` now
+ fills `WeekCandidate::effects`, so the scoring hook can steer
+ venue choice (poor → money-positive venues, stressed →
+ stress-relieving ones) while the default kernel stays
+ stats-free.
+- **Weekly store persistence**: `AreaPopulation::weekSchedules` /
+ `weekDayViews` / `weekUsage` / `weekStats` / `rosters` and the
+ revision history ride `saveData["npcWeekSchedules"]`
+ (`NpcPopulationSystem::serializeWeekSchedules` /
+ `deserializeWeekSchedules`); old saves without the section
+ regenerate from defaults. `EditorApp::loadGame` also
+ DEACTIVATES terrain before loading the new base scene
+ (otherwise `m_active` stayed true with a stale
+ `m_terrainEntityId` and the graph read-throughs after reload saw
+ a dead entity), and revision history is preserved across
+ `generateAreaPopulation` AND `NpcPopulationSystem::update()`'s
+ clear+regenerate cycle.
+- **Revision operation**: `NpcPopulationSystem::reviseWeek`
+ (areaIndex, reason, graphVersion) re-runs the weekly build with
+ the CURRENT stats snapshot and splices at the current absolute
+ clock via `TownSchedule::spliceWeekAfter` — past days stay
+ identical, the in-flight entry is kept WHOLE (in-flight goals
+ finish first, no NPC is moved), the rest of the week is
+ rewritten and the day views rebuilt. Revisions append a
+ `WeekRevision{reason, weekIndex, graphVersion, revisionId}` to
+ `AreaPopulation::revisions`, preserved on regen and on
+ save/load. Bindings re-resolve by (weekIndex, graphVersion,
+ revision).
+- **Bugfixes landed while wiring the probes**: the spawner
+ `spawnDistanceSq = -1` sentinel round-trip no longer writes
+ JSON null (`SceneSerializer::serializeCharacterSpawner` /
+ `deserializeCharacterSpawner` guard), which previously crashed
+ save/load in a live town.
+- **Verification**: new pure `runWeekVarietyProbe` (stats shape:
+ the empty-snapshot build is bit-identical to the baseline, the
+ health rest day is home-only at the hashed index, money<25 adds
+ a weekend job shift, stress>85 gives a deterministic rest day,
+ effects ride into the score hook, and `spliceWeekAfter` keeps
+ the in-flight entry whole with day tilings intact) and the demo
+ battery `demoTaxi --headless --test-week-revision` (neutral
+ revision is a recorded no-op with the revision key fields, save
+ → reload round trip keeps weeks and the revision marker, and a
+ second revision appends). Full battery + CTest 9/9 PASS.
**Task 6.8.11 — Unified POI model: shared database + page-managed
activation (workstreams B+C, ground-up POI definition).**
@@ -5200,7 +5424,10 @@ building — keyed on the "one Home per building at its doorstep"
invariant: id `entrance:`, kind `entrance`, domain
Exterior, tag `entrance`, entrance role `front` (generated buildings
are single-door; multi-door role matching waits for 6.8.8/6.8.13
-content), position = the Home location (the doorstep). The POI set
+content — **landed 6.8.8: back/side doorsteps computed by TownGen
+and employee (Work) locations anchored there, with the matching
+`entrance::back` POI emitted by the same appendEntries
+call and `entryOfBuildingForRole` resolving role**), position = the Home location (the doorstep). The POI set
is finalized right after (the initial population build was missing
that call — only the 60-frame re-merge had it). Pure helpers:
`entryId`, `entryOfBuilding` (building index first, then address)
@@ -5221,8 +5448,10 @@ anchors on the same entry). Editor: the POI Database panel groups
interior POIs under their building's entry (toggleable; exterior
records stay flat, filtered-out entries fall their interior records
back flat). The distance-score side (ScheduleScoring) has no live
-caller yet — the weekly planner (6.8.2) must compute candidate
-distances to `approachPosition`, noted there. Verification:
+caller yet — **landed 6.8.8: the weekly planner's scoreCandidate now
+computes candidate distances against
+`TownLocations::approachPosition(loc)` (the building entry, with
+employee back-door anchors per 6.8.8)**. Verification:
`runPoiDatabaseProbe` extended (append count + idempotence, entry
fields/doorstep position, index/address lookup, interior/exterior/
orphan approach resolution, projection domain+entry stamps, no
@@ -5270,71 +5499,128 @@ destination — the schedule model is unchanged.
**Task 6.8.13 — Reference layouts: home, job, leisure (workstreams
B+C, content fixtures).**
-- Author ONE reference layout per location kind in
- `town_layouts.txt`, exercising the whole pipeline (tags → POI db →
- entry POI → schedule assignment → stat effects):
- - **home** (residence): the Home location and its entry POI. At
- home the NPC is IDLE — no POI activation and no behavior trees
- required. Interior home AI (cooking, cleaning, family
- interactions, wandering between rooms) is explicitly OUT of
- scope for the schedule-based AI; a home entry only means rest
- with the home's (weaker) stress-recovery effect.
- - **job** (a shop or office): Work + job tags, work POIs with
- their stat effects (`money:+X/h`, `stress:+Y/h`), opening
- hours, and an employee entrance role.
- - **leisure** (a park/cafe/bar): Leisure tags and stress-relief
- effects with a money cost where appropriate, opening hours and
- a customer entrance role.
-- These are the concrete fixtures for 6.8.2 tag assignment, 6.8.6
- DSL POI authoring and the 6.8.12 entry contract — implement them
- early so every later task has real content to test against.
-- Done when: a town containing the three layouts produces correctly
- tagged Work/Leisure/Home locations with entry POIs, the scheduler
- assigns NPCs to them, and completing an entry applies exactly the
- declared stat effects.
-- Verification: generated-tag/entry/effect probes per layout; a
- schedule probe asserting home entries are idle (no activation).
+**Status: DONE (2026-10-29).** `town_layouts.txt` ships the three
+reference layouts and the stat effects are real data end to end.
+
+- **`ref-home`** (residence, tags house/village/town): living +
+ bedroom, idle rest — the home LOCATION carries
+ `:effects ((stress -1))` (weaker stress recovery per hour; NO
+ POI activation and no behavior trees at home — interior home AI
+ stays out of scope).
+- **`ref-shop`** (commercial shop): the shop room carries
+ `:effects ((money 5) (stress 1))`, the layout declares
+ `(door back center)` so employees (Work locations) anchor at the
+ back door (6.8.8) while customers keep the front, and opening
+ hours come from the runtime rules table (shop 9-18, 6.8.5).
+- **`ref-park`** (leisure park): `:effects ((money -2) (stress -4))`
+ with a customer front door.
+- **The `:effects` room option** (new, `LayoutEval`): values are
+ `(stat delta)` pairs; the plan carries them parallel to
+ roomNames (`BuildingPlan::roomStatEffects`), and
+ `TownLocations::buildLocations` merges them onto the
+ Work/Leisure/Home locations by ROOM TYPE role match (work =
+ shop/store/office/commercial/work/shopfront/storage; leisure =
+ park/cafe/bar/leisure; home = bedroom/living/livingroom/home/
+ residence), additive across matching rooms.
+- **Effects apply on COMPLETED schedule entries**:
+ `systems/StatEffectsApplier::applyLocationEffects(store, id,
+ loc, hours)` applies `loc.statEffects × hours` through the
+ `CharacterStatStore` (clamped); `NpcMotionSystem::update` calls
+ it on every entry-location transition, keyed
+ `(npc, location, startHour)` so each entry applies exactly once
+ per run (a back-scrub never re-applies; the completed set is
+ runtime-only).
+- Done when: a town containing the three layouts produces
+ correctly tagged Work/Leisure/Home locations with entry POIs,
+ the scheduler assigns NPCs to them, and completing an entry
+ applies exactly the declared stat effects. MET: the generated
+ demo town now picks the ref layouts by tag (the batteries'
+ content numbers shifted accordingly and determinism holds within
+ a run); `runReferenceLayoutsProbe` (road_geometry_overlap_test)
+ parses all three layouts and asserts the effects on the
+ Work/Leisure/Home locations, the employee back entry, the
+ effects' survival through the unified feed/projection, and the
+ applier's exact per-hour application.
**Task 6.8.14 — Emergency rescheduling + hospitalization
architecture (workstream B, the health=0 case).**
-- **Emergency layer** on top of the revision model (decision 1):
- data-driven stat-threshold rules (`health < 10` → hospital now;
- later `stress > 90` → home rest) evaluated on stat-change events
- (never per-frame polling). An emergency is the HIGHEST-priority
- goal — above scripted goals and the baseline — recorded as a
- revision with a `reason` and an `until` condition, so it is saved
- and reproducible on load. It cancels or suspends the current
- goal immediately (a health emergency does not wait for the taxi
- ride to finish); when the `until` condition clears, the pure
- baseline simply resumes from the current clock — no schedule
- state to restore.
-- **Hospitalization (health = 0):** the NPC is INCAPACITATED, not
- dead (death stays a non-goal). They are considered IN THE
- HOSPITAL until fully healed (or above a discharge threshold):
- position and entries resolve to the hospital location, health
- regenerates per hour from the hospital's stat effects, and the
- baseline resumes on discharge. Initial transport is symbolic —
- the NPC is moved to the hospital entry POI (live tier: despawn
- where they collapsed, respawn at the hospital); ambulances,
- stretchers and escorted transport are a LATER system building on
- the 6.8.7 rosters.
-- Hospital content requirement: a hospital location with
- `hospital`/`health` tags and heal-over-time effects (a hospital
- reference layout may join 6.8.13). A town without one falls back
- to home rest with a single logged warning — never a crash or an
- NPC stuck incapacitated in the street.
-- F7 overlay shows the emergency state (reason, until condition,
- current health).
-- Done when: crossing `health < 10` rewrites the NPC's goal to the
- hospital immediately and persists across save/load; `health = 0`
- hospitalizes symbolically until healed and the baseline resumes
- on discharge with correct position and no leftover emergency
- state; the no-hospital fallback works.
-- Verification: pure threshold/emergency-priority/discharge probes;
- save → reload during an emergency restores it exactly;
- health=0 → hospital → healed → baseline round trip; no-hospital
- fallback probe.
+**Status: DONE (2026-10-30).**
+
+- **The emergency layer** is per-NPC override state on
+ `AreaPopulation` (`EmergencyState{reason, location, startAbs,
+ schedule}`): the HIGHEST-priority goal, superseding the weekly
+ day view, the legacy daily schedule, scripted goals and live
+ traffic. `beginEmergency`/`endEmergency`/`hasActiveEmergency`/
+ `emergencyScheduleOf` on `NpcPopulationSystem`;
+ `effectiveDayScheduleOf(id, day)` = emergency → week day →
+ daily, and ALL THREE schedule consumers now read it
+ (`NpcMotionSystem::update`, both `NpcTrafficSystem` evaluation
+ sites).
+- **The synthetic schedule** is a 24-entry whole-hour tiling at
+ the emergency location (activity "hospital") so the clock-pure
+ evaluation transports the NPC there symbolically. The
+ destination is the town's hospital Work location (the
+ `hospital` tag on a Work location — the `ref-hospital` layout
+ ships one, weight 5 so towns reliably carry it); a town without
+ one falls back to `home:` rest with a single logged
+ warning.
+- **Data-driven thresholds:** the stats rules module registers
+ them via `ecs.emergency.set_thresholds{ health_below,
+ discharge_at }` (`lua/LuaEmergencyApi.cpp`; the module carries
+ the values as data — hospitalBelowHealth 10, dischargeHealth
+ 100). The engine subscribes ONCE to the `CharacterStatStore`
+ change events (never per-frame polling): health crossing below
+ the trigger begins the emergency; reaching the discharge
+ threshold ends it. `ecs.emergency.begin/end/active` are also
+ callable directly (scripted emergencies).
+- **Goal supersede:** `beginEmergency` cancels the taxi goal
+ (`NpcTaxiSystem::cancelGoal`) and despawns the live car
+ (`NpcTrafficSystem::despawnLiveCar` — new); `assignTaxiGoal`
+ refuses while the NPC is in an emergency.
+- **Persistence:** the `emergencies` section rides
+ `serializeWeekSchedules`/`deserializeWeekSchedules` (registry
+ id, reason, location, startAbs; the synthetic schedule is
+ rebuilt from the location on load), and the state is preserved
+ across the update()/generateAreaPopulation clear+regenerate
+ cycles (like the revision history).
+- **F7** shows a red `EMERGENCY` banner for the selected NPC with
+ the synthetic activity, the emergency location and the live
+ health.
+- **Hospital content:** `ref-hospital` in `town_layouts.txt`
+ (commercial/hospital tags, ward room `:type hospital :effects
+ ((health 5))` — heal-over-time via the 6.8.13 stat effects; a
+ back door for staff per 6.8.8); doctors derive from it via the
+ 6.8.7 rosters.
+- Done-when, MET: crossing the threshold rewrites the NPC's goal
+ to the hospital immediately and persists across save/load;
+ discharge (health back at the threshold) resumes the pure
+ baseline with no leftover state; the no-hospital fallback was
+ verified end to end (an earlier build of the demo town without
+ the hospital layout ran the home-rest path with the warning).
+ The pure layer is intentionally covered by the demo battery
+ rather than a road-test probe: the threshold logic lives in the
+ runtime binding (`LuaEmergencyApi` → `NpcPopulationSystem`),
+ which is OGRE-linked and cannot join the OGRE-free probe binary.
+- Verification: `./demoTaxi --headless --test-emergency` (the
+ rules module is required through the app's Lua state — the full
+ shipped data path — then: threshold trigger via
+ `ecs.stats.set`, the 24-entry synthetic effective schedule,
+ symbolic transport to the emergency location, save → reload
+ mid-emergency restoring it, discharge → baseline resumption,
+ taxi-goal refusal with a valid destination, endEmergency on a
+ non-emergency id, and the no-hospital fallback when the town
+ allows it).
+- **Known issue found by the battery (pre-existing, 4.x-era,
+ tracked separately):** the taxi battery's candidate rotation
+ (stall → next NPC) now papers over a detail-tier robustness
+ gap — a detail entity spawned below a road embankment (the
+ terrain raycast drops its feet to the heightmap, possibly in a
+ ditch) can never physically reach a boarding door above the
+ embankment; the root-motion character wedges at the pad face
+ forever. The symbolic layer is height-agnostic and unaffected.
+ Fix candidate for a later phase: obstruction-aware detail
+ spawns or reachable-door fallback.
#### Cross-cutting requirements
diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp
index 495b479..380c132 100644
--- a/src/features/editScene/demos/demo-taxi/demo_main.cpp
+++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp
@@ -39,6 +39,7 @@
#include "systems/NpcTaxiSystem.hpp"
#include "systems/NpcTrafficSystem.hpp"
#include "roadlib/TownLocations.hpp"
+#include "components/Sun.hpp"
#include "systems/GameClockSystem.hpp"
#include "components/AiDriver.hpp"
#include "components/Door.hpp"
@@ -886,6 +887,222 @@ struct MotionTestListener : public Ogre::FrameListener {
}
};
+/*
+ * --test-week-rollover (phase 6.8.4b): the baseline week is a
+ * day-of-week tiling that repeats every week, so the pure motion
+ * evaluation must reproduce positions BIT-EXACTLY across a week
+ * boundary (same time-of-day on Saturday of week 0 and Monday of
+ * week 1 — the same weekly day-0 view). Also asserts the weekend
+ * template is actually visible: at Saturday noon enough NPCs hold
+ * positions differing from Monday noon (workdays put adults at
+ * work; weekends send them to the outing window). Traffic drivers
+ * in a drive-leg window are skipped (their position follows the
+ * car), like --test-motion.
+ */
+struct WeekRolloverTestListener : public Ogre::FrameListener {
+ EditorApp *app;
+ int frame = 0;
+ int phase = 0;
+ bool failed = false;
+ Ogre::String failReason;
+
+ /* Absolute probe hours: three probe hours on Monday of week
+ * 0 (day 0), the same hours on Saturday (day 6), and the same
+ * hours on Monday of week 1 (day 7 — 7 % 7 == 0, the same
+ * weekly view). The rollover pairs are (day0, day7) — the
+ * SAME template across the boundary; the variety control is
+ * (day0, day6) — Monday vs Saturday templates. */
+ static const int kProbes = 9;
+ double probeAbs[kProbes] = {
+ 0 * 24 + 6.5, 0 * 24 + 12.0, 0 * 24 + 18.5,
+ 6 * 24 + 6.5, 6 * 24 + 12.0, 6 * 24 + 18.5,
+ 7 * 24 + 6.5, 7 * 24 + 12.0, 7 * 24 + 18.5
+ };
+ int probe = 0;
+ int settleFrames = 0;
+ std::vector> snapshots;
+ int deadline = 4000;
+
+ WeekRolloverTestListener(EditorApp *a)
+ : app(a)
+ {
+ snapshots.resize(kProbes);
+ }
+
+ bool frameRenderingQueued(const Ogre::FrameEvent &evt) override
+ {
+ (void)evt;
+ ++frame;
+ if (!failed && frame > deadline) {
+ failed = true;
+ failReason = "watchdog timeout";
+ }
+ if (failed) {
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+
+ NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
+ NpcMotionSystem *motion = NpcMotionSystem::getInstance();
+ GameClockSystem *clock = GameClockSystem::getInstance();
+ if (!pop || !motion || !clock) {
+ failed = true;
+ failReason = "missing population/motion/clock system";
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+
+ if (phase == 0) {
+ if (pop->countPopulationNpcs() <= 0)
+ return true;
+ /* The weekly build must exist (the population
+ * system generates baseline weeks since
+ * 6.8.4a). */
+ uint64_t someId =
+ pop->populationCharacterIds().front();
+ if (!pop->weekScheduleOf(someId)) {
+ failed = true;
+ failReason = "no weekly build stored for "
+ "the population";
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ /* Slow the walk so the transit windows are
+ * wide (same trick as --test-motion). */
+ motion->walkSpeedMps = 0.2f;
+ probe = 0;
+ settleFrames = 0;
+ phase = 1;
+ return true;
+ }
+
+ if (phase == 1) {
+ if (settleFrames == 0) {
+ int day = (int)std::floor(
+ probeAbs[probe] / 24.0);
+ float tod = (float)(probeAbs[probe] -
+ day * 24.0);
+ clock->setTime(day, tod);
+ }
+ if (++settleFrames < 2)
+ return true;
+ for (uint64_t id :
+ pop->populationCharacterIds()) {
+ CharacterRegistry::CharacterRecord *rec =
+ CharacterRegistry::getSingleton()
+ .findCharacter(id);
+ if (!rec)
+ continue;
+ snapshots[probe][id] = rec->position;
+ }
+ ++probe;
+ settleFrames = 0;
+ if (probe < kProbes)
+ return true;
+ phase = 2;
+ return true;
+ }
+
+ if (phase == 2) {
+ /* Rollover bit-exactness: (week0 Monday, tod) ==
+ * (week1 Monday, tod) for every non-driving
+ * NPC — the same weekly day-0 view. */
+ static const int rolloverPairs[3][2] = {
+ { 0, 6 }, /* 6.5 */
+ { 1, 7 }, /* 12.0 */
+ { 2, 8 } /* 18.5 */
+ };
+ for (const auto &pr : rolloverPairs) {
+ int a = pr[0], b = pr[1];
+ float tod =
+ (float)(probeAbs[a] -
+ std::floor(probeAbs[a] /
+ 24.0) * 24.0);
+ for (const auto &kv : snapshots[a]) {
+ if (isTrafficDriver(kv.first))
+ continue;
+ auto other =
+ snapshots[b].find(kv.first);
+ if (other == snapshots[b].end())
+ continue;
+ if (kv.second.distance(
+ other->second) > 0.001f) {
+ std::cout << std::fixed;
+ std::cout.precision(3);
+ std::cout << "[dbg] NPC "
+ << kv.first
+ << " at tod "
+ << tod << ": week0 ("
+ << kv.second.x
+ << ", "
+ << kv.second.y
+ << ", "
+ << kv.second.z
+ << ") vs week1 ("
+ << other->second.x
+ << ", "
+ << other->second.y
+ << ", "
+ << other->second.z
+ << ")" << std::endl;
+ failed = true;
+ failReason =
+ "position across the "
+ "week boundary not "
+ "reproducible for NPC " +
+ std::to_string(
+ kv.first);
+ app->getRoot()
+ ->queueEndRendering();
+ return true;
+ }
+ }
+ }
+ std::cout << "[test] rollover probes bit-exact"
+ << std::endl;
+ phase = 3;
+ return true;
+ }
+
+ if (phase == 3) {
+ /* Weekend variety: Monday noon (probe 1 = day 0)
+ * vs Saturday noon (probe 4 = day 6). The
+ * templates differ (work vs the outing window),
+ * so at least one non-driving NPC stands
+ * elsewhere. */
+ int differed = 0;
+ for (const auto &kv : snapshots[1]) {
+ if (isTrafficDriver(kv.first))
+ continue;
+ auto sit = snapshots[4].find(kv.first);
+ if (sit == snapshots[4].end())
+ continue;
+ if (kv.second.distance(sit->second) >
+ 0.5f)
+ ++differed;
+ }
+ if (differed == 0) {
+ failed = true;
+ failReason = "weekend template produced "
+ "no visible variety "
+ "(Monday vs Saturday at "
+ "noon)";
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ std::cout << "[test] PASS: week rollover (" << kProbes
+ << " probes bit-exact across the "
+ "boundary; " << differed
+ << " NPCs shifted Monday->Saturday)"
+ << std::endl;
+ phase = 4;
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ return true;
+ }
+};
+
/*
* --test-detail (phase 4.7.6): high-detail tier spawn/despawn.
*
@@ -914,6 +1131,15 @@ struct DetailTestListener : public Ogre::FrameListener {
/* Phase 4.9.1: the NPCs eligible to spawn at the scrubbed
* commute time (indoor-bound NPCs must never spawn). */
std::vector eligible;
+ /* 6.8.4b: the weekly templates arrive only at WHOLE hours, so
+ * a fixed scrub hour can structurally miss every walk window.
+ * The scrub deterministically scans commute-adjacent hours and
+ * picks the one with the most spawn-eligible NPCs. */
+ int scrubIndex = -1;
+ int scrubSettle = 0;
+ bool scrubDone = false;
+ float scrubBestHour = 9.3f;
+ int scrubBestCount = 0;
DetailTestListener(EditorApp *a)
: app(a)
@@ -994,13 +1220,55 @@ struct DetailTestListener : public Ogre::FrameListener {
NpcMotionSystem *motion = NpcMotionSystem::getInstance();
if (motion)
motion->walkSpeedMps = 0.2f;
- clock->setTime(clock->getDay(), 9.3f);
phase = 6;
phaseFrames = 0;
+ scrubIndex = 0;
+ scrubSettle = 0;
+ scrubDone = false;
+ scrubBestHour = 9.3f;
+ scrubBestCount = 0;
return true;
}
if (phase == 6) {
+ /* 6.8.4b scrub scan: the weekly templates arrive
+ * only at whole hours, so one fixed hour can
+ * structurally miss every walk window. Probe
+ * commute-adjacent hours deterministically, then
+ * scrub to the best. */
+ static const float scanHours[] = {
+ 8.1f, 8.3f, 8.5f, 8.7f, 8.9f, 9.2f,
+ 9.4f, 9.6f, 9.8f, 10.1f, 10.4f, 10.7f
+ };
+ const int scanCount =
+ (int)(sizeof(scanHours) /
+ sizeof(scanHours[0]));
+ if (!scrubDone) {
+ if (scrubSettle == 0)
+ clock->setTime(
+ clock->getDay(),
+ scanHours[scrubIndex]);
+ if (++scrubSettle < 2)
+ return true;
+ int count = 0;
+ for (uint64_t id : ids)
+ if (detail->isSpawnEligible(id))
+ ++count;
+ if (count > scrubBestCount) {
+ scrubBestCount = count;
+ scrubBestHour =
+ scanHours[scrubIndex];
+ }
+ ++scrubIndex;
+ scrubSettle = 0;
+ if (scrubIndex >= scanCount) {
+ scrubDone = true;
+ phaseFrames = 0;
+ clock->setTime(clock->getDay(),
+ scrubBestHour);
+ }
+ return true;
+ }
/* The population seeds rec->position with the home
* building center; the first pure motion evaluation
* (same frame or the next, depending on frame-listener
@@ -1017,7 +1285,10 @@ struct DetailTestListener : public Ogre::FrameListener {
}
std::cout << "[test] snapshot at tod "
<< clock->getTimeOfDayHours()
- << "; spawning all eligible in high detail"
+ << " (scrub scan best: "
+ << scrubBestCount << " eligible of "
+ << scanCount << " hours); spawning all "
+ "eligible in high detail"
<< std::endl;
/* Phase 4.9.1: spawn exactly the eligible set. */
for (uint64_t id : ids)
@@ -1250,12 +1521,34 @@ struct TaxiTestListener : public Ogre::FrameListener {
bool teleportedToPickup = false;
bool teleportedToDest = false;
int resumeWaitFrames = 0;
+ /* Candidate rotation (content lottery robustness): a candidate
+ * whose detail entity cannot physically reach its boarding
+ * door (spawned below a road embankment, wedged against a
+ * wall) is cancelled and the next one tried. */
+ std::vector candidates;
+ size_t candIdx = 0;
+ int stallFrames = 0;
TaxiTestListener(EditorApp *a)
: app(a)
{
}
+ /* True when another candidate was selected (phase reset to 0
+ * for the re-assign); false when the list is exhausted. */
+ bool advanceCandidate()
+ {
+ candIdx++;
+ if (candIdx < candidates.size()) {
+ teleportedToPickup = false;
+ teleportedToDest = false;
+ vehicleId = 0;
+ phase = 0;
+ return true;
+ }
+ return false;
+ }
+
bool fail(const Ogre::String &reason)
{
failed = true;
@@ -1345,52 +1638,85 @@ struct TaxiTestListener : public Ogre::FrameListener {
pop->populationCharacterIds();
if (ids.empty())
return true;
- clock->setPaused(true);
- motion->walkSpeedMps = 30.0f;
- detail->spawnDistanceSq = 10000.0f * 10000.0f;
- detail->despawnDistanceSq = 20000.0f * 20000.0f;
- detail->minDwellSeconds = 0.0f;
- detail->spawnBudgetPerFrame = 40;
- npc = 0;
- for (uint64_t id : ids) {
- /* Traffic drivers (phase 5.4) refuse taxi
- * goals. */
- if (!isTrafficDriver(id)) {
- npc = id;
- break;
- }
+ if (candidates.empty()) {
+ clock->setPaused(true);
+ motion->walkSpeedMps = 30.0f;
+ detail->spawnDistanceSq =
+ 10000.0f * 10000.0f;
+ detail->despawnDistanceSq =
+ 20000.0f * 20000.0f;
+ detail->minDwellSeconds = 0.0f;
+ detail->spawnBudgetPerFrame = 40;
+ for (uint64_t id : ids)
+ if (!isTrafficDriver(id))
+ candidates.push_back(id);
}
- if (!npc)
- return fail("no non-driving NPC in the "
- "population");
- CharacterRegistry::CharacterRecord *rec =
- reg.findCharacter(npc);
- if (!rec)
- return fail("NPC 0 has no registry record");
- std::string dest;
- auto it = rec->stringColumns.find("npcInterest");
- if (it != rec->stringColumns.end())
- dest = it->second;
- if (dest.empty())
- return fail("NPC 0 has no interest binding");
- if (!taxi->assignTaxiGoal(npc, dest))
- return fail("assignTaxiGoal failed");
- std::cout << "[test] taxi goal: NPC " << npc << " -> "
- << dest << std::endl;
- phase = 1;
- return true;
+ /* Pick the next candidate (the content lottery can
+ * park an NPC's detail entity against an obstacle
+ * — e.g. spawned below a road embankment where
+ * the boarding door is unclimbable — so a stalled
+ * candidate is cancelled and the next one tried;
+ * see the stall timeouts in phases 1/2). */
+ while (candIdx < candidates.size()) {
+ npc = candidates[candIdx];
+ CharacterRegistry::CharacterRecord *rec =
+ reg.findCharacter(npc);
+ if (!rec) {
+ candIdx++;
+ continue;
+ }
+ std::string dest;
+ auto it = rec->stringColumns.find(
+ "npcInterest");
+ if (it != rec->stringColumns.end())
+ dest = it->second;
+ if (dest.empty()) {
+ candIdx++;
+ continue;
+ }
+ if (!taxi->assignTaxiGoal(npc, dest)) {
+ candIdx++;
+ continue;
+ }
+ stallFrames = 0;
+ std::cout << "[test] taxi goal: NPC " << npc
+ << " -> " << dest << std::endl;
+ phase = 1;
+ return true;
+ }
+ return fail("no candidate NPC accepted a taxi goal");
}
NpcTaxiSystem::TaxiInfo ti;
bool hasGoal = taxi->taxiInfoOf(npc, ti);
if (phase == 1) {
- if (!hasGoal)
+ if (!hasGoal) {
+ /* The goal was dropped (a walk stall can
+ * also end here): try the next
+ * candidate. */
+ if (advanceCandidate())
+ return true;
return fail("goal lost before pickup");
- if (ti.state != NpcTaxiSystem::State::WaitAtCurb)
+ }
+ if (ti.state != NpcTaxiSystem::State::WaitAtCurb) {
+ /* Stall: the walk-to-curb must finish
+ * quickly at 30 m/s symbolic speed. */
+ if (++stallFrames > 900) {
+ std::cout
+ << "[test] taxi: NPC " << npc
+ << " walk-to-curb stalled; "
+ "trying the next candidate"
+ << std::endl;
+ taxi->cancelGoal(npc);
+ candIdx++;
+ phase = 0;
+ }
return true;
+ }
std::cout << "[test] NPC waiting at " << ti.pickupCurb
<< " (frame " << frame << ")" << std::endl;
+ stallFrames = 0;
phase = 2;
return true;
}
@@ -1436,34 +1762,22 @@ struct TaxiTestListener : public Ogre::FrameListener {
return true;
}
if (ti.state != NpcTaxiSystem::State::Riding) {
- if (frame % 600 == 0) {
- flecs::entity ch =
- reg.findSpawnedEntity(npc);
- Ogre::Vector3 p = Ogre::Vector3::ZERO;
- if (ch.is_alive() &&
- ch.has() &&
- ch.get().node)
- p = ch.get()
- .node
- ->_getDerivedPosition();
- flecs::entity veh = app->getWorld()
- ->entity(vehicleId);
- Ogre::Vector3 vp = Ogre::Vector3::ZERO;
- if (veh.is_alive() &&
- veh.has() &&
- veh.get()
- .node)
- vp = veh.get()
- .node
- ->_getDerivedPosition();
- std::cout << "[dbg] taxi wait state "
- << (int)ti.state << " entity "
- << (ch.is_alive() ? 1 : 0)
- << " pos " << p.x << " " << p.y
- << " " << p.z << " veh "
- << vp.x << " " << vp.y << " "
- << vp.z << " frame "
- << frame << std::endl;
+ /* Stall: boarding at 30 m/s reach is
+ * quick; a candidate that cannot reach
+ * the door (obstacle/embankment) is
+ * swapped for the next one. */
+ if (++stallFrames > 2400) {
+ std::cout
+ << "[test] taxi: NPC " << npc
+ << " boarding stalled; trying "
+ "the next candidate"
+ << std::endl;
+ taxi->cancelGoal(npc);
+ teleportedToPickup = false;
+ teleportedToDest = false;
+ vehicleId = 0;
+ candIdx++;
+ phase = 0;
}
return true;
}
@@ -1503,11 +1817,29 @@ struct TaxiTestListener : public Ogre::FrameListener {
parkBesideCurb(vehicle,
destDropWorld(taxi, ti));
teleportedToDest = true;
+ stallFrames = 0;
std::cout
<< "[test] sedan teleported to "
"the destination"
<< std::endl;
}
+ /* Stall guard: after the destination
+ * teleport the alight + final walk are
+ * quick at 30 m/s; a candidate stuck
+ * against an obstacle is swapped for
+ * the next one. */
+ if (++stallFrames > 2400) {
+ std::cout
+ << "[test] taxi: NPC " << npc
+ << " destination walk stalled; "
+ "trying the next candidate"
+ << std::endl;
+ taxi->cancelGoal(npc);
+ if (advanceCandidate())
+ return true;
+ return fail("destination walk stalled "
+ "on the last candidate");
+ }
/* Wait for the goal to end (alight + walk to
* the destination location). */
return true;
@@ -4854,8 +5186,13 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
int teleportFrames = 0;
Ogre::Vector3 teleportTarget = Ogre::Vector3::ZERO;
- static const uint32_t kDay = 5;
- static const int kProbes = 192; /* 6:00 .. 22:00 in 5 min steps */
+ /* 6.8.4b: the weekly model makes days 5/6 (Sat/Sun) weekend
+ * templates; this sweep exercises the workday arc, so it pins
+ * day 0 (Monday). The probes step ~1.7 min: whole-hour leg
+ * arrivals give ~1-2 min drive windows, which the old 5-min
+ * grid structurally missed. */
+ static const uint32_t kDay = 0;
+ static const int kProbes = 576; /* 6:00..22:00 sweep */
int probe = 0;
int settleFrames = 0;
std::vector>> snaps;
@@ -4915,6 +5252,12 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
void recordProbe(NpcTrafficSystem *traffic, float hour)
{
+ (void)hour;
+ /* 6.8.4b: re-assert the pinned sweep parameters every
+ * probe (the phase-0 pin alone was observed to race a
+ * population-boot eval at the default walk threshold). */
+ traffic->maxWalkDistanceM = 50.0f;
+ traffic->cruiseSpeed = 11.0f;
for (uint64_t id : traffic->carDriverIds()) {
NpcTrafficSystem::TrafficInfo info;
if (!traffic->trafficInfoOf(id, info))
@@ -5036,7 +5379,17 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
/* Part B setup: jump back to the observed driving
* leg, restore the high-detail tier, run the clock
* at real time so the AI driver and the schedule
- * advance together. */
+ * advance together.
+ * 6.8.4b gotcha: GameClockSystem re-adopts a sun on
+ * ANY divergence — including its speed — and
+ * re-adoption also takes the sun's TIME, so a
+ * setSpeed(1.0) against the scene's 0.13-h/s sun
+ * silently rolled the clock back to the last sweep
+ * hour and the observation free-ran away from the
+ * drive window. Align the suns' speed with the
+ * test clock FIRST so no divergence fires. */
+ app->getWorld()->query().each(
+ [](SunComponent &sun) { sun.timeSpeed = 1.0f; });
traffic->maxActiveVehicles = 8;
clock->setTime(kDay, std::max(6.0f, driveHour - 0.05f));
clock->setSpeed(1.0f);
@@ -5152,8 +5505,11 @@ struct ParkingTestListener : public Ogre::FrameListener {
bool failed = false;
Ogre::String failReason;
- static const uint32_t kDay = 5;
- static const int kProbes = 192; /* 5-minute drive-window coverage */
+ /* 6.8.4b: sweep the workday arc (the weekly model makes day 5
+ * Saturday). Probe step ~1.7 min (the whole-hour leg windows
+ * are 1-2 min). */
+ static const uint32_t kDay = 0;
+ static const int kProbes = 576;
int probe = 0;
struct Snap {
int phase = 0;
@@ -5452,6 +5808,13 @@ struct ParkingTestListener : public Ogre::FrameListener {
}
if (phase == 3) {
+ /* 6.8.4b gotcha: align the suns' clock speed with
+ * the real-time speed first — GameClockSystem
+ * re-adopts a sun on ANY divergence including its
+ * speed and re-adoption takes the sun's time too,
+ * which would undo the setTime jump below. */
+ app->getWorld()->query().each(
+ [](SunComponent &sun) { sun.timeSpeed = 1.0f; });
traffic->maxActiveVehicles = 8;
clock->setTime(kDay, std::max(6.0f, driveHour - 0.05f));
clock->setSpeed(1.0f);
@@ -8694,6 +9057,1059 @@ struct PoiTestListener : public Ogre::FrameListener {
}
};
+/*
+ * --test-dsl-poi (phase 6.8.6): a hand-authored interior grid built
+ * from the layout DSL declares interior POIs through the room's
+ * :poi option, and the TownGen materialization turns them into
+ * runtime PoiComponent entities wired into the SAME mechanical
+ * machinery component-authored POIs use. Phases:
+ * 1: the preview grid's Poi children exist with stable ids
+ * ("poi...") and the decl's planner
+ * data, merged into the area's POI database;
+ * 2: the behavior-backed POI has its ensured ActuatorComponent
+ * (the E-prompt path);
+ * 3: the OnApproach POI fires the probe action when the player
+ * enters its radius;
+ * 4: the OnArrive Lua POI fires poi_activated after the dwell;
+ * 5: re-previewing the layout keeps every poiId bit-for-bit
+ * (rebuild stability).
+ */
+struct DslPoiTestListener : public Ogre::FrameListener {
+ EditorApp *app;
+ int frame = 0;
+ int phase = 0;
+ int stepFrame = 0;
+ bool failed = false;
+ Ogre::String failReason;
+ flecs::entity building;
+ std::map poi1;
+ std::map poi2;
+ std::string dslSource =
+ "(shell 16 8 :empty)"
+ "(floor 0"
+ " (room shopfront (rect 0 0 1 0.5)"
+ " :type store"
+ " :poi ("
+ " (counter :kind work :tags (work counter)"
+ " :effect (money 2) :effect (stress -1)"
+ " :activation approach"
+ " :behavior dsl_poi_action"
+ " :radius 1.8 :cooldown 60 :once)))"
+ " (room backroom (rect 0 0.5 1 1)"
+ " :poi ("
+ " (bench :kind work :tags (work workbench)"
+ " :activation arrive :lua probe_arrive.lua"
+ " :radius 1.6 :cooldown 10))))";
+
+ explicit DslPoiTestListener(EditorApp *a)
+ : app(a)
+ {
+ }
+
+ void fail(const Ogre::String &msg)
+ {
+ failed = true;
+ failReason = msg;
+ std::cout << "[test] dsl-poi FAIL: " << msg << std::endl;
+ app->getRoot()->queueEndRendering();
+ }
+
+ void enter(int newPhase)
+ {
+ phase = newPhase;
+ stepFrame = frame;
+ }
+
+ void setupScript()
+ {
+ const char *script = R"(
+ ecs.behavior_tree.register_node("dslPoiMark",
+ function(entity_id, params)
+ ecs.global.set("dsl_poi_fired", 1)
+ return "success"
+ end)
+ ecs.action_db.add_action("dsl_poi_action", 1, {}, {},
+ { type = "sequence", children = {
+ ecs.behavior_tree.create_node(
+ "dslPoiMark", "") } })
+ ecs.subscribe_event("poi_activated",
+ function(event, params)
+ ecs.global.set("dsl_poi_lua_fired",
+ 1)
+ end)
+ )";
+ lua_State *L = app->getLuaState()->getState();
+ if (luaL_dostring(L, script) != LUA_OK) {
+ const char *err = lua_tostring(L, -1);
+ fail(std::string("probe Lua script failed: ") +
+ (err ? err : "unknown error"));
+ lua_pop(L, 1);
+ }
+ }
+
+ void preview()
+ {
+ TownGenSystem tgs(*app->getWorld(), app->getSceneManager(),
+ app->getEditorPhysicsSystem()
+ ->getPhysicsWrapper());
+ flecs::entity player = app->getPlayerCharacterEntity();
+ Ogre::Vector3 base(0, 0, 0);
+ if (player.is_alive() && player.has()) {
+ const auto &t = player.get();
+ if (t.node)
+ base = t.node->getPosition();
+ }
+ std::string err;
+ if (!tgs.previewLayout(dslSource, nullptr, 0 /*village*/, 42,
+ base, "full", &err)) {
+ fail(std::string("previewLayout failed: ") + err);
+ }
+ }
+
+ void collectPois(std::map &out)
+ {
+ out.clear();
+ building = flecs::entity();
+ flecs::entity container;
+ app->getWorld()->query().each(
+ [&](flecs::entity e, EntityNameComponent &name,
+ TownComponent &) {
+ if (name.name == "Layout Preview")
+ container = e;
+ });
+ if (container.is_alive())
+ container.children([&](flecs::entity child) {
+ if (child.has() &&
+ !building.is_alive())
+ building = child;
+ });
+ if (!building.is_alive())
+ return;
+ building.children([&](flecs::entity child) {
+ if (!child.has())
+ return;
+ const auto &poi = child.get();
+ out[poi.poiId] = poi;
+ });
+ }
+
+ bool frameRenderingQueued(const Ogre::FrameEvent &) override
+ {
+ frame++;
+ if (failed || phase == 9) {
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ if (frame > 8000) {
+ fail("timeout in phase " + std::to_string(phase));
+ return true;
+ }
+
+ NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
+ switch (phase) {
+ case 0: {
+ if (frame == 1)
+ setupScript();
+ if (!pop || pop->areaIds().empty())
+ return true;
+ flecs::entity player =
+ app->getPlayerCharacterEntity();
+ if (!player.is_alive())
+ return true;
+ preview();
+ enter(1);
+ return true;
+ }
+ case 1: {
+ /* The preview spawned POIs + the database merge. */
+ if (frame - stepFrame < 30)
+ return true;
+ collectPois(poi1);
+ if (building.is_alive() && !poi1.empty()) {
+ /* ExterialOnly preview skipped building
+ * collection — full mode always has the
+ * grid so this is safe. */
+ } else if (frame - stepFrame > 600) {
+ fail("preview created no building");
+ return true;
+ }
+ if (poi1.size() != 2) {
+ if (frame - stepFrame > 600) {
+ fail("expected 2 DSL POIs, got " +
+ std::to_string(poi1.size()));
+ return true;
+ }
+ return true;
+ }
+ for (const auto &kv : poi1) {
+ const std::string &id = kv.first;
+ if (id.find("poi.town-Layout Preview "
+ "Building.") != 0 ||
+ (id.find(".shopfront.") ==
+ std::string::npos &&
+ id.find(".backroom.") ==
+ std::string::npos)) {
+ fail("POI id '" + id +
+ "' not in the stable "
+ "poi..."
+ " shape");
+ return true;
+ }
+ }
+ const PoiDatabase::Set *set =
+ pop ? pop->areaPoiSet(pop->areaIds()[0]) :
+ nullptr;
+ const PoiDatabase::Record *rec =
+ set ? set->findById(
+ "poi.town-Layout Preview "
+ "Building.shopfront.counter") :
+ nullptr;
+ if (rec) {
+ if (rec->kind != "work" ||
+ rec->activation.kind !=
+ PoiDatabase::ActivationKind::
+ OnApproach ||
+ rec->activation.behaviorTree !=
+ "dsl_poi_action" ||
+ rec->activation.radius != 1.8f ||
+ !rec->activation.once ||
+ rec->statEffects.find("money") ==
+ rec->statEffects.end() ||
+ rec->statEffects.at("money") != 2.0) {
+ fail("merged DSL POI record has "
+ "wrong fields");
+ return true;
+ }
+ enter(2);
+ return true;
+ }
+ if (frame - stepFrame > 600) {
+ fail("DSL POI not merged into the "
+ "POI database");
+ return true;
+ }
+ return true;
+ }
+ case 2: {
+ /* The E-prompt machinery: the behavior-backed POI
+ * carries an ensured ActuatorComponent. */
+ flecs::entity e;
+ building.children([&](flecs::entity child) {
+ if (child.has() &&
+ child.get()
+ .behaviorTree ==
+ "dsl_poi_action")
+ e = child;
+ });
+ if (!e.is_alive()) {
+ fail("counter POI entity lost");
+ return true;
+ }
+ if (!e.has()) {
+ if (frame - stepFrame > 600) {
+ fail("behavior-backed DSL POI "
+ "never gained its "
+ "ActuatorComponent");
+ return true;
+ }
+ return true;
+ }
+ const auto &actuator = e.get();
+ bool found = false;
+ for (const auto &name : actuator.actionNames)
+ if (name == "dsl_poi_action")
+ found = true;
+ if (!found) {
+ fail("ensured actuator lists the wrong "
+ "action");
+ return true;
+ }
+ std::cout << "[test] dsl-poi: actuator ensured on "
+ "the counter POI"
+ << std::endl;
+ /* Teleport the player into the approach radius. */
+ const auto &t = e.get();
+ Ogre::Vector3 pos = t.hasWorldPosition ?
+ RenderOriginSystem::
+ getInstance()
+ ->worldToRender(
+ t.worldX,
+ t.worldY,
+ t.worldZ) :
+ (t.node ?
+ t.node
+ ->_getDerivedPosition() :
+ t.position);
+ flecs::entity player =
+ app->getPlayerCharacterEntity();
+ if (!player.is_alive()) {
+ fail("player lost");
+ return true;
+ }
+ {
+ auto &pt = player.get_mut<
+ TransformComponent>();
+ if (pt.node)
+ pt.node->setPosition(pos);
+ pt.position = pos;
+ pt.applyToNode();
+ }
+ enter(3);
+ return true;
+ }
+ case 3: {
+ int fired = (int)GlobalStateStore::getInstance()
+ .getInt("dsl_poi_fired", 0);
+ if (fired == 1) {
+ /* Next: the OnArrive Lua POI. */
+ flecs::entity player =
+ app->getPlayerCharacterEntity();
+ if (!player.is_alive()) {
+ fail("player lost");
+ return true;
+ }
+ Ogre::Vector3 benchPos;
+ bool haveBench = false;
+ building.children([&](flecs::entity child) {
+ if (child.has() &&
+ child.get()
+ .luaScript ==
+ "probe_arrive.lua") {
+ const auto &t = child.get<
+ TransformComponent>();
+ benchPos = t.hasWorldPosition
+ ? RenderOriginSystem::
+ getInstance()
+ ->worldToRender(t.worldX,
+ t.worldY,
+ t.worldZ)
+ :
+ (t.node ?
+ t.node
+ ->_getDerivedPosition() :
+ t.position);
+ haveBench = true;
+ }
+ });
+ if (!haveBench) {
+ fail("bench POI entity lost");
+ return true;
+ }
+ {
+ auto &pt = player.get_mut<
+ TransformComponent>();
+ if (pt.node)
+ pt.node->setPosition(
+ benchPos);
+ pt.position = benchPos;
+ pt.applyToNode();
+ }
+ enter(4);
+ return true;
+ }
+ if (frame - stepFrame > 900)
+ fail("approach POI never fired");
+ return true;
+ }
+ case 4: {
+ int fired = (int)GlobalStateStore::getInstance()
+ .getInt("dsl_poi_lua_fired", 0);
+ if (fired == 1) {
+ std::cout << "[test] dsl-poi: arrive "
+ "poi_activated event fired"
+ << std::endl;
+ enter(5);
+ return true;
+ }
+ if (frame - stepFrame > 900)
+ fail("arrive POI never fired the "
+ "poi_activated event");
+ return true;
+ }
+ case 5: {
+ /* Rebuild stability: re-previewing the SAME
+ * layout reproduces every poiId. */
+ preview();
+ enter(6);
+ return true;
+ }
+ case 6: {
+ if (frame - stepFrame < 30)
+ return true;
+ collectPois(poi2);
+ if (poi2.empty()) {
+ if (frame - stepFrame > 600) {
+ fail("re-preview produced no "
+ "POIs");
+ return true;
+ }
+ return true;
+ }
+ if (poi2.size() != poi1.size()) {
+ fail("re-preview POI count differs (" +
+ std::to_string(poi2.size()) + " vs " +
+ std::to_string(poi1.size()) + ")");
+ return true;
+ }
+ for (const auto &kv : poi1)
+ if (!poi2.count(kv.first)) {
+ fail("re-preview lost poiId '" +
+ kv.first + "'");
+ return true;
+ }
+ std::cout << "[test] PASS: dsl poi (ids stable, "
+ "db merge, actuator, approach BT, "
+ "arrive Lua)"
+ << std::endl;
+ phase = 9;
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ }
+ return true;
+ }
+};
+
+/*
+ * --test-week-revision (phase 6.8.10): an explicit mid-week revision
+ * re-resolves the REST of the current week with the CURRENT stats
+ * snapshot, spliced at the current clock so in-flight goals complete
+ * first, and the whole weekly store (weeks, usage, stats, revision
+ * markers) rides save/load. The neutral-stats case asserts the
+ * revision is a no-op on entries but is RECORDED (weekIndex,
+ * graphVersion, revisionId); then a save → reload round trip keeps
+ * weeks and revisions bit-identical.
+ */
+struct WeekRevisionTestListener : public Ogre::FrameListener {
+ EditorApp *app;
+ int frame = 0;
+ int phase = 0;
+ int stepFrame = 0;
+ bool failed = false;
+ Ogre::String failReason;
+
+ explicit WeekRevisionTestListener(EditorApp *a)
+ : app(a)
+ {
+ }
+
+ void fail(const Ogre::String &msg)
+ {
+ failed = true;
+ failReason = msg;
+ std::cout << "[test] week revision FAIL: " << msg
+ << std::endl;
+ app->getRoot()->queueEndRendering();
+ }
+
+ bool frameRenderingQueued(const Ogre::FrameEvent &) override
+ {
+ ++frame;
+ if (failed || phase == 9) {
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ if (frame > 4000) {
+ fail("timeout in phase " + std::to_string(phase));
+ return true;
+ }
+
+ NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
+ if (!pop || pop->areaIds().empty())
+ return true;
+ const int area = pop->areaIds().front();
+ uint64_t someId = pop->populationCharacterIds().front();
+
+ switch (phase) {
+ case 0: {
+ const TownSchedule::WeekSchedule *week =
+ pop->weekScheduleOf(someId);
+ if (!week)
+ return true;
+ std::vector before =
+ week->entries;
+ if (!pop->reviseWeek(area, "probe-reason", 1)) {
+ fail("reviseWeek returned false");
+ return true;
+ }
+ const TownSchedule::WeekSchedule *after =
+ pop->weekScheduleOf(someId);
+ if (!after || after->entries != before) {
+ fail("revision changed the neutral-stats week "
+ "instead of being a recorded no-op");
+ return true;
+ }
+ nlohmann::json saved =
+ pop->serializeWeekSchedules();
+ if (!saved.contains(std::to_string(area)) ||
+ saved[std::to_string(area)]
+ ["revisions"]
+ .size() != 1) {
+ fail("revision marker not recorded");
+ return true;
+ }
+ const auto &rev =
+ saved[std::to_string(area)]["revisions"][0];
+ if (rev.value("reason", std::string()) !=
+ "probe-reason" ||
+ rev.value("graphVersion", 0u) != 1 ||
+ rev.value("revisionId", 0u) != 0) {
+ fail("revision marker fields wrong");
+ return true;
+ }
+ const std::string path =
+ "/tmp/opencode/week_revision_test.json";
+ app->saveGame(path, "probe");
+ app->loadGame(path);
+ stepFrame = frame;
+ phase = 1;
+ return true;
+ }
+ case 1: {
+ const TownSchedule::WeekSchedule *restored =
+ pop->weekScheduleOf(someId);
+ if (!restored) {
+ fail("reload lost the NPC's week");
+ return true;
+ }
+ nlohmann::json saved =
+ pop->serializeWeekSchedules();
+ if (!saved.contains(std::to_string(area)) ||
+ saved[std::to_string(area)]
+ ["revisions"]
+ .size() != 1) {
+ fail("reload lost the revision marker");
+ return true;
+ }
+ const auto &rev =
+ saved[std::to_string(area)]["revisions"][0];
+ if (rev.value("reason", std::string()) !=
+ "probe-reason") {
+ fail("reloaded revision reason differs");
+ return true;
+ }
+ if (!pop->weekDayScheduleOf(someId, 0)) {
+ fail("reload lost the day views");
+ return true;
+ }
+ /* After a reload, terrain/road streaming may need
+ * a few frames before the area graph is back. */
+ TerrainSystem *ts = TerrainSystem::getInstance();
+ if (!ts || !ts->getRoadSystem() ||
+ !ts->getRoadSystem()
+ ->getTerrainEntity()
+ .is_alive())
+ return true;
+ if (!pop->reviseWeek(area, "probe-reason-2", 2)) {
+ if (frame - stepFrame > 900)
+ fail("second reviseWeek failed "
+ "(graph not ready after reload)");
+ return true;
+ }
+ nlohmann::json again =
+ pop->serializeWeekSchedules();
+ if (again[std::to_string(area)]
+ ["revisions"]
+ .size() != 2) {
+ fail("second revision not appended");
+ return true;
+ }
+ const auto &rev2 = again[std::to_string(area)]
+ ["revisions"][1];
+ if (rev2.value("graphVersion", 0u) != 2 ||
+ rev2.value("revisionId", 0u) != 1) {
+ fail("second revision fields wrong");
+ return true;
+ }
+ std::cout << "[test] PASS: week revision (recorded "
+ "no-op, markers, save → reload round trip)"
+ << std::endl;
+ phase = 9;
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ }
+ return true;
+ }
+};
+
+/*
+ * Emergency battery (phase 6.8.14): a health emergency is the
+ * highest-priority goal. The stats rules module registers the
+ * thresholds through ecs.emergency.set_thresholds (data-driven);
+ * the engine's CharacterStatStore subscription triggers/clears the
+ * emergency on health CHANGE events. Asserts: the synthetic
+ * hospital schedule supersedes the baseline, the NPC actually
+ * travels there, the state survives save → reload, discharge
+ * restores the pure baseline, taxi goals are refused while it
+ * lasts, and a town without a hospital falls back to home rest.
+ */
+struct EmergencyTestListener : public Ogre::FrameListener {
+ EditorApp *app;
+ int frame = 0;
+ int phase = 0;
+ int stepFrame = 0;
+ bool failed = false;
+ Ogre::String failReason;
+ uint64_t npc = 0;
+ std::string emergencyLoc;
+ double initialDist = 0.0;
+ std::string savedReason;
+
+ explicit EmergencyTestListener(EditorApp *a)
+ : app(a)
+ {
+ }
+
+ void fail(const Ogre::String &msg)
+ {
+ failed = true;
+ failReason = msg;
+ std::cout << "[test] emergency FAIL: " << msg << std::endl;
+ app->getRoot()->queueEndRendering();
+ }
+
+ bool areaHasHospital(int area)
+ {
+ NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
+ const TownLocations::LocationSet *locs =
+ pop->areaLocations(area);
+ if (!locs)
+ return false;
+ for (const TownLocations::NpcLocation &loc :
+ locs->locations) {
+ if (loc.kind != TownLocations::LocationKind::Work)
+ continue;
+ for (const std::string &tag : loc.tags)
+ if (tag == "hospital")
+ return true;
+ }
+ return false;
+ }
+
+ bool frameRenderingQueued(const Ogre::FrameEvent &) override
+ {
+ ++frame;
+ if (failed || phase == 9) {
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ if (frame > 12000) {
+ fail("timeout in phase " + std::to_string(phase));
+ return true;
+ }
+
+ NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
+ if (!pop || pop->areaIds().empty())
+ return true;
+
+ switch (phase) {
+ case 0: {
+ if (pop->populationCharacterIds().empty())
+ return true;
+ /* Load the shipped stats rules module through the
+ * app's Lua state: define() declares the stats and
+ * registers the emergency thresholds (the full
+ * shipped data path). */
+ lua_State *L = app->getLuaState()->getState();
+ if (luaL_dostring(L,
+ "local rules = "
+ "require('character_stats_rules'); "
+ "rules.define()") != LUA_OK) {
+ const char *err =
+ lua_tostring(L, -1) ?
+ lua_tostring(L, -1) :
+ "(no error)";
+ fail(Ogre::String(
+ "stats rules module failed: ") +
+ err);
+ return true;
+ }
+ /* A non-driving NPC (a driver's refusal would be
+ * ambiguous) that is NOT already at its area's
+ * hospital — hospital staff (doctor roster) stand
+ * there and would make the travel assertion
+ * vacuous. */
+ NpcTrafficSystem *traffic =
+ NpcTrafficSystem::getInstance();
+ for (uint64_t id : pop->populationCharacterIds()) {
+ if (traffic && (traffic->isDriving(id) ||
+ traffic->isSeated(id)))
+ continue;
+ int area = pop->areaIndexOf(id);
+ const TownLocations::LocationSet *locs =
+ area >= 0 ?
+ pop->areaLocations(area) :
+ nullptr;
+ const TownLocations::NpcLocation
+ *hospital = nullptr;
+ if (locs) {
+ for (const TownLocations::
+ NpcLocation &loc :
+ locs->locations) {
+ if (loc.kind !=
+ TownLocations::
+ LocationKind::
+ Work)
+ continue;
+ bool isHospital = false;
+ for (const std::string &tag :
+ loc.tags)
+ if (tag ==
+ "hospital")
+ isHospital =
+ true;
+ if (isHospital) {
+ hospital = &loc;
+ break;
+ }
+ }
+ }
+ /* Towns without a hospital use the home
+ * fallback — any NPC not at home works
+ * there; the distance filter below uses
+ * the hospital when present. */
+ CharacterRegistry::CharacterRecord *rec =
+ CharacterRegistry::getSingleton()
+ .findCharacter(id);
+ if (!hospital || !rec) {
+ npc = id;
+ break;
+ }
+ double dist =
+ (Ogre::Vector2(rec->position.x,
+ rec->position.z) -
+ Ogre::Vector2(hospital->position.x,
+ hospital->position.z))
+ .length();
+ if (dist > 10.0) {
+ npc = id;
+ break;
+ }
+ }
+ if (!npc) {
+ fail("no suitable NPC in the population "
+ "(all drivers or all already at the "
+ "hospital)");
+ return true;
+ }
+ if (pop->hasActiveEmergency(npc))
+ return true; /* wait for a clean start */
+ if (!pop->weekDayScheduleOf(npc, 0) &&
+ !pop->scheduleOf(npc)) {
+ fail("NPC has no baseline schedule");
+ return true;
+ }
+ /* Trigger: a health CHANGE event below the
+ * registered threshold (10). The listener fires
+ * synchronously inside set(). */
+ if (luaL_dostring(L,
+ (Ogre::String(
+ "ecs.stats.set(") +
+ std::to_string(npc) +
+ ", 'health', 5)")
+ .c_str()) != LUA_OK) {
+ fail("ecs.stats.set failed");
+ return true;
+ }
+ if (!pop->hasActiveEmergency(npc)) {
+ fail("health drop did not begin an emergency "
+ "(thresholds not registered or listener "
+ "not wired)");
+ return true;
+ }
+ const TownSchedule::NpcSchedule *em =
+ pop->emergencyScheduleOf(npc);
+ if (!em || em->entries.size() != 24) {
+ fail("emergency schedule is not the 24-entry "
+ "synthetic");
+ return true;
+ }
+ emergencyLoc = em->entries[0].location;
+ /* Pre-trigger distance (the motion system has not
+ * run yet this frame): the picker guaranteed
+ * > 10 m; the emergency must bring the NPC there
+ * (symbolic transport). */
+ const TownLocations::LocationSet *locs0 =
+ pop->areaLocations(pop->areaIndexOf(npc));
+ const TownLocations::NpcLocation *loc0 =
+ locs0 ? locs0->findByName(emergencyLoc) :
+ nullptr;
+ CharacterRegistry::CharacterRecord *rec0 =
+ CharacterRegistry::getSingleton()
+ .findCharacter(npc);
+ if (!loc0 || !rec0) {
+ fail("emergency location or NPC record "
+ "missing");
+ return true;
+ }
+ initialDist = (Ogre::Vector2(rec0->position.x,
+ rec0->position.z) -
+ Ogre::Vector2(loc0->position.x,
+ loc0->position.z))
+ .length();
+ std::cout << "[test] emergency began for NPC " << npc
+ << " at " << emergencyLoc
+ << " (pre-trigger distance "
+ << initialDist << " m)" << std::endl;
+ phase = 1;
+ return true;
+ }
+ case 1: {
+ /* The effective schedule is the synthetic one,
+ * every entry pointing at the emergency
+ * location. */
+ int weekday = (int)std::floor(
+ GameClockSystem::getInstance()
+ ->getAbsoluteHours() /
+ 24.0f) %
+ 7;
+ const TownSchedule::NpcSchedule *eff =
+ pop->effectiveDayScheduleOf(npc, weekday);
+ if (!eff || eff->entries.size() != 24) {
+ fail("effectiveDayScheduleOf did not return "
+ "the emergency schedule");
+ return true;
+ }
+ for (const TownSchedule::ScheduleEntry &e :
+ eff->entries) {
+ if (e.location != emergencyLoc ||
+ e.activity != "hospital") {
+ fail("emergency schedule entries do "
+ "not tile the emergency "
+ "location");
+ return true;
+ }
+ }
+ /* Symbolic transport: the NPC must be AT the
+ * emergency location now (it started >10 m
+ * away). */
+ const TownLocations::LocationSet *locs =
+ pop->areaLocations(
+ pop->areaIndexOf(npc));
+ const TownLocations::NpcLocation *loc =
+ locs ? locs->findByName(emergencyLoc) :
+ nullptr;
+ CharacterRegistry::CharacterRecord *rec =
+ CharacterRegistry::getSingleton()
+ .findCharacter(npc);
+ if (!loc || !rec) {
+ fail("emergency location or NPC record "
+ "lost");
+ return true;
+ }
+ double dist = (Ogre::Vector2(rec->position.x,
+ rec->position.z) -
+ Ogre::Vector2(loc->position.x,
+ loc->position.z))
+ .length();
+ if (dist < 5.0) {
+ stepFrame = frame;
+ phase = 2;
+ return true;
+ }
+ if (frame - stepFrame > 600) {
+ fail("NPC did not move to the emergency "
+ "location (still "
+ + std::to_string(dist) + " m away)");
+ }
+ return true;
+ }
+ case 2: {
+ /* Save mid-emergency, reload. */
+ app->saveGame("/tmp/opencode/emergency_test.json",
+ "probe");
+ app->loadGame("/tmp/opencode/emergency_test.json");
+ stepFrame = frame;
+ phase = 3;
+ return true;
+ }
+ case 3: {
+ /* After the reload the population must be back
+ * AND the emergency restored. */
+ if (pop->areaIds().empty() ||
+ pop->populationCharacterIds().empty())
+ return true;
+ bool idBack = false;
+ for (uint64_t id : pop->populationCharacterIds())
+ if (id == npc) {
+ idBack = true;
+ break;
+ }
+ if (!idBack) {
+ if (frame - stepFrame > 900)
+ fail("reloaded population lost the "
+ "NPC");
+ return true;
+ }
+ if (!pop->hasActiveEmergency(npc)) {
+ if (frame - stepFrame > 900)
+ fail("reload lost the active "
+ "emergency");
+ return true;
+ }
+ const TownSchedule::NpcSchedule *em =
+ pop->emergencyScheduleOf(npc);
+ if (!em || em->entries.size() != 24 ||
+ em->entries[0].location != emergencyLoc) {
+ fail("restored emergency schedule differs");
+ return true;
+ }
+ /* Heal: discharge at >= the registered threshold
+ * (100). */
+ lua_State *L = app->getLuaState()->getState();
+ if (luaL_dostring(L,
+ (Ogre::String(
+ "ecs.stats.set(") +
+ std::to_string(npc) +
+ ", 'health', 100)")
+ .c_str()) != LUA_OK) {
+ fail("ecs.stats.set (heal) failed");
+ return true;
+ }
+ if (pop->hasActiveEmergency(npc)) {
+ fail("discharge threshold did not end the "
+ "emergency");
+ return true;
+ }
+ phase = 4;
+ return true;
+ }
+ case 4: {
+ /* The baseline simply resumed: the effective
+ * schedule is the weekly day view again, and a
+ * scripted taxi goal is accepted once more
+ * (re-assign then drop it to leave no state). */
+ int weekday = (int)std::floor(
+ GameClockSystem::getInstance()
+ ->getAbsoluteHours() /
+ 24.0f) %
+ 7;
+ const TownSchedule::NpcSchedule *eff =
+ pop->effectiveDayScheduleOf(npc, weekday);
+ const TownSchedule::NpcSchedule *base =
+ pop->weekDayScheduleOf(npc, weekday);
+ if (!eff || !base || eff != base) {
+ fail("baseline schedule did not resume "
+ "after discharge");
+ return true;
+ }
+ /* Re-trigger for the refusal check. */
+ lua_State *L = app->getLuaState()->getState();
+ if (luaL_dostring(L,
+ (Ogre::String(
+ "ecs.stats.set(") +
+ std::to_string(npc) +
+ ", 'health', 5)")
+ .c_str()) != LUA_OK) {
+ fail("ecs.stats.set (re-trigger) failed");
+ return true;
+ }
+ if (!pop->hasActiveEmergency(npc)) {
+ fail("emergency did not re-trigger");
+ return true;
+ }
+ /* Taxi goals are refused during an emergency (a
+ * valid destination, so the refusal is really the
+ * emergency check). */
+ NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance();
+ CharacterRegistry::CharacterRecord *rec =
+ CharacterRegistry::getSingleton()
+ .findCharacter(npc);
+ if (!taxi || !rec) {
+ fail("taxi system or NPC record missing");
+ return true;
+ }
+ auto it = rec->stringColumns.find("npcInterest");
+ std::string dest = it == rec->stringColumns.end() ?
+ std::string() :
+ it->second;
+ if (dest.empty()) {
+ fail("NPC has no interest binding");
+ return true;
+ }
+ if (taxi->assignTaxiGoal(npc, dest)) {
+ fail("assignTaxiGoal accepted during an "
+ "emergency");
+ return true;
+ }
+ /* Heal again to leave a clean state. */
+ if (luaL_dostring(L,
+ (Ogre::String(
+ "ecs.stats.set(") +
+ std::to_string(npc) +
+ ", 'health', 100)")
+ .c_str()) != LUA_OK) {
+ fail("ecs.stats.set (heal 2) failed");
+ return true;
+ }
+ if (pop->hasActiveEmergency(npc)) {
+ fail("second discharge failed");
+ return true;
+ }
+ /* endEmergency on a non-emergency id: false. */
+ if (pop->endEmergency(npc)) {
+ fail("endEmergency returned true without an "
+ "emergency");
+ return true;
+ }
+ phase = 5;
+ return true;
+ }
+ case 5: {
+ /* The no-hospital fallback: an NPC whose area has
+ * no hospital Work location must be sent home
+ * (warned once). */
+ for (uint64_t id : pop->populationCharacterIds()) {
+ int area = pop->areaIndexOf(id);
+ if (area < 0 || areaHasHospital(area))
+ continue;
+ if (!pop->beginEmergency(
+ id, "probe-fallback"))
+ continue;
+ const TownSchedule::NpcSchedule *em =
+ pop->emergencyScheduleOf(id);
+ if (!em || em->entries.empty() ||
+ em->entries[0].location.compare(
+ 0, 5, "home:") != 0) {
+ fail("no-hospital emergency did not "
+ "fall back to home rest");
+ return true;
+ }
+ pop->endEmergency(id);
+ std::cout << "[test] no-hospital fallback "
+ "verified (NPC "
+ << id << " -> home rest)"
+ << std::endl;
+ std::cout
+ << "[test] PASS: emergency (threshold "
+ "trigger, hospital schedule, travel, "
+ "save → reload, discharge → "
+ "baseline, taxi refused)"
+ << std::endl;
+ phase = 9;
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ std::cout
+ << "[test] emergency note: no hospital-free "
+ "area in this town; home-fallback not "
+ "exercised here"
+ << std::endl;
+ std::cout
+ << "[test] PASS: emergency (threshold trigger, "
+ "hospital schedule, travel, save → reload, "
+ "discharge → baseline, taxi refused)"
+ << std::endl;
+ phase = 9;
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ }
+ return true;
+ }
+};
+
/*
* demo-taxi: taxi driver demo. Starts as an exact copy of
* demos/demo-interior-exterior-dynamics (same scene-switch-door setup,
@@ -8896,6 +10312,10 @@ int main(int argc, char *argv[])
bool testVehiclePark = false;
bool testPopulationSwitch = false;
bool testMotion = false;
+ bool testWeekRollover = false;
+ bool testDslPoi = false;
+ bool testWeekRevision = false;
+ bool testEmergency = false;
bool testDetail = false;
bool testTaxi = false;
bool testDeterminism = false;
@@ -8956,6 +10376,14 @@ int main(int argc, char *argv[])
testPopulationSwitch = true;
} else if (arg == "--test-motion") {
testMotion = true;
+ } else if (arg == "--test-week-rollover") {
+ testWeekRollover = true;
+ } else if (arg == "--test-dsl-poi") {
+ testDslPoi = true;
+ } else if (arg == "--test-week-revision") {
+ testWeekRevision = true;
+ } else if (arg == "--test-emergency") {
+ testEmergency = true;
} else if (arg == "--test-detail") {
testDetail = true;
} else if (arg == "--test-taxi") {
@@ -9077,6 +10505,25 @@ int main(int argc, char *argv[])
/* The motion test too. */
if (testMotion && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
+ /* The week-rollover test too. */
+ if (testWeekRollover && !sceneArgGiven)
+ sceneFile = "demo_scene_exterior.json";
+ /* The DSL-POI test too (the preview previews inside the
+ * town's POI scope). */
+ if (testDslPoi && !sceneArgGiven)
+ sceneFile = "demo_scene_exterior.json";
+ /* The week-revision test too (the weekly store lives
+ * in the exterior town). */
+ if (testWeekRevision && !sceneArgGiven)
+ sceneFile = "demo_scene_exterior.json";
+ /* The emergency test too (town population + hospital). */
+ if (testEmergency && !sceneArgGiven)
+ sceneFile = "demo_scene_exterior.json";
+ /* The POI activation test runs on the exterior scene
+ * (its probe POIs merge into the town's POI database). */
+ if (testPoi && !sceneArgGiven)
+ sceneFile = "demo_scene_exterior.json";
+ /* The detail-tier test too. */
/* The detail-tier test too. */
if (testDetail && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
@@ -9216,6 +10663,24 @@ int main(int argc, char *argv[])
if (testMotion)
app.getRoot()->addFrameListener(&motionListener);
+ WeekRolloverTestListener weekRolloverListener(&app);
+ if (testWeekRollover)
+ app.getRoot()->addFrameListener(
+ &weekRolloverListener);
+
+ DslPoiTestListener dslPoiListener(&app);
+ if (testDslPoi)
+ app.getRoot()->addFrameListener(&dslPoiListener);
+
+ WeekRevisionTestListener weekRevisionListener(&app);
+ if (testWeekRevision)
+ app.getRoot()->addFrameListener(
+ &weekRevisionListener);
+
+ EmergencyTestListener emergencyListener(&app);
+ if (testEmergency)
+ app.getRoot()->addFrameListener(&emergencyListener);
+
DetailTestListener detailListener(&app);
if (testDetail)
app.getRoot()->addFrameListener(&detailListener);
@@ -9486,6 +10951,66 @@ int main(int argc, char *argv[])
}
}
+ if (testWeekRollover) {
+ if (weekRolloverListener.failed) {
+ std::cerr << "[test] FAIL: "
+ << weekRolloverListener.failReason
+ << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ if (weekRolloverListener.phase != 4) {
+ std::cerr << "[test] FAIL: incomplete (week "
+ "rollover phase "
+ << weekRolloverListener.phase
+ << ")" << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ }
+
+ if (testDslPoi) {
+ if (dslPoiListener.failed) {
+ std::cerr << "[test] FAIL: "
+ << dslPoiListener.failReason
+ << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ if (dslPoiListener.phase != 9) {
+ std::cerr << "[test] FAIL: incomplete (dsl "
+ "poi phase "
+ << dslPoiListener.phase << ")"
+ << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ }
+
+ if (testWeekRevision) {
+ if (weekRevisionListener.failed) {
+ std::cerr << "[test] FAIL: "
+ << weekRevisionListener.failReason
+ << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ if (weekRevisionListener.phase != 9) {
+ std::cerr << "[test] FAIL: incomplete (week "
+ "revision phase "
+ << weekRevisionListener.phase << ")"
+ << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ }
+
if (testDetail) {
if (detailListener.failed) {
std::cerr << "[test] FAIL: "
diff --git a/src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua b/src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua
index 7093e72..aece52b 100644
--- a/src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua
+++ b/src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua
@@ -49,6 +49,16 @@ function M.define()
ecs.stats.define{ name = "health", default = 100, min = 0, max = 100 }
ecs.stats.define{ name = "money", default = 0, min = 0 }
ecs.stats.define{ name = "stress", default = 0, min = 0, max = 100 }
+ -- 6.8.14: register the module's emergency thresholds with the
+ -- engine (data-driven; the store listener triggers on health
+ -- CHANGE events, never polling). discharge_at defaults above
+ -- the trigger so an NPC cannot oscillate at the threshold.
+ if ecs.emergency and ecs.emergency.set_thresholds then
+ ecs.emergency.set_thresholds{
+ health_below = M.emergency.hospitalBelowHealth,
+ discharge_at = M.emergency.dischargeHealth,
+ }
+ end
end
-- Apply one completed schedule activity's effects for `hours`
diff --git a/src/features/editScene/demos/demo-taxi/town_layouts.txt b/src/features/editScene/demos/demo-taxi/town_layouts.txt
index 9fb8261..a2a197c 100644
--- a/src/features/editScene/demos/demo-taxi/town_layouts.txt
+++ b/src/features/editScene/demos/demo-taxi/town_layouts.txt
@@ -73,3 +73,74 @@
(windows front))
(roof flat 0 0 w d)
(parking (rect-front-center 13 5.2))))
+
+; ---------------------------------------------------------------------
+; Reference layouts (phase 6.8.13): one per location kind, exercising
+; tags -> POI database -> entry POI -> schedule assignment -> stat
+; effects. Home is idle rest (no activation and no behavior trees
+; required); job pays money + raises stress with opening hours and an
+; employee back door; leisure relieves stress for a money cost with a
+; customer front door.
+; ---------------------------------------------------------------------
+
+; Home: a residence. Idle rest with a weaker stress-recovery effect
+; (no POI activation at home).
+(layout ref-home :tags (house village town) :score 1
+(let ((w 12) (d 10))
+(shell w d :floors 1 :empty)
+(floor 0
+ (room living1 (rect 0 0 0.55 1) :type living
+ :effects ((stress -1)))
+ (room bedroom1 (rect 0.55 0 1 1) :type bedroom)
+ (connect living1 bedroom1)
+ (door front center)
+ (windows all))
+(roof gable 0 0 w d)
+(parking (rect-front-center 5.2 5.2))))
+
+; Job: a shop/office with a work counter. money:+5/h, stress:+1/h,
+; open 9-18, employee back door (6.8.8).
+(layout ref-shop :tags (commercial shop village town) :score 1
+(let ((w 16) (d 12))
+(shell w d :floors 1 :empty)
+(floor 0
+ (room shop1 (rect 0 0 0.7 1) :type shop
+ :effects ((money 5) (stress 1)))
+ (room storage1 (rect 0.7 0 1 1) :windows none)
+ (connect shop1 storage1)
+ (door front center)
+ (door back center)
+ (windows front))
+(roof flat 0 0 w d)
+(parking (rect-front-center 13 5.2))))
+
+; Leisure: a park/cafe. money:-2/h, stress:-4/h, open 9-20,
+; customer front door.
+(layout ref-park :tags (leisure park village town) :score 1
+(let ((w 14) (d 12))
+(shell w d :floors 1 :empty)
+(floor 0
+ (room park1 (rect 0 0 1 1) :type park
+ :effects ((money -2) (stress -4)))
+ (door front center)
+ (windows all))
+(roof gable 0 0 w d)
+(parking (rect-front-center 5.2 5.2))))
+
+; Hospital (6.8.14): the emergency destination. The ward heals
+; health over time (health:+5/h) via its stat effects; doctors work
+; here (6.8.7 rosters); weight 5 so towns reliably carry one for the
+; emergency layer.
+(layout ref-hospital :tags (commercial hospital village town) :score 5
+(let ((w 20) (d 14))
+(shell w d :floors 1 :empty)
+(floor 0
+ (room ward1 (rect 0 0 0.6 1) :type hospital
+ :effects ((health 5)))
+ (room lobby1 (rect 0.6 0 1 1) :type lobby)
+ (connect ward1 lobby1)
+ (door front center)
+ (door back center)
+ (windows all))
+(roof flat 0 0 w d)
+(parking (rect-front-center 13 5.2))))
diff --git a/src/features/editScene/layoutlib/BuildingPlan.hpp b/src/features/editScene/layoutlib/BuildingPlan.hpp
index 3f417ce..c2a8708 100644
--- a/src/features/editScene/layoutlib/BuildingPlan.hpp
+++ b/src/features/editScene/layoutlib/BuildingPlan.hpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
/*
@@ -92,6 +93,48 @@ struct PlanPlacement {
float yawDeg = 0.0f;
};
+/** One DSL-authored POI (phase 6.8.6). Authored per ROOM through
+ * the `(room ... :poi ((name opts...)))` option; positions are
+ * footprint FRACTIONS of the shell (the same space as room rects),
+ * snapped into the owning room's rect at materialization. The
+ * fields mirror PoiComponent one-to-one (kind, tags, stat effects,
+ * opening hours, activation spec) so a DSL POI and a
+ * component-authored POI are the same thing to every consumer. */
+struct PlanPoi {
+ /** The room-table name this decl came from. */
+ std::string room;
+ /** Decl name; unique across the plan (the materialized
+ * PoiComponent::poiId builds on it). */
+ std::string name;
+ std::string kind = "interaction";
+ std::vector tags;
+ /** Stat name -> delta per in-game hour (decl order kept). */
+ std::vector> statEffects;
+ /** "" | "front" | "back" | "side". */
+ std::string entranceRole;
+ /** Opening hours in [0,24); < 0 = always open. */
+ float openHour = -1.0f;
+ float closeHour = -1.0f;
+ /** The floor the POI exists on (defaults to the first room
+ * instance; per-floor pois pick ":level"). */
+ int level = -1;
+ /** Footprint fractions of the shell ([0,1]). Defaults to
+ * the owning room's rect center. */
+ float atX = -1.0f;
+ float atZ = -1.0f;
+ /** Activation, mirroring PoiDatabase::ActivationKind
+ * (0 none, 1 manual, 2 onApproach, 3 onArrive). */
+ int activation = 0;
+ /** ActionDatabase action / behavior-tree name (behaviorTree
+ * activation) or a `LuaScripts` path (the `poi_activated`
+ * event carries it). */
+ std::string behaviorTree;
+ std::string luaScript;
+ float radius = 1.5f;
+ float cooldownSeconds = 0.0f;
+ bool once = false;
+};
+
/* ------------------------------------------------------------------ */
/* Furniture composition rules (phase 6.5.F). Pure data, shared by */
/* the DSL (:compose room option), the serialized RoomComponent and */
@@ -175,9 +218,19 @@ struct BuildingPlan {
* option (phase 6.5.F), parallel to roomNames. Empty = the
* priority-class tag walk furnishes the room. */
std::vector roomCompositions;
+ /** Per-room stat effects from the :effects room option
+ * (phase 6.8.13), parallel to roomNames: (stat -> delta per
+ * in-game hour). Work POIs pay money and raise stress, leisure
+ * POIs relieve stress for a cost, home is idle rest with a
+ * weaker recovery. Empty = no effects. */
+ using RoomStatEffectPair = std::pair;
+ using RoomStatEffectList = std::vector;
+ std::vector roomStatEffects;
std::vector roofs;
std::vector parkings;
std::vector placements;
+ /** DSL-authored POIs (6.8.6), in declaration order. */
+ std::vector pois;
bool valid() const
{
diff --git a/src/features/editScene/layoutlib/LayoutEval.cpp b/src/features/editScene/layoutlib/LayoutEval.cpp
index f57aac4..eaf5384 100644
--- a/src/features/editScene/layoutlib/LayoutEval.cpp
+++ b/src/features/editScene/layoutlib/LayoutEval.cpp
@@ -1348,11 +1348,182 @@ static bool parseTagList(const SExpr &form, std::vector &out,
std::to_string(tag.line) + ")";
return false;
}
- out.push_back(tag.text);
+ out.push_back(tag.text);
}
return true;
}
+/* Parse one :poi room option declaration (phase 6.8.6):
+ * (name :kind k :tags (sym...) :effect (stat delta)
+ * :activation manual|approach|arrive :behavior action
+ * :lua path :at (x z) :level n :hours (open close)
+ * :entrance role :radius n :cooldown s :once)
+ * Activations mirror PoiDatabase::ActivationKind (none=0, manual=1,
+ * approach=2, arrive=3). A POI must have exactly one handler:
+ * :behavior and :lua are mutually exclusive, a decl that activates
+ * nothing is a hard error (so authored POIs always do something
+ * when triggered). */
+static bool parsePoiDecl(const SExpr &decl, PlanPoi &out,
+ std::string *error)
+{
+ auto fail = [&](const SExpr &at, const std::string &msg) -> bool {
+ if (error)
+ *error = msg + " (line " + std::to_string(at.line) +
+ ")";
+ return false;
+ };
+ auto argNum = [&](const SExpr &e, double &out) -> bool {
+ if (!e.isNumber())
+ return false;
+ out = e.number;
+ return true;
+ };
+ if (!decl.isList() || decl.list.size() < 2 ||
+ !decl.list[0].isSymbol())
+ return fail(decl,
+ "each :poi declaration must be "
+ "(name :option value ...)");
+ out.name = decl.list[0].text;
+ for (size_t i = 1; i < decl.list.size(); ++i) {
+ if (!decl.list[i].isSymbol())
+ return fail(decl.list[i], "POI option must be a "
+ ":keyword");
+ const std::string &key = decl.list[i].text;
+ if (key == ":once") {
+ out.once = true;
+ continue;
+ }
+ if (i + 1 >= decl.list.size())
+ return fail(decl.list[i], "POI option '" + key +
+ "' needs a value");
+ const SExpr &val = decl.list[i + 1];
+ ++i; /* the value: consumed with the keyword below */
+ if (key == ":kind") {
+ if (!val.isSymbol())
+ return fail(val, ":kind needs a symbol");
+ out.kind = val.text;
+ } else if (key == ":tags") {
+ if (!val.isList())
+ return fail(val, ":tags needs a symbol "
+ "list");
+ for (const SExpr &t : val.list)
+ if (!t.isSymbol())
+ return fail(t, ":tags entries must "
+ "be symbols");
+ for (const SExpr &t : val.list)
+ out.tags.push_back(t.text);
+ } else if (key == ":effect") {
+ if (!val.isList() || val.list.size() != 2 ||
+ !val.list[0].isSymbol())
+ return fail(val, ":effect needs a "
+ "(stat delta) pair");
+ double delta = 0.0;
+ if (!argNum(val.list[1], delta))
+ return fail(val.list[1], ":effect delta "
+ "must be a number");
+ out.statEffects.push_back(
+ { val.list[0].text, delta });
+ } else if (key == ":activation") {
+ if (!val.isSymbol())
+ return fail(val, ":activation needs a "
+ "mode");
+ if (val.text == "none")
+ out.activation = 0;
+ else if (val.text == "manual")
+ out.activation = 1;
+ else if (val.text == "approach")
+ out.activation = 2;
+ else if (val.text == "arrive")
+ out.activation = 3;
+ else
+ return fail(val, ":activation is none, "
+ "manual, approach or "
+ "arrive");
+ } else if (key == ":behavior") {
+ if (!val.isSymbol())
+ return fail(val, ":behavior needs an "
+ "action name");
+ out.behaviorTree = val.text;
+ } else if (key == ":lua") {
+ if (!val.isSymbol())
+ return fail(val, ":lua needs a script "
+ "path");
+ out.luaScript = val.text;
+ } else if (key == ":at") {
+ if (!val.isList() || val.list.size() != 2)
+ return fail(val, ":at needs two shell "
+ "fractions (x z)");
+ double atX = 0.0, atZ = 0.0;
+ if (!argNum(val.list[0], atX) ||
+ !argNum(val.list[1], atZ))
+ return fail(val, ":at fractions must be "
+ "numbers");
+ if (atX < 0.0 || atX > 1.0 || atZ < 0.0 ||
+ atZ > 1.0)
+ return fail(val, ":at fractions (when "
+ "given) must stay inside "
+ "[0,1]");
+ out.atX = (float)atX;
+ out.atZ = (float)atZ;
+ } else if (key == ":level") {
+ double lvl = 0.0;
+ if (!argNum(val, lvl) || lvl < 0.0 ||
+ lvl != std::floor(lvl))
+ return fail(val, ":level needs a "
+ "non-negative integer");
+ out.level = (int)lvl;
+ } else if (key == ":hours") {
+ if (!val.isList() || val.list.size() != 2)
+ return fail(val, ":hours needs open and "
+ "close hours");
+ double open = 0.0, closeH = 0.0;
+ if (!argNum(val.list[0], open) ||
+ !argNum(val.list[1], closeH))
+ return fail(val, ":hours values must be "
+ "numbers");
+ out.openHour = (float)open;
+ out.closeHour = (float)closeH;
+ } else if (key == ":entrance") {
+ if (!val.isSymbol())
+ return fail(val, ":entrance needs a role");
+ if (val.text != "front" && val.text != "back" &&
+ val.text != "side")
+ return fail(val, ":entrance is front, back "
+ "or side");
+ out.entranceRole = val.text;
+ } else if (key == ":radius") {
+ double r = 0.0;
+ if (!argNum(val, r) || r <= 0.0)
+ return fail(val, ":radius needs a positive "
+ "number");
+ out.radius = (float)r;
+ } else if (key == ":cooldown") {
+ double c = 0.0;
+ if (!argNum(val, c) || c < 0.0)
+ return fail(val, ":cooldown needs a "
+ "non-negative number");
+ out.cooldownSeconds = (float)c;
+ } else if (key == ":once") {
+ out.once = true;
+ } else {
+ return fail(decl.list[i], "unknown POI option '" +
+ key + "'");
+ }
+ }
+ if (!out.behaviorTree.empty() && !out.luaScript.empty())
+ return fail(decl, "POI '" + out.name + "' declares both "
+ ":behavior and :lua");
+ if (out.activation != 0 && out.behaviorTree.empty() &&
+ out.luaScript.empty())
+ return fail(decl, "POI '" + out.name +
+ "' declares an activation but neither "
+ ":behavior nor :lua");
+ if (!out.luaScript.empty() && out.activation == 0)
+ return fail(decl, "POI '" + out.name + "' declares :lua "
+ "but no :activation");
+ return true;
+}
+
/* Parse one :compose rule:
* (group taglist [:at center|zneg|zpos|xneg|xpos] [:rot n] child*)
* (row taglist [:at edge] [:step n] [:skip n] [:count n])
@@ -1528,13 +1699,21 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
if (form.list.size() < 3 || !form.list[1].isSymbol())
return fail(form, "room needs (room name (rect x0 z0 x1 "
"z1))");
- if ((form.list.size() - 3) % 2 != 0)
+ if ((form.list.size() - 3) % 2 != 0) {
+ std::string dump;
+ for (const auto &el : form.list) {
+ dump += el.isSymbol() ? el.text : "(...)";
+ dump += " ";
+ }
return fail(form, "room options come in :keyword value "
- "pairs");
+ "pairs [" + dump + "]");
+ }
bool noWindows = false;
std::string roomType;
std::vector furnitureTags;
RoomCompositionRules compositions;
+ using StatEffectPair = std::pair;
+ std::vector statEffects_;
for (size_t i = 3; i < form.list.size(); i += 2) {
if (!form.list[i].isSymbol())
return fail(form.list[i], "room option must be a "
@@ -1590,11 +1769,69 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
return fail(ruleForm, ruleError);
compositions.push_back(std::move(rule));
}
+ } else if (key == ":poi") {
+ /* Phase 6.8.6: DSL-authored interior POIs.
+ * Value = a list of decls, each
+ * (name :kind kind :tags (sym...)
+ * :effect (stat delta)
+ * :activation manual|approach|arrive
+ * :behavior actionName :lua scriptPath
+ * :at (x z) :level n :hours (open close)
+ * :entrance role :radius n :cooldown s
+ * :once). Positions are footprint fractions
+ * of the SHELL (the room-rect space) and snap
+ * into the owning room's rect; name uniqueness
+ * is per plan (the materialized poiId builds on
+ * it). */
+ const SExpr &decls = form.list[i + 1];
+ if (!decls.isList() || decls.list.empty())
+ return fail(decls,
+ ":poi needs a POI declaration "
+ "list, e.g. :poi ((counter :kind "
+ "work :behavior work_counter))");
+ if (!m_roomPois.insert(form.list[1].text).second)
+ continue; /* redeclared room: first wins */
+ for (const SExpr &decl : decls.list) {
+ PlanPoi p;
+ p.room = form.list[1].text;
+ std::string err;
+ if (!parsePoiDecl(decl, p, &err))
+ return fail(decl, err);
+ m_plan->pois.push_back(std::move(p));
+ }
+ } else if (key == ":effects") {
+ /* Phase 6.8.13: the location-level stat effects
+ * (home/work/leisure entries apply them on
+ * completion, per in-game hour). Unlike the
+ * :poi activation spec, this is data on the
+ * room's LOCATION — work pays money and raises
+ * stress, leisure relieves stress for a cost,
+ * home is idle rest with a weaker recovery. */
+ const SExpr &effs = form.list[i + 1];
+ if (!effs.isList() || effs.list.empty())
+ return fail(effs,
+ ":effects needs a (stat delta) "
+ "pair list");
+ for (const SExpr &pair : effs.list) {
+ if (!pair.isList() || pair.list.size() != 2 ||
+ !pair.list[0].isSymbol())
+ return fail(pair,
+ ":effects entries must be "
+ "(stat delta) pairs");
+ if (!pair.list[1].isNumber())
+ return fail(pair.list[1],
+ ":effects delta must be a "
+ "number");
+ statEffects_.push_back(
+ { pair.list[0].text,
+ pair.list[1].number });
+ }
} else {
return fail(form.list[i], "unknown room option '" +
key +
"' (only :windows, :type, "
- ":furniture, :compose)");
+ ":furniture, :compose, :poi, "
+ ":effects)");
}
}
const SExpr &rect = form.list[2];
@@ -1611,7 +1848,7 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
* room tables; re-declaring a name (e.g. on another floor)
* reuses its id and its first type wins. */
auto registerRoom = [this, &form, &roomType, &furnitureTags,
- &compositions]() -> int {
+ &compositions, &statEffects_]() -> int {
auto nit = m_roomNameIds.find(form.list[1].text);
if (nit != m_roomNameIds.end())
return nit->second;
@@ -1620,6 +1857,7 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
m_plan->roomTypes.push_back(roomType);
m_plan->roomFurnitureTags.push_back(furnitureTags);
m_plan->roomCompositions.push_back(std::move(compositions));
+ m_plan->roomStatEffects.push_back(statEffects_);
m_roomNameIds.emplace(form.list[1].text, id);
return id;
};
@@ -2303,6 +2541,7 @@ bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
m_warnings = warnings;
m_rooms.clear();
m_roomNameIds.clear();
+ m_roomPois.clear();
/* Predefined bindings. */
std::map base;
diff --git a/src/features/editScene/layoutlib/LayoutEval.hpp b/src/features/editScene/layoutlib/LayoutEval.hpp
index 8fe23e1..1ca8b31 100644
--- a/src/features/editScene/layoutlib/LayoutEval.hpp
+++ b/src/features/editScene/layoutlib/LayoutEval.hpp
@@ -3,6 +3,7 @@
#include
#include