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 #include +#include #include #include @@ -338,6 +339,9 @@ private: /* roomIds index for a room name of the current evaluation: * name -> index into BuildingPlan::roomNames. */ std::map m_roomNameIds; + /** Rooms that declared :poi entries (first declaration wins, + * matching the room-table dedupe discipline). */ + std::set m_roomPois; }; } /* namespace LayoutLib */ diff --git a/src/features/editScene/lua/LuaEmergencyApi.cpp b/src/features/editScene/lua/LuaEmergencyApi.cpp new file mode 100644 index 0000000..4d325e2 --- /dev/null +++ b/src/features/editScene/lua/LuaEmergencyApi.cpp @@ -0,0 +1,121 @@ +#include "LuaEmergencyApi.hpp" + +#include "../systems/CharacterStatStore.hpp" +#include "../systems/NpcPopulationSystem.hpp" + +#include + +namespace editScene { + +namespace { + +/** The registered thresholds (data, from the stats rules module). + * healthBelow < 0 = the emergency layer is disabled. */ +struct Thresholds { + double healthBelow = -1.0; + double dischargeAt = 100.0; + bool registered = false; +}; + +Thresholds &thresholds() +{ + static Thresholds t; + return t; +} + +/** The store listener: fires on stat-CHANGE events only (never + * per-frame polling). The health emergency triggers when health + * crosses below health_below and discharges at discharge_at. */ +void onStatChanged(uint64_t id, const std::string &name, double newValue) +{ + Thresholds &t = thresholds(); + if (!t.registered || name != "health") + return; + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + if (!pop) + return; + const bool active = pop->hasActiveEmergency(id); + if (!active && newValue < t.healthBelow) + pop->beginEmergency(id, "health-emergency"); + else if (active && newValue >= t.dischargeAt) + pop->endEmergency(id); +} + +/** ecs.emergency.set_thresholds{ health_below = n, discharge_at = n + * } — registers the data-driven thresholds and the store listener + * (once). */ +int luaSetThresholds(lua_State *L) +{ + Thresholds &t = thresholds(); + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "health_below"); + if (lua_isnumber(L, -1)) + t.healthBelow = lua_tonumber(L, -1); + lua_pop(L, 1); + lua_getfield(L, 1, "discharge_at"); + if (lua_isnumber(L, -1)) + t.dischargeAt = lua_tonumber(L, -1); + lua_pop(L, 1); + if (!t.registered && t.healthBelow >= 0.0) { + t.registered = true; + CharacterStatStore::getInstance().subscribe( + [](uint64_t id, const std::string &name, + double newValue) { + onStatChanged(id, name, newValue); + }); + } + return 0; +} + +int luaBegin(lua_State *L) +{ + uint64_t id = (uint64_t)luaL_checkinteger(L, 1); + const char *reason = luaL_optstring(L, 2, "emergency"); + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + lua_pushboolean(L, + pop && pop->beginEmergency(id, reason) ? 1 : 0); + return 1; +} + +int luaEnd(lua_State *L) +{ + uint64_t id = (uint64_t)luaL_checkinteger(L, 1); + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + lua_pushboolean(L, pop && pop->endEmergency(id) ? 1 : 0); + return 1; +} + +int luaActive(lua_State *L) +{ + uint64_t id = (uint64_t)luaL_checkinteger(L, 1); + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + lua_pushboolean(L, + pop && pop->hasActiveEmergency(id) ? 1 : 0); + return 1; +} + +} // namespace + +void registerLuaEmergencyApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return; + } + + lua_newtable(L); + lua_pushcfunction(L, luaSetThresholds); + lua_setfield(L, -2, "set_thresholds"); + lua_pushcfunction(L, luaBegin); + lua_setfield(L, -2, "begin"); + lua_pushcfunction(L, luaEnd); + lua_setfield(L, -2, "end"); + lua_pushcfunction(L, luaActive); + lua_setfield(L, -2, "active"); + lua_setfield(L, -2, "emergency"); + + lua_pop(L, 1); +} + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaEmergencyApi.hpp b/src/features/editScene/lua/LuaEmergencyApi.hpp new file mode 100644 index 0000000..867b67e --- /dev/null +++ b/src/features/editScene/lua/LuaEmergencyApi.hpp @@ -0,0 +1,19 @@ +#pragma once + +extern "C" { +#include +#include +} + +namespace editScene { + +/** Lua-facing emergency layer (phase 6.8.14). The data-driven + * thresholds are registered by the stats rules module + * (`ecs.emergency.set_thresholds{ health_below = n, discharge_at = + * n }`); the engine subscribes to the stat-change events and + * triggers/clears emergencies itself — never per-frame polling. + * begin/end are also callable directly (scripted emergencies). */ + +void registerLuaEmergencyApi(lua_State *L); + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaRostersApi.cpp b/src/features/editScene/lua/LuaRostersApi.cpp new file mode 100644 index 0000000..6141efb --- /dev/null +++ b/src/features/editScene/lua/LuaRostersApi.cpp @@ -0,0 +1,102 @@ +#include "LuaRostersApi.hpp" + +extern "C" { +#include +} + +namespace editScene { + +namespace { + +RostersProvider g_provider = nullptr; +void *g_user = nullptr; + +/** ecs.rosters.area(areaIndex) -> table or nil + * + * { "" = { { id = , key = , + * home = "", work = "" }, ... }, ... } + * nil when the area has no weekly build. */ +int luaArea(lua_State *L) +{ + int areaIndex = (int)luaL_checkinteger(L, 1); + if (!g_provider) { + lua_pushnil(L); + return 1; + } + TownRosters::Rosters rosters; + if (!g_provider(areaIndex, g_user, rosters)) { + lua_pushnil(L); + return 1; + } + lua_newtable(L); + for (const auto &kv : rosters) { + lua_newtable(L); + for (size_t i = 0; i < kv.second.size(); ++i) { + const TownRosters::RosterMember &m = kv.second[i]; + lua_newtable(L); + lua_pushinteger(L, (lua_Integer)m.registryId); + lua_setfield(L, -2, "id"); + lua_pushstring(L, m.stableKey.c_str()); + lua_setfield(L, -2, "key"); + lua_pushstring(L, m.homeLocation.c_str()); + lua_setfield(L, -2, "home"); + lua_pushstring(L, m.workLocation.c_str()); + lua_setfield(L, -2, "work"); + lua_rawseti(L, -2, (lua_Integer)(i + 1)); + } + lua_setfield(L, -2, kv.first.c_str()); + } + return 1; +} + +/** ecs.rosters.tags() -> the special job tag list. */ +int luaTags(lua_State *L) +{ + lua_newtable(L); + const std::vector &tags = + TownRosters::specialJobTags(); + for (size_t i = 0; i < tags.size(); ++i) { + lua_pushstring(L, tags[i].c_str()); + lua_rawseti(L, -2, (lua_Integer)(i + 1)); + } + return 1; +} + +/** ecs.rosters.is_special(tag) -> bool */ +int luaIsSpecial(lua_State *L) +{ + const char *tag = luaL_checkstring(L, 1); + lua_pushboolean(L, + TownRosters::isSpecialJobTag(tag) ? 1 : 0); + return 1; +} + +} // namespace + +void setLuaRostersProvider(RostersProvider provider, void *user) +{ + g_provider = provider; + g_user = user; +} + +void registerLuaRostersApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return; + } + + lua_newtable(L); + lua_pushcfunction(L, luaArea); + lua_setfield(L, -2, "area"); + lua_pushcfunction(L, luaTags); + lua_setfield(L, -2, "tags"); + lua_pushcfunction(L, luaIsSpecial); + lua_setfield(L, -2, "is_special"); + lua_setfield(L, -2, "rosters"); + + lua_pop(L, 1); +} + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaRostersApi.hpp b/src/features/editScene/lua/LuaRostersApi.hpp new file mode 100644 index 0000000..86e01e8 --- /dev/null +++ b/src/features/editScene/lua/LuaRostersApi.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "../roadlib/TownRosters.hpp" + +extern "C" { +#include +} + +namespace editScene { + +/** Lua-facing roster provider: returns the area's rosters + * (TownRosters type). The runtime (EditorApp.setup) binds it to + * NpcPopulationSystem; the headless Lua smoke test binds a fake + * instead. Returns false when the area has no weekly build. */ +using RostersProvider = bool (*)(int areaIndex, void *user, + TownRosters::Rosters &out); + +/** Bind the provider (nullptr clears). `user` flows back to the + * callback (the runtime passes the NpcPopulationSystem). */ +void setLuaRostersProvider(RostersProvider provider, void *user); + +void registerLuaRostersApi(lua_State *L); + +} // namespace editScene diff --git a/src/features/editScene/roadlib/PoiDatabase.cpp b/src/features/editScene/roadlib/PoiDatabase.cpp index 966c08f..70a142b 100644 --- a/src/features/editScene/roadlib/PoiDatabase.cpp +++ b/src/features/editScene/roadlib/PoiDatabase.cpp @@ -146,6 +146,31 @@ int appendEntries(Set &set, const TownLocations::LocationSet &locations) if (set.add(std::move(rec))) ++appended; } + + /* 6.8.8: a building that declares a back/side door gets the + * matching employee entry as a SECOND POI, anchored at the + * back door (its Work location already anchors there). The + * customer (front) entry is unchanged. */ + for (const TownLocations::NpcLocation &loc : locations.locations) { + if (loc.kind != TownLocations::LocationKind::Work || + loc.entranceRole != "back" || loc.address.empty()) + continue; + const std::string id = entryId(loc.address) + ":back"; + if (set.findById(id) != nullptr) + continue; + Record rec; + rec.id = id; + rec.domain = Domain::Exterior; + rec.kind = "entrance"; + rec.name = id; + rec.address = loc.address; + rec.building = loc.building; + rec.position = loc.position; + rec.tags = { "entrance" }; + rec.entranceRole = "back"; + if (set.add(std::move(rec))) + ++appended; + } return appended; } @@ -165,11 +190,23 @@ const Record *entryOfBuilding(const Set &set, int building, return byAddress; } +const Record *entryOfBuildingForRole(const Set &set, + const std::string &role, int building, + const std::string &address) +{ + if (role.empty() || role == "front") + return entryOfBuilding(set, building, address); + const std::string want = entryId(address) + ":" + role; + const Record *rec = set.findById(want); + return rec ? rec : entryOfBuilding(set, building, address); +} + Ogre::Vector3 approachPosition(const Set &set, const Record &rec) { if (rec.domain != Domain::Interior) return rec.position; - const Record *entry = entryOfBuilding(set, rec.building, rec.address); + const Record *entry = entryOfBuildingForRole( + set, rec.entranceRole, rec.building, rec.address); return entry ? entry->position : rec.position; } @@ -210,7 +247,9 @@ TownLocations::LocationSet projectLocations(const Set &set) loc.indoor = rec.domain == Domain::Interior; if (loc.indoor) { const Record *entry = - entryOfBuilding(set, rec.building, rec.address); + entryOfBuildingForRole( + set, rec.entranceRole, rec.building, + rec.address); if (entry) { loc.entryPosition = entry->position; loc.hasEntry = true; @@ -221,6 +260,8 @@ TownLocations::LocationSet projectLocations(const Set &set) * LocationSet). */ loc.tags = rec.tags; loc.entranceRole = rec.entranceRole; + /* 6.8.10: stat effects ride the projection too. */ + loc.statEffects = rec.statEffects; out.locations.push_back(loc); } return out; @@ -268,6 +309,9 @@ Set feedLocationSet(const TownLocations::LocationSet &locations) std::vector{ rec.kind } : loc.tags; rec.entranceRole = loc.entranceRole; + /* 6.8.10: the location's stat effects travel with the + * record (generated baseline keeps them empty). */ + rec.statEffects = loc.statEffects; /* Stat effects and activation are left empty for the * generated baseline (richer authoring paths fill * them). */ diff --git a/src/features/editScene/roadlib/PoiDatabase.hpp b/src/features/editScene/roadlib/PoiDatabase.hpp index 8ad1efd..f5f51db 100644 --- a/src/features/editScene/roadlib/PoiDatabase.hpp +++ b/src/features/editScene/roadlib/PoiDatabase.hpp @@ -152,6 +152,11 @@ int appendEntries(Set &set, const TownLocations::LocationSet &locations); * then by address; null when the building has no entry. */ const Record *entryOfBuilding(const Set &set, int building, const std::string &address); +/** 6.8.8: role-aware entry resolution — prefer the declared back/ + * side entry (employee door) when it exists, else the front entry. */ +const Record *entryOfBuildingForRole(const Set &set, + const std::string &role, int building, + const std::string &address); /** The approach position of a POI: for an Interior-domain POI the * position of its building's entry POI (falling back to the POI's diff --git a/src/features/editScene/roadlib/TownGen.cpp b/src/features/editScene/roadlib/TownGen.cpp index 3059e12..ad84733 100644 --- a/src/features/editScene/roadlib/TownGen.cpp +++ b/src/features/editScene/roadlib/TownGen.cpp @@ -884,6 +884,83 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, b.doorstep.y = sampleY(sampleHeight, b.doorstep.x, b.doorstep.z, lot.centroid.y); + /* 6.8.8: optional back/side door roles. The + * front door is always computed above; when the + * layout declares a back/side door I record the + * matching approach point with the same 2 m + * outside clearance — employee (Work) locations + * anchor at the back door, customer (Leisure) + * locations stay at the front. */ + { + const Ogre::Vector3 axisX(-v.z, 0.0f, v.x); + for (int side = 1; side <= 3; ++side) { + bool horizontal = side >= 2; + int cells = horizontal ? + b.plan.cellsZ : + b.plan.cellsX; + uint64_t flag = side == 1 ? + LayoutLib::PlanFlags:: + DoorZNeg : + (side == 2 ? + LayoutLib::PlanFlags:: + DoorXNeg : + LayoutLib::PlanFlags:: + DoorXPos); + int idx = -1; + for (int c = 0; c < cells; ++c) { + int x = horizontal ? + (side == 2 ? + 0 : + b.plan.cellsX - 1) : + c; + int z = horizontal ? c : 0; + if (b.plan.at(x, 0, z) & flag) { + idx = c; + break; + } + } + if (idx < 0) + continue; + if (side == 1) { + float backOffX = + (idx + 0.5f) * + b.plan.cellSize - + width * 0.5f; + b.backDoorstep = + center2d + + v * (depth * 0.5f + + 2.0f) + + axisX * backOffX; + b.backDoorstep.y = sampleY( + sampleHeight, + b.backDoorstep.x, + b.backDoorstep.z, + lot.centroid.y); + b.hasBackDoor = true; + } else { + float doorOffZ = + (idx + 0.5f) * + b.plan.cellSize - + depth * 0.5f; + b.sideDoorstep = + center2d + + axisX * + ((side == 2 ? + -1.0f : + 1.0f) * + (width * 0.5f + + 2.0f)) + + v * doorOffZ; + b.sideDoorstep.y = sampleY( + sampleHeight, + b.sideDoorstep.x, + b.sideDoorstep.z, + lot.centroid.y); + b.hasSideDoor = true; + } + } + } + /* Address from the frontage street; split siblings * share the number with a letter suffix in * frontage order ("Main Street 12A", "12B"). */ diff --git a/src/features/editScene/roadlib/TownGen.hpp b/src/features/editScene/roadlib/TownGen.hpp index f6b2fe3..1801dec 100644 --- a/src/features/editScene/roadlib/TownGen.hpp +++ b/src/features/editScene/roadlib/TownGen.hpp @@ -137,6 +137,19 @@ struct TownBuilding { * unless the caller fills it). */ Ogre::Vector3 doorstep = Ogre::Vector3::ZERO; + /** 6.8.8: optional back/side door roles (declared by the + * layout's `(door back|left|right ...)`). The front door stays + * `doorstep`. When a back/side door is declared the building + * records the matching approach point (2 m outside that door + * at ground level); employee (Work) locations anchor at the + * back door while customer (Leisure) locations keep the front, + * and entry POIs carry the role. hasBackDoor/hasSideDoor are + * false for the stock single-door layouts, which therefore + * stay bit-identical. */ + Ogre::Vector3 backDoorstep = Ogre::Vector3::ZERO; + Ogre::Vector3 sideDoorstep = Ogre::Vector3::ZERO; + bool hasBackDoor = false; + bool hasSideDoor = false; /** " " or "Lot ". */ std::string address; /** Frontage edge (index into RoadGraph::edges) and side diff --git a/src/features/editScene/roadlib/TownLocations.cpp b/src/features/editScene/roadlib/TownLocations.cpp index 9523aa4..cfea8c8 100644 --- a/src/features/editScene/roadlib/TownLocations.cpp +++ b/src/features/editScene/roadlib/TownLocations.cpp @@ -53,9 +53,21 @@ bool isIndoorKind(LocationKind kind) * (6.8.2b) will extend or replace them. Grounded in the shipped * layout libraries: corner-shop declares (commercial shop ...), * the built-in district defaults tag houses (house/village/town) - * and city towers (tower/center/commercial). */ + * and city towers (tower/center/commercial). The 6.8.7 + * special-job tags join the commercial list — a police station + * is a workplace, not an Interest point. */ static const char *const COMMERCIAL_TAGS[] = { "shop", "commercial", "office", "leisure", + "police", "ambulance", "firefighter", "hospital", +}; + +/* The 6.8.7 special-job mapping: a building carrying the tag maps + * onto its service job (checked before the shop/office roles). */ +static const char *const SPECIAL_JOB_TAGS[] = { + "police", "ambulance", "firefighter", "hospital", +}; +static const char *const SPECIAL_JOB_ROLE[] = { + "police", "medic", "firefighter", "doctor", }; bool isCommercialBuilding(const std::vector &buildingTags) @@ -67,19 +79,6 @@ bool isCommercialBuilding(const std::vector &buildingTags) return false; } -const char *jobTagForBuilding(const std::vector &buildingTags) -{ - for (const std::string &tag : buildingTags) { - if (tag == "shop") - return "salesman"; - if (tag == "office") - return "clerk"; - } - /* Any other commercial tag (e.g. "commercial" on a city - * tower). */ - return "worker"; -} - int LocationSet::homeOfBuilding(int building) const { for (const NpcLocation &loc : locations) @@ -186,6 +185,70 @@ NpcBindings computeBindings(const LocationSet &locs, return b; } +const char *jobTagForBuilding(const std::vector &buildingTags) +{ + for (const std::string &tag : buildingTags) { + for (size_t s = 0; + s < sizeof(SPECIAL_JOB_TAGS) / + sizeof(SPECIAL_JOB_TAGS[0]); + ++s) + if (tag == SPECIAL_JOB_TAGS[s]) + return SPECIAL_JOB_ROLE[s]; + } + for (const std::string &tag : buildingTags) { + if (tag == "shop") + return "salesman"; + if (tag == "office") + return "clerk"; + } + /* Any other commercial tag (e.g. "commercial" on a city + * tower). */ + return "worker"; +} + +/* 6.8.13: which room :type a location kind takes its stat effects + * from. Work POIs are commercial (shop/store/office/commercial/ + * work/shopfront); leisure POIs are leisure venues (park/cafe/bar/ + * leisure); home is a residence (bedroom/living/livingroom/home/ + * residence). */ +static bool roomTypeIn(const std::string &type, + const char *const *roles, size_t count) +{ + for (size_t i = 0; i < count; ++i) + if (type == roles[i]) + return true; + return false; +} + +static void mergeRoomEffects( + const LayoutLib::BuildingPlan &plan, + const char *const *roles, size_t count, + std::unordered_map &out) +{ + static const char *const emptyRoles[] = { nullptr }; + (void)emptyRoles; + for (size_t i = 0; i < plan.roomStatEffects.size() && + i < plan.roomTypes.size(); + ++i) { + const std::string &type = plan.roomTypes[i]; + if (!roomTypeIn(type, roles, count)) + continue; + for (const auto &se : plan.roomStatEffects[i]) + out[se.first] += se.second; + } +} + +static const char *const WORK_ROOM_TYPES[] = { + "shop", "store", "office", "commercial", "work", "shopfront", + "storage", "hospital", "clinic", "ward", +}; +static const char *const LEISURE_ROOM_TYPES[] = { + "park", "cafe", "bar", "leisure", +}; +static const char *const HOME_ROOM_TYPES[] = { + "bedroom", "living", "livingroom", "home", "residence", +}; + LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed, float interestRatio) { @@ -222,6 +285,14 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed, home.position = b.doorstep != Ogre::Vector3::ZERO ? b.doorstep : b.center; + /* 6.8.13: home entries are idle rest, so the home + * location may carry a weaker stress-recovery + * effect from the residence rooms (bedroom/living/ + * home). */ + mergeRoomEffects(b.plan, HOME_ROOM_TYPES, + sizeof(HOME_ROOM_TYPES) / + sizeof(HOME_ROOM_TYPES[0]), + home.statEffects); home.building = (int)bi; add(home); @@ -247,12 +318,24 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed, work.kind = LocationKind::Work; work.name = std::string("work:") + b.address; work.address = b.address; - work.position = anchor; + /* 6.8.8: employees approach the back door when + * the layout declares one; single-door buildings + * keep the front (unchanged). */ + work.position = b.hasBackDoor ? + b.backDoorstep : + anchor; work.building = (int)bi; work.tags = b.tags; work.tags.push_back( jobTagForBuilding(b.tags)); - work.entranceRole = "front"; + work.entranceRole = b.hasBackDoor ? "back" : "front"; + /* 6.8.13: work POIs carry their stat + * effects (money/stress per in-game hour) + * from the commercial rooms. */ + mergeRoomEffects(b.plan, WORK_ROOM_TYPES, + sizeof(WORK_ROOM_TYPES) / + sizeof(WORK_ROOM_TYPES[0]), + work.statEffects); add(work); NpcLocation leisure; @@ -264,6 +347,13 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed, leisure.building = (int)bi; leisure.tags = b.tags; leisure.entranceRole = "front"; + /* 6.8.13: leisure POIs carry their + * stress-relief effects (often for a money + * cost) from the leisure rooms. */ + mergeRoomEffects(b.plan, LEISURE_ROOM_TYPES, + sizeof(LEISURE_ROOM_TYPES) / + sizeof(LEISURE_ROOM_TYPES[0]), + leisure.statEffects); add(leisure); continue; } diff --git a/src/features/editScene/roadlib/TownLocations.hpp b/src/features/editScene/roadlib/TownLocations.hpp index 13a4376..33d4c51 100644 --- a/src/features/editScene/roadlib/TownLocations.hpp +++ b/src/features/editScene/roadlib/TownLocations.hpp @@ -117,6 +117,14 @@ struct NpcLocation { * locations (generated buildings are single-door). */ std::string entranceRole; + + /** Stat effects offered per in-game hour at this location + * (6.8.10). The unified POI record's statEffects travel + * through the LocationSet (feed + projection), so a Work or + * Leisure POI that earns money or relieves stress is data on + * the location the schedule layer chooses. Empty = neutral + * (no effects). */ + std::unordered_map statEffects; }; /** The approach endpoint of a location: its entry POI position when diff --git a/src/features/editScene/roadlib/TownRosters.cpp b/src/features/editScene/roadlib/TownRosters.cpp new file mode 100644 index 0000000..428d94b --- /dev/null +++ b/src/features/editScene/roadlib/TownRosters.cpp @@ -0,0 +1,63 @@ +#include "TownRosters.hpp" + +#include + +namespace TownRosters { + +const std::vector &specialJobTags() +{ + static const std::vector tags = { + "police", "medic", "firefighter", "doctor" + }; + return tags; +} + +bool isSpecialJobTag(const std::string &tag) +{ + for (const std::string &t : specialJobTags()) + if (tag == t) + return true; + return false; +} + +Rosters deriveRosters( + const std::vector &members, + const std::vector &tags) +{ + Rosters out; + + /* Registry-id order (decision 4 discipline): the source + * vector's build order is irrelevant. */ + std::vector ordered; + ordered.reserve(members.size()); + for (const MemberJob &m : members) + ordered.push_back(&m); + std::sort(ordered.begin(), ordered.end(), + [](const MemberJob *a, const MemberJob *b) { + return a->registryId < b->registryId; + }); + + for (const MemberJob *m : ordered) { + if (m->tagQuery.rfind("job:", 0) != 0) + continue; /* no resolved job tag */ + const std::string tag = m->tagQuery.substr(4); + bool wanted = false; + for (const std::string &t : tags) + if (tag == t) { + wanted = true; + break; + } + if (!wanted) + continue; + + RosterMember entry; + entry.registryId = m->registryId; + entry.stableKey = m->stableKey; + entry.homeLocation = m->homeLocation; + entry.workLocation = m->workLocation; + out[tag].push_back(std::move(entry)); + } + return out; +} + +} /* namespace TownRosters */ diff --git a/src/features/editScene/roadlib/TownRosters.hpp b/src/features/editScene/roadlib/TownRosters.hpp new file mode 100644 index 0000000..19197e6 --- /dev/null +++ b/src/features/editScene/roadlib/TownRosters.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +/** + * Special-job rosters (demo-taxi phase 6.8.7). + * + * A population's jobs are already resolved by the weekly schedule + * generator (decision 5, weekly-sticky): every working adult's + * WeekEntry carries the tag query it answered ("job:") and + * the resolved work location; their home is where the sleep + * bookends sit. A roster is the slice of those members whose job + * tag belongs to the special set (police, ambulance, ...), + * registry-id ordered, exposed for later game systems (patrol + * dispatch, emergency pickup, ...). No AI behavior lives here — + * this is DATA. + * + * Pure std; the derivation input is the FLATTENED job list + * (MemberJob) so tests and the Lua binding can link this without + * the OGRE-bound town headers — the weekly-build adapter lives in + * TownRostersWeeks.hpp (linked into the OGRE taring). + */ +namespace TownRosters { + +/** One rostered member: who (registry id), where they live and + * where their special job is (LocationSet names). */ +struct RosterMember { + uint64_t registryId = 0; + std::string stableKey; + std::string homeLocation; + std::string workLocation; +}; + +/** Special tag -> members, both in registry-id order. */ +using Rosters = std::map>; + +/** One member's resolved job summary (the derivation input). */ +struct MemberJob { + uint64_t registryId = 0; + std::string stableKey; + std::string homeLocation; + std::string workLocation; + /** The job tag query the weekly build resolved ("job:", + * "" when the member carries no job at all — pulled home by + * the 6.8.5 closing rule — or works the tagless binding). */ + std::string tagQuery; +}; + +/** The special job tags (the fixed vocabulary; jobTagForBuilding + * maps building tags onto it). */ +const std::vector &specialJobTags(); + +/** True when the job tag belongs to the special set. */ +bool isSpecialJobTag(const std::string &tag); + +/** Derive the rosters from the members' job summaries. The member + * order follows ascending registry id regardless of the input + * order; a member with several "job:" entries (impossible today — + * the job is sticky) contributes its first. Tags outside the + * special set are ignored, as is the tagless Work binding. */ +Rosters deriveRosters( + const std::vector &members, + const std::vector &tags = specialJobTags()); + +} /* namespace TownRosters */ diff --git a/src/features/editScene/roadlib/TownRostersWeeks.cpp b/src/features/editScene/roadlib/TownRostersWeeks.cpp new file mode 100644 index 0000000..632dbbc --- /dev/null +++ b/src/features/editScene/roadlib/TownRostersWeeks.cpp @@ -0,0 +1,29 @@ +#include "TownRostersWeeks.hpp" + +namespace TownRosters { + +std::vector weekJobMembers( + const std::vector &weeks) +{ + std::vector out; + out.reserve(weeks.size()); + for (const TownSchedule::WeekSchedule &ws : weeks) { + MemberJob m; + m.registryId = ws.registryId; + m.stableKey = ws.stableKey; + for (const TownSchedule::WeekEntry &e : ws.entries) { + if (m.homeLocation.empty() && + (e.activity == "sleep" || e.activity == "home")) + m.homeLocation = e.location; + if (e.activity == "work" && + m.tagQuery.empty()) { + m.tagQuery = e.tagQuery; + m.workLocation = e.location; + } + } + out.push_back(std::move(m)); + } + return out; +} + +} /* namespace TownRosters */ diff --git a/src/features/editScene/roadlib/TownRostersWeeks.hpp b/src/features/editScene/roadlib/TownRostersWeeks.hpp new file mode 100644 index 0000000..7bcb94e --- /dev/null +++ b/src/features/editScene/roadlib/TownRostersWeeks.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "TownRosters.hpp" +#include "TownSchedule.hpp" + +/** + * Weekly-build adapter for the roster derivation (phase 6.8.7). + * OGRE-bound through TownSchedule.hpp — headless-linkable into the + * road geometry probe, while the pure core (TownRosters.hpp) stays + * OGRE-free for the Lua binding test. + */ +namespace TownRosters { + +/** Flatten a weekly build into the derivation input: the member's + * home is its first sleep/home entry's location, its job the first + * work entry's query/location. Members without work (children, + * or the 6.8.5 closing pull) carry an empty tagQuery. */ +std::vector weekJobMembers( + const std::vector &weeks); + +} /* namespace TownRosters */ diff --git a/src/features/editScene/roadlib/TownSchedule.cpp b/src/features/editScene/roadlib/TownSchedule.cpp index c57efcb..80136f1 100644 --- a/src/features/editScene/roadlib/TownSchedule.cpp +++ b/src/features/editScene/roadlib/TownSchedule.cpp @@ -1,7 +1,9 @@ #include "TownSchedule.hpp" #include +#include #include +#include #include namespace TownSchedule { @@ -175,10 +177,64 @@ WeekIndex buildWeekIndex(const TownLocations::LocationSet &locs) return ix; } +/* ===== 6.8.5 realism rules ===== */ + +/** A stat value with a neutral default (missing key = inert). */ +double statValue(const StatsSnapshot &snapshot, const std::string &name, + double fallback) +{ + auto it = snapshot.find(name); + return it == snapshot.end() ? fallback : it->second; +} + +/** The location's open window under the rules: the FIRST tag in + * the location's tag order that carries a table entry wins; an + * unmatched location is open all day. Degenerate table entries + * (close <= open) read as closed-but-valid bounds — a slot can + * never fit inside them, so such venues self-filter. */ +std::pair openWindowOf( + const WeekScheduleRules &rules, + const TownLocations::NpcLocation &loc) +{ + for (const std::string &tag : loc.tags) { + std::map >::const_iterator it = + rules.openHoursByTag.find(tag); + if (it != rules.openHoursByTag.end()) + return it->second; + } + return std::pair(0.0f, 24.0f); +} + +/** True when the open window covers the whole slot (a schedule + * entry may never sit at a closed venue, even in part). */ +bool openFor(const std::pair &win, float slotStart, + float slotEnd) +{ + return win.first <= slotStart + 1.0e-4f && + win.second >= slotEnd - 1.0e-4f; +} + +/** The resolved soft capacity for a location (tag overrides first; + * 0 default rules = unlimited). */ +int capacityOf(const WeekScheduleRules &rules, + const TownLocations::NpcLocation &loc) +{ + for (const std::string &tag : loc.tags) { + std::map::const_iterator it = + rules.capacityByTag.find(tag); + if (it != rules.capacityByTag.end()) + return it->second; + } + return rules.softCapacity; +} + /** Score one candidate: the hook wins, else the pure default - * (usage + straight-line home distance + name-hash jitter). */ + * (usage + straight-line home distance + name-hash jitter). The + * rules resolve the candidate's soft capacity (6.8.5). */ double scoreCandidate(const ScoreHook &hook, const TownLocations::LocationSet &locs, int idx, + const WeekScheduleRules &rules, const TownLocations::NpcLocation *home, int usage, const StatsSnapshot &snapshot) { @@ -187,11 +243,26 @@ double scoreCandidate(const ScoreHook &hook, c.name = loc.name; c.tags = loc.tags; if (home) { - float dx = loc.position.x - home->position.x; - float dz = loc.position.z - home->position.z; + /* 6.8.12: candidate distances are scored against the + * APPROACH anchor (the building entry, for interior + * destinations) rather than the interior cell — same + * value for generated towns (entry == position), and + * the employee back-door anchor for 6.8.8. */ + const Ogre::Vector3 &anchor = + TownLocations::approachPosition(loc); + float dx = anchor.x - home->position.x; + float dz = anchor.z - home->position.z; c.distanceMeters = std::sqrt(dx * dx + dz * dz); } c.usage = usage; + int cap = capacityOf(rules, loc); + c.capacity = cap > 0 ? (double)cap : 1.0e9; + /* 6.8.10: the location's stat effects are visible to the + * score hook (poor NPCs prefer money-positive venues, + * stressed NPCs stress-relieving ones). The default score + * keeps its deterministic no-stats kernel. */ + for (const auto &se : loc.statEffects) + c.effects[se.first] = se.second; if (hook) return hook(c, snapshot); return TownScheduleScore::defaultScore(c, snapshot); @@ -363,13 +434,50 @@ std::vector generateWeekSchedules( randRange(rng, -0.5f, 0.5f))); if (errandLen < 1) errandLen = 1; + + /* 6.8.10 stat-driven weekly variety. The stats map + * carries ONLY members with EXPLICIT stat values + * (defaults stay inert), so an empty/default stats + * snapshot reproduces the 6.8.3 week bit-for-bit. + * health < 30 or stress > 85 → a deterministic + * full-home rest day; money < 25 → fewer paid outings + * and an extra weekend job shift (poor NPCs take + * more job blocks); stress > 60 → more leisure. */ + float errandP = rules.daily.errandProbability; + int healthRestDay = -1, stressRestDay = -1; + bool extraWeekendJob = false; + if (!stats.empty()) { + const double health = statValue(snapshot, + "health", 100.0); + const double money = statValue(snapshot, + "money", 100.0); + const double stress = statValue(snapshot, + "stress", 0.0); + if (stress > 60.0) + errandP *= 1.2f; + if (money < 25.0) + errandP *= 0.6f; + if (health < 30.0) + errandP *= 0.5f; + errandP = clampf(errandP, 0.0f, 1.0f); + if (health < 30.0) + healthRestDay = (int)(fnv1a(stableKey + + "|health-rest") % + 7); + if (stress > 85.0) + stressRestDay = (int)(fnv1a(stableKey + + "|stress-rest") % + 7); + extraWeekendJob = money < 25.0 && adult; + } bool errand[7]; for (int d = 0; d < 7; ++d) - errand[d] = randRange(rng, 0.0f, 1.0f) < - rules.daily.errandProbability; + errand[d] = randRange(rng, 0.0f, 1.0f) < errandP; /* Job resolution (once per member; decision 5 — jobs - * are weekly-sticky). */ + * are weekly-sticky). 6.8.5: only venues open for the + * whole work block qualify (unmatched tags are always + * open, so tagless towns are unaffected). */ std::string jobTag; std::string workName; if (adult) { @@ -389,7 +497,42 @@ std::vector generateWeekSchedules( "used)"); pool = ix.works; } - if (pool.empty()) { + /* The candidate slot: the work block the member + * will hold (the pushed entry rounds the end to + * a whole hour, so feasibility checks the same + * boundary). */ + float wStart = (float)workStart; + float wEnd = wStart + rules.daily.workHours; + float + slotEnd = std::round(clampf(wEnd, wStart, + 24.0f)); + bool closedOut = false; + if (!pool.empty()) { + std::vector open; + for (int idx : pool) + if (openFor( + openWindowOf( + rules, + locations + .locations + [idx]), + wStart, slotEnd)) + open.push_back(idx); + if ((int)open.size() < (int)pool.size()) + closedOut = true; + pool = open; + } + if (pool.empty() && closedOut) { + /* 6.8.5 closing pull: every Work venue is + * shut for the member's whole work block, + * so the workday stays home — no schedule + * entry may sit at a closed venue even + * after the usual fallbacks failed. */ + workName = bind.home; + warn("week schedule: no Work location is " + "open for the work window; workers " + "stay home"); + } else if (pool.empty()) { /* Decision 7: work -> the daily binding * (the Interest placeholder or home). */ workName = bind.work; @@ -400,7 +543,8 @@ std::vector generateWeekSchedules( int bestIdx = -1; for (int idx : pool) { double s = scoreCandidate( - hook, locations, idx, home, + hook, locations, idx, + rules, home, usage.get( locations.locations [idx] @@ -453,6 +597,19 @@ std::vector generateWeekSchedules( workdayWake); push(wake, bind.home, "sleep", "", day); + /* 6.8.10: deterministic full-home rest days + * (health < 30, stress > 85) — the rest of the + * day is home-idle, no work and no outings; + * the week keeps its bookends and tiles. */ + if (day == healthRestDay || day == stressRestDay) { + push((float)sleepHour, bind.home, "home", "", + day); + push(24.0f, bind.home, "sleep", "", day); + if (t < 24.0f && !ws.entries.empty()) + ws.entries.back().endHour = 24.0f; + continue; + } + /* The day's window: work for working adults, or * the day's outing window. */ if (adult && !isWeekend) { @@ -468,9 +625,20 @@ std::vector generateWeekSchedules( } else if (adult) { /* Adult weekend: an afternoon outing * window (the leisure slot lands - * inside it). */ - push((float)weekendWake + 4.0f, bind.home, - "home", "", day); + * inside it). 6.8.10: poor NPCs take + * a weekend job shift instead (extra + * job block at the sticky venue). */ + if (extraWeekendJob) { + push((float)weekendWake + 8.0f, + workName, "work", + jobTag.empty() ? + std::string() : + "job:" + jobTag, + day); + } else { + push((float)weekendWake + 4.0f, bind.home, + "home", "", day); + } } else { float outHour = pm->age == "child" ? @@ -503,13 +671,48 @@ std::vector generateWeekSchedules( "(all Leisure used)"); pool = ix.leisures; } + /* 6.8.5: only venues open for the whole + * leisure slot (a visit never sits at a + * closed venue; closed-at-slot venue + * towns fall through the usual + * curb/interest chain). The fallback + * chain itself stays unfiltered — curbs + * and interests are street-side, never + * venue-hours-gated. */ + if (!pool.empty()) { + float slotEnd = + t + (float)errandLen; + std::vector open; + for (int idx : pool) + if (openFor( + openWindowOf( + rules, + locations + .locations + [idx]), + t, slotEnd)) + open.push_back(idx); + if ((int)open.size() < + (int)pool.size()) { + pool = open; + if (pool.empty()) + warn("week " + "schedule: " + "every Leisure " + "location is " + "closed at the " + "leisure slot; " + "fallbacks " + "used"); + } + } int leisureIdx = -1; if (!pool.empty()) { double best = -1.0e30; for (int idx : pool) { double s = scoreCandidate( hook, locations, - idx, home, + idx, rules, home, usage.get( locations .locations @@ -586,9 +789,85 @@ std::vector generateWeekSchedules( return out; } -const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour) +WeekSchedule spliceWeekAfter(const WeekSchedule &stored, + const WeekSchedule &fresh, + double absoluteHours) { - /* Each day's entries tile [0, 24); the exact bucket wins. */ + WeekSchedule out; + out.registryId = stored.registryId; + out.stableKey = stored.stableKey; + out.weekIndex = fresh.weekIndex; + + const int curDay = (int)std::floor(absoluteHours / 24.0); + const float tod = (float)(absoluteHours - + (double)curDay * 24.0); + + for (int day = 0; day < 7; ++day) { + if (day < curDay) { + for (const WeekEntry &e : stored.entries) + if (e.day == day) + out.entries.push_back(e); + continue; + } + if (day > curDay) { + for (const WeekEntry &e : fresh.entries) + if (e.day == day) + out.entries.push_back(e); + continue; + } + + /* Current day: keep the stored entries up to and + * including the in-flight entry (startHour <= tod). + * The in-flight entry (startHour < tod < endHour) + * is kept WHOLE — in-flight goals finish first. */ + float boundary = 0.0f; + bool haveFlight = false; + for (const WeekEntry &e : stored.entries) { + if (e.day != day) + continue; + if (e.startHour <= tod + 1e-4f) { + out.entries.push_back(e); + if (e.endHour > tod + 1e-4f) + haveFlight = true; + boundary = e.endHour; + } + } + if (!haveFlight) { + /* Nothing in flight (a whole-hour boundary): + * the boundary is the next stored start or 24. */ + boundary = 24.0f; + for (const WeekEntry &e : stored.entries) + if (e.day == day && e.startHour > tod + 1e-4f) { + boundary = std::min(boundary, + e.startHour); + break; + } + } + for (const WeekEntry &e : fresh.entries) { + if (e.day != day) + continue; + if (e.startHour >= boundary - 1e-4f) + out.entries.push_back(e); + } + /* The day tiles [0, 24): the last entry reaches 24. */ + if (!out.entries.empty()) { + WeekEntry &last = out.entries.back(); + if (last.day == day && last.endHour < 24.0f) + last.endHour = 24.0f; + } + } + + std::stable_sort(out.entries.begin(), out.entries.end(), + [](const WeekEntry &a, const WeekEntry &b) { + if (a.day != b.day) + return a.day < b.day; + return a.startHour < b.startHour; + }); + return out; +} + +const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour) +{ /* Each day's entries tile [0, 24); the exact bucket wins. */ float t = hour - 24.0f * std::floor(hour / 24.0f); for (const WeekEntry &e : week.entries) { if (e.day != day) @@ -599,4 +878,26 @@ const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour) return nullptr; } +std::array splitWeekToDays(const WeekSchedule &week) +{ + std::array days; + for (int d = 0; d < 7; ++d) { + days[d].registryId = week.registryId; + days[d].stableKey = week.stableKey; + } + for (const WeekEntry &e : week.entries) { + if (e.day < 0 || e.day >= 7) + continue; + ScheduleEntry s; + s.startHour = e.startHour; + s.endHour = e.endHour; + s.location = e.location; + s.activity = e.activity; + days[e.day].entries.push_back(s); + } + /* `week.entries` is already sorted by (day, startHour), so + * each view inherits the per-day order. */ + return days; +} + } /* namespace TownSchedule */ diff --git a/src/features/editScene/roadlib/TownSchedule.hpp b/src/features/editScene/roadlib/TownSchedule.hpp index 74dc30a..f06cc98 100644 --- a/src/features/editScene/roadlib/TownSchedule.hpp +++ b/src/features/editScene/roadlib/TownSchedule.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -145,10 +146,34 @@ struct WeekSchedule { }; /** Weekly rules: the existing per-day numerics plus the weekend - * day indexes (0..6; default {5, 6}). */ + * day indexes (0..6; default {5, 6}) and the 6.8.5 realism + * constraints (opening hours per location tag + soft capacity). + * The pure defaults are NO-OPS (always-open venues, unlimited + * capacity) so default towns keep their prior weekly schedules + * bit-identically; real content opts in per build (the demo's + * weekly build passes shop 9-18 + capacity 8). */ struct WeekScheduleRules { ScheduleRules daily; std::vector weekendDays = { 5, 6 }; + + /** 6.8.5: opening hours per location TAG — a location whose + * tags carry a table key (first tag in the location's tag + * order wins) is OPEN on [first, second) hours; the + * generator filters candidates to venues open for a slot's + * full span (schedules never place an NPC at a closed venue + * while a legal candidate exists) and pulls workers/visitors + * through the usual fallback chains otherwise. Unmatched + * locations are always open. Empty = always open. */ + std::map > openHoursByTag; + + /** 6.8.5: soft capacity — reservations past this per-location + * pick count sink the candidate's score (one popular venue + * does not absorb the whole town; the score stays comparable + * so the push is soft, never a hard veto). <= 0 = unlimited; + * tag overrides win over the default (same first-tag-order + * lookup as the opening hours). */ + int softCapacity = 0; + std::map capacityByTag; }; /** Generate the baseline week for every population member. @@ -196,4 +221,26 @@ std::vector generateWeekSchedules( const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour); +/** Day-shaped views of a week (6.8.4b): per day-of-week one + * `NpcSchedule` whose entries are that day's whole-hour tiling of + * [0, 24). The runtime consumers (motion nextLocationChange / + * traffic claim replay / evalSymbolic) keep their daily-shaped + * evaluation path; this is the adapter, so the week's day-of-week + * tiling feeds the same `entryAt()` machinery unchanged. Views of + * every day of an empty week are empty (single-entry schedules, + * never moves). */ +std::array splitWeekToDays(const WeekSchedule &week); + +/** 6.8.10: merge a stored week with a freshly resolved week at an + * absolute game hour. The past (days before the current day and + * the stored entries whose start is at or before the clock) stays + * IDENTICAL; the current in-flight entry is kept WHOLE (in-flight + * goals finish first); only the REST of the week is rewritten from + * the fresh build. registryId/stableKey travel from the stored + * week, weekIndex from the fresh one. Entries never overlap and + * every day tiles [0, 24). */ +WeekSchedule spliceWeekAfter(const WeekSchedule &stored, + const WeekSchedule &fresh, + double absoluteHours); + } /* namespace TownSchedule */ diff --git a/src/features/editScene/roadlib/TownScheduleScore.cpp b/src/features/editScene/roadlib/TownScheduleScore.cpp index 0e0a751..770fc5a 100644 --- a/src/features/editScene/roadlib/TownScheduleScore.cpp +++ b/src/features/editScene/roadlib/TownScheduleScore.cpp @@ -7,8 +7,9 @@ namespace TownScheduleScore { double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, const Weights &weights) { - /* Usage and distance push the score down, a name-hash jitter - * breaks ties deterministically; the stats snapshot is + /* Usage and distance push the score down, over-capacity picks + * (6.8.5 soft capacity) push it further down, and a name-hash + * jitter breaks ties deterministically; the stats snapshot is * deliberately unused by the default (decision 8: stats are * sampled at week start and only steer via the override hook, * not silently inside the default). */ @@ -19,9 +20,12 @@ double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, h *= 16777619u; } double jitter = (double)h / (double)UINT32_MAX; - return -(weights.usage * (double)candidate.usage + - weights.distance * candidate.distanceMeters) + - weights.jitter * jitter; + double score = -(weights.usage * (double)candidate.usage + + weights.distance * candidate.distanceMeters); + double over = (double)candidate.usage - candidate.capacity; + if (over > 0.0) + score -= weights.capacity * over; + return score + weights.jitter * jitter; } } // namespace TownScheduleScore diff --git a/src/features/editScene/roadlib/TownScheduleScore.hpp b/src/features/editScene/roadlib/TownScheduleScore.hpp index b777351..c4b76b3 100644 --- a/src/features/editScene/roadlib/TownScheduleScore.hpp +++ b/src/features/editScene/roadlib/TownScheduleScore.hpp @@ -50,6 +50,11 @@ struct Candidate { int usage = 0; /** Stat effects offered per in-game hour (e.g. money=+10). */ std::unordered_map effects; + /** Soft capacity (6.8.5): picks past it sink the score, so + * one popular location does not absorb the whole town. The + * generator resolves the limit from the rules' tag table; + * the default (huge) is unlimited. */ + double capacity = 1.0e9; }; /** Candidate scoring weights (mirrors @@ -58,9 +63,13 @@ struct Weights { double usage = 1.0; double distance = 0.01; double jitter = 0.001; + /** Score penalty per pick above the candidate's soft + * capacity (6.8.5). */ + double capacity = 1.0; }; -/** The pure default: -(usage*w + distance*w) + hashJitter*w. */ +/** The pure default: -(usage*w + distance*w + overflow*w) + + * hashJitter*w. */ double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, const Weights &weights = Weights{}); diff --git a/src/features/editScene/systems/NpcMotionSystem.cpp b/src/features/editScene/systems/NpcMotionSystem.cpp index d9c86f0..7c7da77 100644 --- a/src/features/editScene/systems/NpcMotionSystem.cpp +++ b/src/features/editScene/systems/NpcMotionSystem.cpp @@ -5,6 +5,9 @@ #include #include "CharacterRegistry.hpp" +#include "CharacterStatStore.hpp" +#include "StatEffectsApplier.hpp" + #include "GameClockSystem.hpp" #include "NpcPopulationSystem.hpp" #include "RoadSystem.hpp" @@ -219,7 +222,15 @@ void NpcMotionSystem::update() m_states.erase(id); continue; } - const TownSchedule::NpcSchedule *sched = pop->scheduleOf(id); + /* 6.8.4b: the active schedule is the WEEK's day-of-week + * tiling when the area carries a weekly build + * (day-of-week = absolute day % 7; the day view is a + * daily-shaped [0,24) tiling), else the legacy daily + * schedule. 6.8.14: an active EMERGENCY supersedes + * all of that (the highest-priority goal). */ + int weekday = (int)std::floor(tabs / 24.0) % 7; + const TownSchedule::NpcSchedule *sched = + pop->effectiveDayScheduleOf(id, weekday); int areaIdx = pop->areaIndexOf(id); const TownLocations::LocationSet *locs = areaIdx >= 0 ? pop->areaLocations(areaIdx) : nullptr; @@ -245,6 +256,40 @@ void NpcMotionSystem::update() const TownSchedule::ScheduleEntry *active = lc.active; const TownLocations::NpcLocation *cur = lc.from; + /* 6.8.13: on COMPLETED schedule entries, apply the + * location's declared stat effects per in-game hour + * spent at it (data on the POI, never the POI + * mutating stats itself). Each (npc, location, + * startHour) key applies exactly once per run — a + * back-scrub never re-applies an already completed + * entry, and the completed set is runtime-only. */ + { + auto prev = m_lastEntry.find(id); + if (prev != m_lastEntry.end()) { + const TownSchedule::ScheduleEntry &p = prev->second; + if (p.location != active->location) { + const TownLocations::NpcLocation + *ploc = locs->findByName( + p.location); + if (ploc) { + EntryKey key{ id, p.location, + p.startHour }; + if (m_completedEntries + .insert(key) + .second) + StatEffectsApplier:: + applyLocationEffects( + CharacterStatStore:: + getInstance(), + id, *ploc, + p.endHour - + p.startHour); + } + } + } + m_lastEntry[id] = *active; + } + if (!lc.next) { /* Single-location schedule: never moves. */ if (cur) diff --git a/src/features/editScene/systems/NpcMotionSystem.hpp b/src/features/editScene/systems/NpcMotionSystem.hpp index 470d198..b87fa1e 100644 --- a/src/features/editScene/systems/NpcMotionSystem.hpp +++ b/src/features/editScene/systems/NpcMotionSystem.hpp @@ -149,6 +149,25 @@ private: flecs::world &m_world; std::map m_states; + /* 6.8.13: per-NPC last evaluated entry (for completed-entry + * stat effects), and the (npc, location, startHour) keys + * already applied this run so a back-scrub never + * re-applies an entry. */ + struct EntryKey { + uint64_t id = 0; + std::string location; + float startHour = 0.0f; + bool operator<(const EntryKey &other) const + { + if (id != other.id) + return id < other.id; + if (location != other.location) + return location < other.location; + return startHour < other.startHour; + } + }; + std::map m_lastEntry; + std::set m_completedEntries; /* NPCs driven externally (taxi rides); skipped by update(). */ std::set m_suspended; diff --git a/src/features/editScene/systems/NpcPopulationSystem.cpp b/src/features/editScene/systems/NpcPopulationSystem.cpp index a7e9930..a981788 100644 --- a/src/features/editScene/systems/NpcPopulationSystem.cpp +++ b/src/features/editScene/systems/NpcPopulationSystem.cpp @@ -2,7 +2,13 @@ #include +#include "CharacterStatStore.hpp" #include "CharacterRegistry.hpp" +#include "GameClockSystem.hpp" +#include "NpcTaxiSystem.hpp" +#include "NpcTrafficSystem.hpp" + +#include #include "CharacterSlotSystem.hpp" #include "RenderOriginSystem.hpp" #include "RoadSystem.hpp" @@ -77,6 +83,20 @@ static const uint32_t kScheduleSeedXor = 0xc2b2ae35u; /* 6.8.4a: the baseline week's seed discipline mirrors the others. */ static const uint32_t kWeekScheduleSeedXor = 0x27d4eb2fu; +/** 6.8.5: the demo town's weekly realism rules — shop venues are + * open 9-18 (the plan's worked example; closed-at-slot visits + * fall through the curb/interest chain) and picks spread past 8 + * per location (one popular venue does not absorb the whole + * town). Pure-layer defaults stay no-ops; this is the content + * opt-in for the demo's baseline week. */ +static TownSchedule::WeekScheduleRules demoWeeklyRules() +{ + TownSchedule::WeekScheduleRules r; + r.openHoursByTag["shop"] = std::make_pair(9.0f, 18.0f); + r.softCapacity = 8; + return r; +} + NpcPopulationSystem *NpcPopulationSystem::ms_instance = nullptr; NpcPopulationSystem::NpcPopulationSystem(flecs::world &world) @@ -297,18 +317,57 @@ int NpcPopulationSystem::generateAreaPopulation(int areaIndex, sc.distanceMeters = c.distanceMeters; sc.usage = c.usage; sc.effects = c.effects; + sc.capacity = c.capacity; return ScheduleScoring::score(sc, snap); }; std::vector weekWarns; std::map usage; + /* 6.8.10: the week-start stats snapshot. Only members + * with EXPLICIT stat values are included — missing or + * all-default members leave the week's shape exactly + * the 6.8.3 baseline (defaults never perturb the + * baseline). */ + std::unordered_map + weekStats; + { + CharacterStatStore &stats = + CharacterStatStore::getInstance(); + for (const TownPopulation::PopMember &pm : pop.members) { + TownSchedule::StatsSnapshot snap = + stats.snapshot(pm.registryId); + bool explicitAny = false; + for (const auto &kv : snap) + if (stats.has(pm.registryId, + kv.first)) { + explicitAny = true; + break; + } + if (explicitAny) + weekStats[pm.registryId] = snap; + } + ap.weekStats = weekStats; + } std::vector weeks = TownSchedule::generateWeekSchedules( pop, ap.locations, effSeed ^ kWeekScheduleSeedXor, - TownSchedule::WeekScheduleRules(), {}, hook, + demoWeeklyRules(), weekStats, hook, &weekWarns, &usage); for (const TownSchedule::WeekSchedule &w : weeks) ap.weekSchedules[w.registryId] = w; ap.weekUsage = usage; + /* 6.8.10: keep the population for mid-week revisions. */ + ap.population = pop; + /* 6.8.7: the special-job rosters are an OUTPUT of the + * weekly build (same determinism contract). */ + ap.rosters = TownRosters::deriveRosters( + TownRosters::weekJobMembers(weeks), + TownRosters::specialJobTags()); + /* 6.8.4b: the per-day DAILY-shaped views the runtime + * consumes (motion/traffic keep their entryAt()/ + * replay paths; the tiling now comes from the week). */ + for (const TownSchedule::WeekSchedule &w : weeks) + ap.weekDayViews[w.registryId] = + TownSchedule::splitWeekToDays(w); for (const std::string &w : weekWarns) Ogre::LogManager::getSingleton().logMessage( "NpcPopulationSystem[" + area.name + @@ -337,6 +396,19 @@ int NpcPopulationSystem::generateAreaPopulation(int areaIndex, newInterest; } } + /* 6.8.10: revision history is part of the weekly store — it + * must survive a weekly rebuild (a town graph change is a + * fresh baseline, not a reason to forget the recorded + * revisions). 6.8.14: the active emergencies survive too. + * The weekly store otherwise regenerates as before + * (population, locations, schedules, usage). */ + { + auto old = m_areas.find(areaIndex); + if (old != m_areas.end()) { + ap.revisions = old->second.revisions; + ap.emergencies = old->second.emergencies; + } + } m_areas[areaIndex] = ap; reg.setAutoSaveSuspended(false); @@ -435,21 +507,36 @@ void NpcPopulationSystem::update() if (key == m_autoKey && terrain.id() == m_autoTerrain) return; - if (terrain.id() != m_autoTerrain) + if (terrain.id() != m_autoTerrain) { + /* 6.8.10: revision history is part of the weekly + * store and must survive the clear + regenerate cycle + * (the clear replaces the whole AreaPopulation + * struct, so preserve per-area revisions before + * calling clearAllPopulations). 6.8.14: the active + * emergencies survive too (they are live state on + * stable registry ids). */ + std::map > + savedRevisions; + std::map > + savedEmergencies; + for (const auto &kv : m_areas) { + savedRevisions[kv.first] = kv.second.revisions; + savedEmergencies[kv.first] = kv.second.emergencies; + } clearAllPopulations(); - - /* While the region store is still merging pages the areas can - * exist without their buildings; retry about once a second - * instead of latching an empty population or spamming the - * log every frame. */ - if (m_autoRetry > 0) { - --m_autoRetry; - return; - } - int n = generateAllAreas(nullptr); - if (n == 0) { - m_autoRetry = 60; - return; + int n = generateAllAreas(nullptr); + for (const auto &kv : savedRevisions) + if (m_areas.count(kv.first)) + m_areas[kv.first].revisions = kv.second; + for (const auto &kv : savedEmergencies) + if (m_areas.count(kv.first)) + m_areas[kv.first].emergencies = kv.second; + if (n == 0) { + m_autoRetry = 60; + return; + } } m_autoKey = key; m_autoTerrain = terrain.id(); @@ -677,3 +764,423 @@ NpcPopulationSystem::weekUsageOf(int areaIndex) const auto it = m_areas.find(areaIndex); return it == m_areas.end() ? nullptr : &it->second.weekUsage; } + +const TownRosters::Rosters * +NpcPopulationSystem::areaRosters(int areaIndex) const +{ + auto it = m_areas.find(areaIndex); + return it == m_areas.end() ? nullptr : &it->second.rosters; +} + +bool NpcPopulationSystem::reviseWeek(int areaIndex, + const std::string &reason, + uint32_t graphVersion) +{ auto it = m_areas.find(areaIndex); + if (it == m_areas.end() || it->second.weekSchedules.empty()) + return false; + AreaPopulation &ap = it->second; + + /* Recompute the effective seed (the area record is stable + * data; the population/location contract matches the weekly + * build's). */ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getRoadSystem()) + return false; + flecs::entity terrainEnt = ts->getRoadSystem()->getTerrainEntity(); + if (!terrainEnt.is_alive() || + !terrainEnt.has()) + return false; + const RoadGraph *graph = &terrainEnt.get().roadGraph; + if (areaIndex < 0 || + (size_t)areaIndex >= graph->townAreas.size()) + return false; + const uint32_t effSeed = RoadNetworkGen::effectiveAreaSeed( + *graph, graph->townAreas[areaIndex]); + + /* The current stats snapshot (explicit only, like the weekly + * build). */ + std::unordered_map + weekStats; + { + CharacterStatStore &stats = CharacterStatStore::getInstance(); + for (const TownPopulation::PopMember &pm : ap.population.members) { + TownSchedule::StatsSnapshot snap = + stats.snapshot(pm.registryId); + bool explicitAny = false; + for (const auto &kv : snap) + if (stats.has(pm.registryId, kv.first)) { + explicitAny = true; + break; + } + if (explicitAny) + weekStats[pm.registryId] = snap; + } + } + + TownSchedule::ScoreHook hook = + [](const TownSchedule::WeekCandidate &c, + const TownSchedule::StatsSnapshot &snap) { + ScheduleScoring::Candidate sc; + sc.name = c.name; + sc.tags = c.tags; + sc.distanceMeters = c.distanceMeters; + sc.usage = c.usage; + sc.effects = c.effects; + sc.capacity = c.capacity; + return ScheduleScoring::score(sc, snap); + }; + std::vector warns; + std::map usage; + std::vector fresh = + TownSchedule::generateWeekSchedules( + ap.population, ap.locations, + effSeed ^ kWeekScheduleSeedXor, demoWeeklyRules(), + weekStats, hook, &warns, &usage); + if (fresh.empty()) + return false; + + const double tabs = GameClockSystem::getInstance() + ->getAbsoluteHours(); + + /* 6.8.10 decision 1: the revision rewrites the REST of the + * current week only. Past days and the in-flight entry are + * preserved verbatim (in-flight goals complete before the + * new schedule takes over; the revision never moves an + * NPC's position). */ + std::unordered_map freshById; + for (const TownSchedule::WeekSchedule &w : fresh) + freshById[w.registryId] = w; + for (auto &kv : ap.weekSchedules) { + auto fi = freshById.find(kv.first); + if (fi != freshById.end()) + kv.second = TownSchedule::spliceWeekAfter( + kv.second, fi->second, tabs); + } + for (auto &kv : ap.weekDayViews) + kv.second = TownSchedule::splitWeekToDays( + ap.weekSchedules[kv.first]); + ap.weekUsage = usage; + ap.weekStats = weekStats; + + AreaPopulation::WeekRevision rev; + rev.reason = reason; + rev.weekIndex = 0; + rev.graphVersion = graphVersion; + rev.revisionId = (uint32_t)ap.revisions.size(); + ap.revisions.push_back(rev); + return true; +} + +const TownSchedule::NpcSchedule * +NpcPopulationSystem::weekDayScheduleOf(uint64_t registryId, + int day) const +{ if (day < 0 || day >= 7) + return nullptr; + for (const auto &kv : m_areas) { + std::unordered_map< + uint64_t, + std::array >:: + const_iterator it = + kv.second.weekDayViews.find(registryId); + if (it != kv.second.weekDayViews.end()) + return &it->second[(size_t)day]; + } + return nullptr; +} + +bool NpcPopulationSystem::beginEmergency(uint64_t registryId, + const std::string &reason) +{ + const int areaIndex = areaIndexOf(registryId); + if (areaIndex < 0) + return false; + AreaPopulation &ap = m_areas[areaIndex]; + if (ap.emergencies.count(registryId)) + return true; /* already active */ + + /* The emergency destination: the town's hospital Work + * location (tag "hospital"); a town without one falls back + * to the member's home rest, warned once. */ + std::string hospitalLoc; + for (const TownLocations::NpcLocation &loc : ap.locations.locations) { + if (loc.kind != TownLocations::LocationKind::Work) + continue; + for (const std::string &tag : loc.tags) + if (tag == "hospital") { + hospitalLoc = loc.name; + break; + } + if (!hospitalLoc.empty()) + break; + } + if (hospitalLoc.empty()) { + CharacterRegistry ® = CharacterRegistry::getSingleton(); + CharacterRegistry::CharacterRecord *rec = + reg.findCharacter(registryId); + auto it = rec ? rec->stringColumns.find("npcHome") : + rec->stringColumns.end(); + if (it == rec->stringColumns.end() || it->second.empty()) + return false; + hospitalLoc = "home:" + it->second; + static bool warnedNoHospital = false; + if (!warnedNoHospital) { + warnedNoHospital = true; + Ogre::LogManager::getSingleton().logMessage( + "NpcPopulationSystem: no hospital " + "location in town; emergency falls back " + "to home rest"); + } + } + + AreaPopulation::EmergencyState em; + em.reason = reason; + em.location = hospitalLoc; + em.startAbs = GameClockSystem::getInstance()->getAbsoluteHours(); + /* The synthetic schedule: whole-hour entries at the emergency + * location, every day (activity "hospital" reads clearly in + * F7; the effects application treats it like any entry). */ + for (int h = 0; h < 24; ++h) { + TownSchedule::ScheduleEntry e; + e.startHour = (float)h; + e.endHour = (float)(h + 1); + e.location = hospitalLoc; + e.activity = "hospital"; + em.schedule.entries.push_back(e); + } + ap.emergencies[registryId] = std::move(em); + + /* The emergency is the highest-priority goal: supersede the + * current one immediately (a health emergency does not wait + * for the taxi ride to finish). */ + if (NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance()) + taxi->cancelGoal(registryId); + if (NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance()) + traffic->despawnLiveCar(registryId); + return true; +} + +bool NpcPopulationSystem::endEmergency(uint64_t registryId) +{ + const int areaIndex = areaIndexOf(registryId); + if (areaIndex < 0) + return false; + return m_areas[areaIndex].emergencies.erase(registryId) > 0; +} + +bool NpcPopulationSystem::hasActiveEmergency(uint64_t registryId) const +{ + const int areaIndex = areaIndexOf(registryId); + if (areaIndex < 0) + return false; + auto it = m_areas.find(areaIndex); + if (it == m_areas.end()) + return false; + return it->second.emergencies.count(registryId) > 0; +} + +const TownSchedule::NpcSchedule * +NpcPopulationSystem::emergencyScheduleOf(uint64_t registryId) const +{ + const int areaIndex = areaIndexOf(registryId); + if (areaIndex < 0) + return nullptr; + auto it = m_areas.find(areaIndex); + if (it == m_areas.end()) + return nullptr; + auto em = it->second.emergencies.find(registryId); + return em == it->second.emergencies.end() ? + nullptr : + &em->second.schedule; +} + +const TownSchedule::NpcSchedule * +NpcPopulationSystem::effectiveDayScheduleOf(uint64_t registryId, + int day) const +{ + if (const TownSchedule::NpcSchedule *em = + emergencyScheduleOf(registryId)) + return em; + if (const TownSchedule::NpcSchedule *wday = + weekDayScheduleOf(registryId, day)) + return wday; + return scheduleOf(registryId); +} + +nlohmann::json NpcPopulationSystem::serializeWeekSchedules() const +{ + nlohmann::json out = nlohmann::json::object(); + for (const auto &area : m_areas) { + const AreaPopulation &ap = area.second; + nlohmann::json areaJson = nlohmann::json::object(); + nlohmann::json weeks = nlohmann::json::array(); + for (const auto &kv : ap.weekSchedules) { + const TownSchedule::WeekSchedule &w = kv.second; + nlohmann::json wj; + wj["registryId"] = w.registryId; + wj["stableKey"] = w.stableKey; + wj["weekIndex"] = w.weekIndex; + nlohmann::json entries = nlohmann::json::array(); + for (const TownSchedule::WeekEntry &e : w.entries) { + entries.push_back( + { { "day", e.day }, + { "start", e.startHour }, + { "end", e.endHour }, + { "loc", e.location }, + { "act", e.activity }, + { "query", e.tagQuery } }); + } + wj["entries"] = std::move(entries); + auto si = ap.weekStats.find(w.registryId); + if (si != ap.weekStats.end()) { + nlohmann::json stats = + nlohmann::json::object(); + for (const auto &kv : si->second) + stats[kv.first] = kv.second; + wj["stats"] = std::move(stats); + } + weeks.push_back(std::move(wj)); + } + areaJson["weeks"] = std::move(weeks); + nlohmann::json usage = nlohmann::json::object(); + for (const auto &kv : ap.weekUsage) + usage[kv.first] = kv.second; + areaJson["usage"] = std::move(usage); + nlohmann::json revs = nlohmann::json::array(); + for (const auto &rev : ap.revisions) + revs.push_back({ { "reason", rev.reason }, + { "weekIndex", rev.weekIndex }, + { "graphVersion", + rev.graphVersion }, + { "revisionId", rev.revisionId } }); + areaJson["revisions"] = std::move(revs); + nlohmann::json ems = nlohmann::json::array(); + for (const auto &kv : ap.emergencies) + ems.push_back({ { "registryId", kv.first }, + { "reason", kv.second.reason }, + { "location", + kv.second.location }, + { "startAbs", + kv.second.startAbs } }); + areaJson["emergencies"] = std::move(ems); + out[std::to_string(area.first)] = std::move(areaJson); + } + return out; +} + +void NpcPopulationSystem::deserializeWeekSchedules( + const nlohmann::json &j) +{ + Ogre::LogManager::getSingleton().logMessage( + "deserializeWeekSchedules: areas in store " + + std::to_string(m_areas.size()) + ", json areas " + + std::to_string(j.size())); + for (const auto &area : j.items()) { + int areaIndex = std::stoi(area.key()); + auto it = m_areas.find(areaIndex); + if (it == m_areas.end()) { + Ogre::LogManager::getSingleton().logMessage( + "deserializeWeekSchedules: skip area " + + std::to_string(areaIndex) + + " (not in store)"); + continue; + } + AreaPopulation &ap = it->second; + const auto &areaJson = area.value(); + + ap.weekSchedules.clear(); + ap.weekDayViews.clear(); + ap.weekStats.clear(); + if (areaJson.contains("weeks")) + for (const auto &wj : areaJson["weeks"]) { + TownSchedule::WeekSchedule w; + w.registryId = wj.value("registryId", + (uint64_t)0); + w.stableKey = wj.value("stableKey", + std::string()); + w.weekIndex = wj.value("weekIndex", 0u); + for (const auto &ej : wj["entries"]) { + TownSchedule::WeekEntry e; + e.day = ej.value("day", 0); + e.startHour = ej.value("start", 0.0f); + e.endHour = ej.value("end", 0.0f); + e.location = ej.value("loc", + std::string()); + e.activity = ej.value("act", + std::string()); + e.tagQuery = ej.value("query", + std::string()); + w.entries.push_back(e); + } + ap.weekSchedules[w.registryId] = w; + ap.weekDayViews[w.registryId] = + TownSchedule::splitWeekToDays(w); + if (wj.contains("stats")) { + TownSchedule::StatsSnapshot snap; + for (const auto &el : + wj["stats"].items()) + snap[el.key()] = + el.value() + .get(); + ap.weekStats[w.registryId] = snap; + } + } + + ap.weekUsage.clear(); + if (areaJson.contains("usage")) + for (const auto &el : areaJson["usage"].items()) + ap.weekUsage[el.key()] = + el.value().get(); + + ap.revisions.clear(); + if (areaJson.contains("revisions")) + for (const auto &rj : areaJson["revisions"]) { + AreaPopulation::WeekRevision rev; + rev.reason = rj.value("reason", + std::string()); + rev.weekIndex = rj.value("weekIndex", 0u); + rev.graphVersion = rj.value("graphVersion", + 0u); + rev.revisionId = rj.value("revisionId", + 0u); + ap.revisions.push_back(rev); + } + + /* 6.8.14: the active emergencies restore with their + * synthetic schedules rebuilt from the location. */ + ap.emergencies.clear(); + if (areaJson.contains("emergencies")) + for (const auto &ej : areaJson["emergencies"]) { + uint64_t id = ej.value("registryId", + (uint64_t)0); + std::string locName = + ej.value("location", + std::string()); + if (id == 0 || locName.empty()) + continue; + AreaPopulation::EmergencyState em; + em.reason = ej.value("reason", + std::string()); + em.location = locName; + em.startAbs = ej.value("startAbs", 0.0); + for (int h = 0; h < 24; ++h) { + TownSchedule::ScheduleEntry e; + e.startHour = (float)h; + e.endHour = (float)(h + 1); + e.location = locName; + e.activity = "hospital"; + em.schedule.entries.push_back(e); + } + ap.emergencies[id] = std::move(em); + } + + /* The rosters are a deterministic output of the + * restored weeks — recompute from the week data. */ + std::vector weeks; + weeks.reserve(ap.weekSchedules.size()); + for (const auto &kv : ap.weekSchedules) + weeks.push_back(kv.second); + ap.rosters = TownRosters::deriveRosters( + TownRosters::weekJobMembers(weeks), + TownRosters::specialJobTags()); + } +} diff --git a/src/features/editScene/systems/NpcPopulationSystem.hpp b/src/features/editScene/systems/NpcPopulationSystem.hpp index 7c3050a..7750792 100644 --- a/src/features/editScene/systems/NpcPopulationSystem.hpp +++ b/src/features/editScene/systems/NpcPopulationSystem.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -8,10 +9,13 @@ #include #include +#include #include "../roadlib/TownPopulation.hpp" #include "../roadlib/TownLocations.hpp" #include "../roadlib/TownSchedule.hpp" +#include "../roadlib/TownRosters.hpp" +#include "../roadlib/TownRostersWeeks.hpp" #include "../roadlib/PoiDatabase.hpp" /** @@ -104,11 +108,61 @@ public: * the F7 overlay shows the week. */ const TownSchedule::WeekSchedule *weekScheduleOf( uint64_t registryId) const; + /** 6.8.4b: the DAILY-shaped view of the NPC's week day + * (day-of-week 0..6), nullptr when the area has no weekly + * build. Motion/traffic evaluate this object with the same + * entryAt()/replay paths as the legacy daily schedule. */ + const TownSchedule::NpcSchedule *weekDayScheduleOf( + uint64_t registryId, int day) const; /** The weekly build's location usage tally (decision 4), null * when the area has no weekly build. Read-only — an output * of the generation, not game state. */ const std::map *weekUsageOf(int areaIndex) const; + /** 6.8.7: the area's special-job rosters (special tag -> + * registry-id-ordered members with home/work bindings), + * nullptr when the area has no weekly build. An output of + * the weekly build; the F7 overlay lists it and the Lua + * binding exposes it. */ + const TownRosters::Rosters *areaRosters(int areaIndex) const; + + /** 6.8.10: explicit mid-week revision — re-resolve the REST + * of the area's week with the CURRENT stats snapshot, + * spliced at the current absolute clock so in-flight goals + * complete before the new schedule takes over. The revision + * is recorded and saved with the game; the pure baseline + * resumes from the current clock afterwards. Returns false + * when the area has no weekly build. */ + bool reviseWeek(int areaIndex, const std::string &reason, + uint32_t graphVersion); + + /** 6.8.10: serialize the weekly store (weeks, bindings, + * usage, the stats snapshot used and the revision markers) + * for save/load; old saves without it regenerate from + * defaults. */ + nlohmann::json serializeWeekSchedules() const; + void deserializeWeekSchedules(const nlohmann::json &j); + + /** 6.8.14: the emergency layer. A health (or other + * stat-threshold) emergency is the HIGHEST-priority goal: + * the member's effective schedule becomes the emergency + * schedule (whole-hour entries at the hospital, or home + * rest when the town has no hospital), the current goal is + * cancelled/superseded immediately, and when the threshold + * clears the pure baseline simply resumes from the clock. + * Emergency state rides save/load. */ + bool beginEmergency(uint64_t registryId, const std::string &reason); + bool endEmergency(uint64_t registryId); + bool hasActiveEmergency(uint64_t registryId) const; + /** The emergency schedule for a member, nullptr when none. */ + const TownSchedule::NpcSchedule *emergencyScheduleOf( + uint64_t registryId) const; + /** The day schedule a consumer should evaluate: the + * emergency schedule first, else the weekly day view, else + * the legacy daily schedule. */ + const TownSchedule::NpcSchedule *effectiveDayScheduleOf( + uint64_t registryId, int day) const; + /** * Per-frame update. In game mode the population is generated * automatically once the active terrain's road graph carries @@ -138,9 +192,47 @@ private: * the daily schedules for now (6.8.4b flips them). */ std::unordered_map weekSchedules; + /* 6.8.4b: per-day DAILY-shaped views of the week (the + * adapter the runtime consumes). Keyed by registry + * id; index = day-of-week 0..6. */ + std::unordered_map > + weekDayViews; /* The weekly build's location -> pick tally (decision * 4; F7 week pane). */ std::map weekUsage; + /* 6.8.10: the week-start stats snapshot the build was + * shaped by (only members with explicit values). */ + std::unordered_map + weekStats; + /** Explicit recorded revision of the REST of the + * current week (6.8.10). Forward-looking, never + * touches the in-flight goal. Saved with the game. */ + struct WeekRevision { + std::string reason; + uint32_t weekIndex = 0; + uint32_t graphVersion = 0; + uint32_t revisionId = 0; + }; + std::vector revisions; + /** 6.8.14: active EMERGENCIES per member (the + * highest-priority goal overriding the baseline). + * The synthetic schedule tiles [0, 24) at the + * emergency location so the clock-pure evaluation + * "moves" the NPC there symbolically. */ + struct EmergencyState { + std::string reason; + std::string location; /* LocationSet name */ + double startAbs = 0.0; + TownSchedule::NpcSchedule schedule; + }; + std::unordered_map emergencies; + /* The population this area's schedules were built + * from — kept for mid-week revisions. */ + TownPopulation::Population population; + /* 6.8.7: the special-job rosters derived from this + * area's weekly build (registry-id order). */ + TownRosters::Rosters rosters; /* Ids merged from authored components (markers/POIs) so * the periodic re-merge can replace exactly those — * runtime-added POIs appear within a second, no full diff --git a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp index 6edd8da..60c63a8 100644 --- a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp +++ b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp @@ -16,6 +16,7 @@ #include "NpcDetailSystem.hpp" #include "NpcMotionSystem.hpp" #include "NpcPopulationSystem.hpp" +#include "../roadlib/TownRosters.hpp" #include "NpcTaxiSystem.hpp" #include "NpcTrafficSystem.hpp" @@ -219,6 +220,41 @@ void NpcScheduleDebugOverlay::render() ImGui::Text(" %-8s %.1f", kv.first.c_str(), kv.second); } + /* Phase 6.8.14: the emergency layer. When the + * selected NPC is in an emergency the schedule + * table below shows the synthetic hospital/home + * tiling; say so LOUDLY here, with the reason + * and the live health (the discharge input). */ + if (pop->hasActiveEmergency(selId)) { + const TownSchedule::NpcSchedule *em = + pop->emergencyScheduleOf(selId); + ImGui::PushStyleColor( + ImGuiCol_Text, + IM_COL32(220, 80, 80, 255)); + ImGui::TextUnformatted("EMERGENCY"); + ImGui::PopStyleColor(); + if (!em || em->entries.empty()) + ImGui::TextDisabled( + " (schedule pending)"); + if (em && !em->entries.empty()) { + ImGui::Text(" activity: %s", + em->entries[0] + .activity + .c_str()); + ImGui::Text(" at: %s", + em->entries[0] + .location + .c_str()); + } + auto hit = + CharacterStatStore::getInstance() + .snapshot(selId); + auto hv = hit.find("health"); + ImGui::Text(" health: %.1f", + hv == hit.end() ? + 0.0 : + hv->second); + } /* Phase 4.7.5: symbolic motion state + position. */ if (motion) { NpcMotionSystem::MotionInfo mi; @@ -528,6 +564,31 @@ void NpcScheduleDebugOverlay::render() .c_str(), top[k].second); } + /* 6.8.7: the area's special-job + * rosters compactly listed. */ + const TownRosters::Rosters + *rosters = + pop->areaRosters( + areaIdxW); + if (rosters && + !rosters->empty()) { + ImGui::TextDisabled( + "special rosters:"); + for (const auto &kv : + *rosters) + ImGui::TextDisabled( + " %s: %d " + "member%s", + kv.first + .c_str(), + (int)kv.second + .size(), + kv.second + .size() == + 1 ? + "" : + "s"); + } } } ImGui::Separator(); diff --git a/src/features/editScene/systems/NpcTaxiSystem.cpp b/src/features/editScene/systems/NpcTaxiSystem.cpp index df8f138..52b0d5a 100644 --- a/src/features/editScene/systems/NpcTaxiSystem.cpp +++ b/src/features/editScene/systems/NpcTaxiSystem.cpp @@ -140,6 +140,10 @@ bool NpcTaxiSystem::assignTaxiGoal(uint64_t registryId, if (traffic && (traffic->isDriving(registryId) || traffic->isSeated(registryId))) return false; + /* 6.8.14: an active emergency is the highest-priority goal — + * scripted taxi goals are refused while it lasts. */ + if (pop->hasActiveEmergency(registryId)) + return false; int areaIdx = pop->areaIndexOf(registryId); if (areaIdx < 0 || !findLocation(areaIdx, destLocation)) return false; diff --git a/src/features/editScene/systems/NpcTrafficSystem.cpp b/src/features/editScene/systems/NpcTrafficSystem.cpp index f72cdf8..aee1365 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.cpp +++ b/src/features/editScene/systems/NpcTrafficSystem.cpp @@ -298,11 +298,17 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor( } std::vector curbs = locs.indicesOfKind(TownLocations::LocationKind::Curb); + float capSq = parkFallbackRadiusM > 0.0f ? + parkFallbackRadiusM * + parkFallbackRadiusM : + FLT_MAX; float bestD = FLT_MAX; for (int i : bays) { const TownLocations::NpcLocation &p = locs.locations[i]; float d = (p.position - loc->position).squaredLength(); + if (d > capSq) + continue; if (d < bestD - 1e-4f) { bestD = d; out.loc = &p; @@ -315,6 +321,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor( locs.locations[i]; float d = (p.position - loc->position) .squaredLength(); + if (d > capSq) + continue; if (d < bestD - 1e-4f) { bestD = d; out.loc = &p; @@ -329,6 +337,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor( float d = (p.position - loc->position) .squaredLength(); + if (d > capSq) + continue; if (d < bestD - 1e-4f) { bestD = d; out.loc = &p; @@ -341,7 +351,12 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor( /* 6.6.3: nearest FREE Parking slot first ... overflow spots * ("parking-ovf") come later: they are the driveway-mouth - * reserve, not designated spaces (6.6.6). */ + * reserve, not designated spaces (6.6.6). 6.8.5: the tiers + * stay within the venue's fallback radius (a full frontage + * falls to the venue's own curb, not another street's bay). */ + float capSq = parkFallbackRadiusM > 0.0f ? + parkFallbackRadiusM * parkFallbackRadiusM : + FLT_MAX; float bestD = FLT_MAX; for (int i : locs.indicesOfKind(TownLocations::LocationKind::Parking)) { @@ -354,6 +369,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor( if (!slotReachable(car.areaIdx, p, graph)) continue; float d = (p.position - loc->position).squaredLength(); + if (d > capSq) + continue; if (d < bestD - 1e-4f) { bestD = d; out.loc = &p; @@ -376,6 +393,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor( if (!slotReachable(car.areaIdx, p, graph)) continue; float d = (p.position - loc->position).squaredLength(); + if (d > capSq) + continue; if (d < bestD - 1e-4f) { bestD = d; out.loc = &p; @@ -393,6 +412,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor( if (k >= curbChainMaxSpots) continue; float d = (p.position - loc->position).squaredLength(); + if (d > capSq) + continue; if (d < bestD - 1e-4f) { bestD = d; out.loc = &p; @@ -450,8 +471,12 @@ void NpcTrafficSystem::rebuildClaims(double tabs, float tod, std::vector evs; for (auto &kv : m_cars) { TrafficCar &car = kv.second; + /* 6.8.4b: the day-arc is the week's day-of-week tile + * when the area has a weekly build; 6.8.14: an active + * emergency supersedes it. */ + int weekday = (int)std::floor(dayStart / 24.0) % 7; const TownSchedule::NpcSchedule *sched = - pop->scheduleOf(car.driverId); + pop->effectiveDayScheduleOf(car.driverId, weekday); const TownLocations::LocationSet *locs = pop->areaLocations(car.areaIdx); if (!sched || !locs || car.homeLocation.empty() || @@ -693,6 +718,16 @@ const NpcTrafficSystem::LegRoute *NpcTrafficSystem::legRoute( return ins.first->second.unusable ? nullptr : &ins.first->second; } +bool NpcTrafficSystem::despawnLiveCar(uint64_t driverId) +{ + auto it = m_cars.find(driverId); + if (it == m_cars.end() || !it->second.highDetail) + return false; + despawnHighDetail(it->second); + it->second.dwell = 0.0f; + return true; +} + void NpcTrafficSystem::setSuspended(TrafficCar &car, bool susp) { if (car.suspended == susp) @@ -709,8 +744,13 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod, NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); NpcMotionSystem *motion = NpcMotionSystem::getInstance(); CharacterRegistry ® = CharacterRegistry::getSingleton(); + /* 6.8.4b: the day-arc is the week's day-of-week tile when the + * area has a weekly build; 6.8.14: an active emergency + * supersedes it. */ + int weekday = (int)std::floor(tabs / 24.0) % 7; const TownSchedule::NpcSchedule *sched = - pop ? pop->scheduleOf(car.driverId) : nullptr; + pop ? pop->effectiveDayScheduleOf(car.driverId, weekday) : + nullptr; const TownLocations::LocationSet *locs = pop ? pop->areaLocations(car.areaIdx) : nullptr; CharacterRegistry::CharacterRecord *rec = diff --git a/src/features/editScene/systems/NpcTrafficSystem.hpp b/src/features/editScene/systems/NpcTrafficSystem.hpp index 9e07837..30959fd 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.hpp +++ b/src/features/editScene/systems/NpcTrafficSystem.hpp @@ -158,6 +158,11 @@ public: { return m_cars.size(); } + /** 6.8.14: despawn a car's live tier (emergency supersede — + * the symbolic layer stays the ground truth and the next + * evaluation re-syncs the car). False when the car isn't + * high-detail. */ + bool despawnLiveCar(uint64_t driverId); /** Cars currently in the high-detail tier (benchmark telemetry). */ size_t activeVehicleCount() const { @@ -255,6 +260,13 @@ public: int maxActiveVehicles = 8; /** Driving speed (m/s); also the symbolic tier's advance rate. */ float cruiseSpeed = 11.0f; + /** 6.8.5: claim-time fallback radius — a visitor slot pick + * (free bays / overflow / curb chain) prefers spots within + * this distance of the venue; farther candidates are + * rejected so a full frontage falls to the venue's own curb + * instead of routing a 140-370 m detour to another street's + * bay. 0 = unlimited (the pre-6.8.5 behavior). */ + float parkFallbackRadiusM = 80.0f; /** Walk-vs-drive threshold (meters, straight-line): longer legs * are driven when the car is with the driver (phase 6.A). */ float maxWalkDistanceM = 300.0f; diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 9ddd162..f8c766a 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -2651,7 +2651,9 @@ nlohmann::json SceneSerializer::serializeCharacterSpawner(flecs::entity entity) auto &spawner = entity.get(); nlohmann::json json; json["registryId"] = spawner.registryId; - json["spawnDistance"] = std::sqrt(spawner.spawnDistanceSq); + json["spawnDistance"] = spawner.spawnDistanceSq < 0.0f ? + -1.0f : + std::sqrt(spawner.spawnDistanceSq); json["despawnDistance"] = std::sqrt(spawner.despawnDistanceSq); return json; } @@ -2663,7 +2665,13 @@ void SceneSerializer::deserializeCharacterSpawner(flecs::entity entity, spawner.registryId = json.value("registryId", 0); float spawnDist = json.value("spawnDistance", 100.0f); float despawnDist = json.value("despawnDistance", 200.0f); - spawner.spawnDistanceSq = spawnDist * spawnDist; + /* The sentinel (-1) must round-trip exactly: high-detail + * spawners use spawnDistanceSq = -1 to disable distance + * gating; a naive sqrt(-1) writes JSON null and the reload + * then throws type_error. */ + spawner.spawnDistanceSq = spawnDist < 0.0f ? + spawnDist : + spawnDist * spawnDist; spawner.despawnDistanceSq = despawnDist * despawnDist; entity.set(spawner); } diff --git a/src/features/editScene/systems/ScheduleScoring.cpp b/src/features/editScene/systems/ScheduleScoring.cpp index fd61b37..80739db 100644 --- a/src/features/editScene/systems/ScheduleScoring.cpp +++ b/src/features/editScene/systems/ScheduleScoring.cpp @@ -27,10 +27,12 @@ double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, wc.distanceMeters = candidate.distanceMeters; wc.usage = candidate.usage; wc.effects = candidate.effects; + wc.capacity = candidate.capacity; TownScheduleScore::Weights ww; ww.usage = weights.usage; ww.distance = weights.distance; ww.jitter = weights.jitter; + ww.capacity = weights.capacity; return TownScheduleScore::defaultScore(wc, snapshot, ww); } diff --git a/src/features/editScene/systems/ScheduleScoring.hpp b/src/features/editScene/systems/ScheduleScoring.hpp index ea9726c..11a8625 100644 --- a/src/features/editScene/systems/ScheduleScoring.hpp +++ b/src/features/editScene/systems/ScheduleScoring.hpp @@ -42,15 +42,21 @@ struct Candidate { int usage = 0; /** Stat effects offered per in-game hour (e.g. money=+10). */ StatsSnapshot effects; + /** Soft capacity (6.8.5): picks past it sink the score; huge + * default = unlimited. */ + double capacity = 1.0e9; }; struct Weights { double usage = 1.0; double distance = 0.01; double jitter = 0.001; + /** Score penalty per pick above the soft capacity (6.8.5). */ + double capacity = 1.0; }; -/** The pure default: -(usage*w + distance*w) + hashJitter*w. */ +/** The pure default: -(usage*w + distance*w + overflow*w) + + * hashJitter*w. */ double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, const Weights &weights = Weights{}); diff --git a/src/features/editScene/systems/StatEffectsApplier.cpp b/src/features/editScene/systems/StatEffectsApplier.cpp new file mode 100644 index 0000000..55a1e7a --- /dev/null +++ b/src/features/editScene/systems/StatEffectsApplier.cpp @@ -0,0 +1,19 @@ +#include "StatEffectsApplier.hpp" + +namespace StatEffectsApplier { + +void applyLocationEffects(CharacterStatStore &store, + uint64_t registryId, + const TownLocations::NpcLocation &loc, + float hours) +{ + if (hours <= 0.0f || loc.statEffects.empty()) + return; + for (const auto &kv : loc.statEffects) { + if (kv.second != 0.0) + store.add(registryId, kv.first, + kv.second * (double)hours); + } +} + +} // namespace StatEffectsApplier diff --git a/src/features/editScene/systems/StatEffectsApplier.hpp b/src/features/editScene/systems/StatEffectsApplier.hpp new file mode 100644 index 0000000..54dd654 --- /dev/null +++ b/src/features/editScene/systems/StatEffectsApplier.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "CharacterStatStore.hpp" +#include "../roadlib/TownLocations.hpp" + +/** + * Apply a location's declared stat effects on a COMPLETED schedule + * entry (phase 6.8.13). Effects are data on the POI (NpcLocation + * ::statEffects) and are applied per in-game hour the NPC spent at + * the entry, exactly once per completed entry — the POI itself + * never mutates stats. + * + * The home entry is idle rest: a home POI with a weaker + * stress-recovery effect applies the same way (no activation and + * no behavior trees are required). Job POIs pay money and raise + * stress, leisure POIs relieve stress (often for a money cost). + */ +namespace StatEffectsApplier { + +void applyLocationEffects(CharacterStatStore &store, + uint64_t registryId, + const TownLocations::NpcLocation &loc, + float hours); + +} // namespace StatEffectsApplier diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index 2a47f1f..9de59b8 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -19,12 +19,15 @@ #include "../components/RoadGraph.hpp" #include "../physics/physics.h" #include "../roadlib/RoadNetworkGen.hpp" +#include "../components/Poi.hpp" +#include "../components/RuntimeMarker.hpp" #include "../roadlib/RoadPath.hpp" #include #include #include +#include #include #include #include @@ -1242,6 +1245,124 @@ void TownGenSystem::createPlanRooms(flecs::entity building, } } +void TownGenSystem::createPlanPois(flecs::entity building, + const TownGen::TownBuilding &b, + const CellGridComponent &grid) +{ + const LayoutLib::BuildingPlan &plan = b.plan; + if (plan.pois.empty()) + return; + const std::vector bounds = + LayoutLib::planRoomBounds(plan); + + for (const LayoutLib::PlanPoi &pd : plan.pois) { + /* The POI exists on the room instance whose floor + * matches (the first instance when the decl left + * :level at -1). */ + const LayoutLib::PlanRoomBounds *rb = nullptr; + for (const LayoutLib::PlanRoomBounds &r : bounds) + if (plan.roomNames[r.roomId] == pd.room && + (pd.level < 0 || pd.level == r.floor)) { + rb = &r; + break; + } + if (!rb) { + Ogre::LogManager::getSingleton().logMessage( + "TownGenSystem: POI '" + pd.name + + "' in room '" + pd.room + + "' has no matching room instance (floor " + + std::to_string(pd.level < 0 ? 0 : pd.level) + + "); skipped"); + continue; + } + + /* The position: footprint fractions of the SHELL + * (defaults to the owning room's rect center), snapped + * into the room's rect, at the cell center of the + * POI's floor. The building node's local space is + * the cell grid (cellToWorld: x*cellSize, floors at + * y*cellHeight). */ + int cx = pd.atX >= 0.0f ? + std::clamp((int)std::lround( + (double)pd.atX * + plan.cellsX), + rb->minX, rb->maxX) : + (rb->minX + rb->maxX) / 2; + int cz = pd.atZ >= 0.0f ? + std::clamp((int)std::lround( + (double)pd.atZ * + plan.cellsZ), + rb->minZ, rb->maxZ) : + (rb->minZ + rb->maxZ) / 2; + + flecs::entity poiEnt = m_world.entity(); + poiEnt.child_of(building); + const std::string &roomName = + plan.roomNames[rb->roomId]; + poiEnt.set(EntityNameComponent( + "poi " + pd.name + " " + roomName + + (rb->floor > 0 ? + " f" + std::to_string(rb->floor) : + ""))); + poiEnt.add(); + + /* The entity parents to the building (rebuild lifetime, + * town streaming), but its scene node hangs at the ROOT + * at the derived world-render position: 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 instead. */ + TransformComponent xform; + xform.node = m_sceneMgr->getRootSceneNode() + ->createChildSceneNode(); + Ogre::Vector3 local((cx + 0.5f) * grid.cellSize, + rb->floor * grid.cellHeight + 0.05f, + (cz + 0.5f) * grid.cellSize); + Ogre::Vector3 shellCenter( + (grid.width - 1) * grid.cellSize * 0.5f, + 0.0f, + (grid.depth - 1) * grid.cellSize * 0.5f); + Ogre::Quaternion yaw(Ogre::Radian(b.yaw), + Ogre::Vector3::UNIT_Y); + Ogre::Vector3 renderPos = + (b.center - yaw * shellCenter) + yaw * local; + xform.position = renderPos; + xform.rotation = Ogre::Quaternion::IDENTITY; + xform.scale = Ogre::Vector3::UNIT_SCALE; + TerrainSystem *ts2 = m_terrainSystem ? m_terrainSystem : + TerrainSystem::getInstance(); + if (ts2) { + xform.worldX = ts2->renderToWorldX(renderPos.x); + xform.worldY = ts2->renderToWorldY(renderPos.y); + xform.worldZ = ts2->renderToWorldZ(renderPos.z); + xform.hasWorldPosition = true; + } + xform.applyToNode(); + poiEnt.set(xform); + + PoiComponent pc; + pc.poiId = "poi." + grid.gridUid + "." + roomName + + (rb->floor > 0 ? + "-f" + std::to_string(rb->floor) : + "") + + "." + pd.name; + pc.kind = pd.kind; + pc.tags = pd.tags; + pc.entranceRole = pd.entranceRole; + pc.openHour = pd.openHour; + pc.closeHour = pd.closeHour; + for (const auto &se : pd.statEffects) + pc.statEffects[se.first] = se.second; + pc.activationKind = pd.activation; + pc.behaviorTree = pd.behaviorTree; + pc.luaScript = pd.luaScript; + pc.radius = pd.radius; + pc.cooldownSeconds = pd.cooldownSeconds; + pc.once = pd.once; + poiEnt.set(pc); + } +} + void TownGenSystem::createBuilding(flecs::entity container, flecs::entity material, flecs::entity texture, const TownGen::TownBuilding &b, @@ -1303,6 +1424,12 @@ void TownGenSystem::createBuilding(flecs::entity container, if (genMode != "exteriorOnly") createPlanRooms(building, b, building.get()); + /* Phase 6.8.6: the plan's DSL-authored interior POIs become + * runtime PoiComponent children (interior-aware grids only — + * an exteriorOnly shell has no rooms to put them in). */ + if (genMode != "exteriorOnly") + createPlanPois(building, b, building.get()); + /* Roofs from the layout plan; when the plan defines none (the * district defaults don't), fall back to the edge-side roofType * override or a flat slab over the whole footprint. diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp index 5bc2db6..2cea9a2 100644 --- a/src/features/editScene/systems/TownGenSystem.hpp +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -228,6 +228,16 @@ private: const TownGen::TownBuilding &b, const CellGridComponent &grid); + /** Materialize the plan's DSL-authored POIs (6.8.6) as runtime + * PoiComponent children of the building (interior-aware grids + * only). Ids are `poi..[-fN].`, so a + * rebuild keeps them; the mechanical side (E prompt, proximity + * firing, the POI database merge) is the shared PoiComponent + * machinery. */ + void createPlanPois(flecs::entity building, + const TownGen::TownBuilding &b, + const CellGridComponent &grid); + /** Full synchronous teardown of a generated entity and its * children (regen/clear used to bare-destruct, leaking physics * bodies, TriangleBuffer meshes and scene nodes — the "remains diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index ffbdac7..8a6ed18 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -43,6 +43,9 @@ #include "roadlib/TownLocations.hpp" #include "roadlib/PoiDatabase.hpp" #include "roadlib/TownSchedule.hpp" +#include "roadlib/TownRostersWeeks.hpp" +#include "systems/CharacterStatStore.hpp" +#include "systems/StatEffectsApplier.hpp" #include #include @@ -4568,6 +4571,189 @@ static bool runTownGenProbe() } } + /* Phase 6.8.6: the :poi room option declares interior + * POIs on the plan — planner data + activation spec, + * declaration order kept. */ + { + BuildingPlan pPoi; + LayoutEval evPoi(lb, &rngDsl, nullptr); + if (!evPoi.evaluate( + "(shell 16 8 :empty)" + "(floor 0" + " (room shopfront (rect 0 0 0.5 1)" + " :type store" + " :poi (" + " (counter :kind work :tags (work counter)" + " :effect (money 2) :effect (stress -1)" + " :activation approach" + " :behavior work_counter" + " :at (0.25 0.5) :radius 2" + " :cooldown 10 :once)" + " (doorway :kind entrance :entrance front" + " :activation arrive :lua door_welcome.lua" + " :hours (9 18) :level 0)))" + " (room stockroom (rect 0.5 0 1 1)" + " :poi ((shelf :activation manual :behavior restock))))", + pPoi, &derr)) { + printf(" *** dsl poi eval: %s ***\n", + derr.c_str()); + ok = false; + } else if (pPoi.pois.size() != 3) { + printf(" *** dsl poi count %zu ***\n", + pPoi.pois.size()); + ok = false; + } else { + const PlanPoi &counter = pPoi.pois[0]; + if (counter.room != "shopfront" || + counter.name != "counter" || + counter.kind != "work" || + counter.tags != + std::vector{ + "work", "counter" } || + counter.statEffects.size() != 2 || + counter.statEffects[0] != + std::pair{ + "money", 2.0 } || + counter.statEffects[1].second != -1.0 || + counter.activation != 2 || + counter.behaviorTree != "work_counter" || + !counter.luaScript.empty() || + std::fabs(counter.atX - 0.25f) > 1e-4f || + std::fabs(counter.atZ - 0.5f) > 1e-4f || + counter.radius != 2.0f || + counter.cooldownSeconds != 10.0f || + !counter.once) { + printf(" *** dsl poi counter " + "fields wrong: room='%s' " + "kind='%s' tags=%zu eff=%zu " + "act=%d beh='%s' lua='%s' " + "at=%g,%g r=%g cd=%g once=%d" + " ***\n", + counter.room.c_str(), + counter.kind.c_str(), + counter.tags.size(), + counter.statEffects.size(), + counter.activation, + counter.behaviorTree.c_str(), + counter.luaScript.c_str(), + (double)counter.atX, + (double)counter.atZ, + (double)counter.radius, + (double) + counter.cooldownSeconds, + (int)counter.once); + ok = false; + } + const PlanPoi &doorway = pPoi.pois[1]; + if (doorway.name != "doorway" || + doorway.kind != "entrance" || + doorway.entranceRole != "front" || + doorway.activation != 3 || + doorway.luaScript != + "door_welcome.lua" || + !doorway.behaviorTree.empty() || + doorway.level != 0 || + std::fabs(doorway.openHour - 9.0f) > + 1e-4f || + std::fabs(doorway.closeHour - 18.0f) > + 1e-4f) { + printf(" *** dsl poi doorway fields " + "wrong (behavior '%s') ***\n", + doorway.behaviorTree.c_str()); + ok = false; + } + const PlanPoi &shelf = pPoi.pois[2]; + if (shelf.name != "shelf" || + shelf.kind != "interaction" || + shelf.activation != 1 || + shelf.behaviorTree != "restock" || + shelf.tags.empty() == false || + shelf.level != -1) { + printf(" *** dsl poi shelf fields " + "wrong ***\n"); + ok = false; + } + } + + /* Re-declaring a room name (another floor): its + * first :poi list wins, like the room type. */ + BuildingPlan pRedecl; + LayoutEval evRedecl(lb, &rngDsl, nullptr); + if (!evRedecl.evaluate( + "(shell 16 16 :floors 2)" + "(floor 0 (room shopfront (rect 0 0 1 0.5)" + " :poi ((a :activation arrive :lua" + " a.lua))))" + "(floor 1 (room shopfront (rect 0 0.5 1 1)" + " :poi ((b :activation arrive :lua" + " b.lua))))", + pRedecl, &derr)) { + printf(" *** dsl poi redecl eval: %s ***\n", + derr.c_str()); + ok = false; + } else if (pRedecl.pois.size() != 1 || + pRedecl.pois[0].name != "a") { + printf(" *** dsl poi redecl kept %zu pois " + "***\n", pRedecl.pois.size()); + ok = false; + } + + /* Macros emit : poi decls like any other form. */ + BuildingPlan pMacro; + LayoutEval evMacro(lb, &rngDsl, nullptr); + if (!evMacro.evaluate( + "(defmacro shop-poi (name behavior)" + " (room shopfront (rect 0 0 0.5 1)" + " :poi ((name :behavior behavior))))" + "(shell 16 8 :empty)" + "(shop-poi counter work_counter)", + pMacro, &derr)) { + printf(" *** dsl poi macro eval: %s" + " ***\n", derr.c_str()); + ok = false; + } else if (pMacro.pois.size() != 1 || + pMacro.pois[0].name != "counter" || + pMacro.pois[0].behaviorTree != + "work_counter") { + printf(" *** dsl poi macro fields wrong " + "***\n"); + ok = false; + } + + /* Malformed decls are hard evaluation errors. */ + const char *badPois[] = { + "(shell 8 8 :empty)(floor 0 (room r " + "(rect 0 0 1 1) :poi ((x :bogus 1))))", + "(shell 8 8 :empty)(floor 0 (room r " + "(rect 0 0 1 1) :poi ((x :activation " + "approach))))", + "(shell 8 8 :empty)(floor 0 (room r " + "(rect 0 0 1 1) :poi ((x :activation " + "approach :behavior a :lua b))))", + "(shell 8 8 :empty)(floor 0 (room r " + "(rect 0 0 1 1) :poi ((x :lua x.lua))))", + "(shell 8 8 :empty)(floor 0 (room r " + "(rect 0 0 1 1) :poi ((x :at (1.5 0)))))", + "(shell 8 8 :empty)(floor 0 (room r " + "(rect 0 0 1 1) :poi ((x :entrance north))))", + }; + for (const char *bad : badPois) { + BuildingPlan pBad; + LayoutEval evBad(lb, &rngDsl, nullptr); + if (evBad.evaluate(bad, pBad, &derr)) { + printf(" *** dsl poi accepted bad " + "decl: %.60s ***\n", + bad); + ok = false; + } + } + printf(" passed (dsl poi decls: %zu over %zu " + "fixtures, %zu rejected)\n", + pPoi.pois.size(), 1 + sizeof(badPois) / + sizeof(badPois[0]), + (size_t)6); + } + /* Phase 6.5.F: planRoomBounds derives per-floor room cell * bounds from roomIds — the input of the plan-to- * RoomComponent materialization in TownGenSystem. */ @@ -7104,6 +7290,359 @@ static bool runTownLocationsProbe() * indices / building -> indices index, the binding changes and the * tagless-town compatibility. */ +static bool runReferenceLayoutsProbe() +{ + printf("reference layouts probe (6.8.13)\n"); + bool ok = true; + + LayoutLib::LayoutBindings lb; + lb.lotW = 40.0f; + lb.lotD = 40.0f; + lb.availW = 35.0f; + lb.availD = 35.0f; + lb.minFp = 40.0f; + lb.maxFp = 400.0f; + lb.minFloors = 1; + lb.maxFloors = 2; + std::mt19937 rng(7); + + const char *refShop = + "(shell 16 12 :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)" + " (door front center)" + " (door back center)" + " (windows front))" + "(roof flat 0 0 16 12)" + "(parking (rect-front-center 13 5.2))"; + const char *refPark = + "(shell 14 12 :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 14 12)" + "(parking (rect-front-center 5.2 5.2))"; + const char *refHome = + "(shell 12 10 :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 12 10)" + "(parking (rect-front-center 5.2 5.2))"; + + TownGen::TownResult town; + /* A shop building with a back door declared (6.8.8). */ + town.buildings.resize(3); + town.buildings[0].address = "shop-1"; + town.buildings[0].tags = { "commercial", "shop" }; + town.buildings[0].hasBackDoor = true; + town.buildings[0].backDoorstep = Ogre::Vector3(10, 0, 0); + town.buildings[0].doorstep = Ogre::Vector3(11, 0, 13); + town.buildings[1].address = "park-1"; + town.buildings[1].tags = { "leisure", "park" }; + town.buildings[1].doorstep = Ogre::Vector3(21, 0, 23); + town.buildings[2].address = "home-1"; + town.buildings[2].doorstep = Ogre::Vector3(31, 0, 33); + + { + LayoutLib::LayoutEval eval(lb, &rng, nullptr); + std::string err; + if (!eval.evaluate(refShop, town.buildings[0].plan, &err)) { + printf(" *** shop eval: %s ***\n", err.c_str()); + ok = false; + } + printf(" shop roomStatEffects=%zu types=%zu\n", + town.buildings[0].plan.roomStatEffects.size(), + town.buildings[0].plan.roomTypes.size()); + } + { + LayoutLib::LayoutEval eval(lb, &rng, nullptr); + std::string err; + if (!eval.evaluate(refPark, town.buildings[1].plan, &err)) { + printf(" *** park eval: %s ***\n", err.c_str()); + ok = false; + } + } + { + LayoutLib::LayoutEval eval(lb, &rng, nullptr); + std::string err; + if (!eval.evaluate(refHome, town.buildings[2].plan, &err)) { + printf(" *** home eval: %s ***\n", err.c_str()); + ok = false; + } + } + + TownLocations::LocationSet set = + TownLocations::buildLocations(town, 42, 1.0f); + const TownLocations::NpcLocation *work = + set.findByName("work:shop-1"); + const TownLocations::NpcLocation *leisure = + set.findByName("leisure:park-1"); + const TownLocations::NpcLocation *home = + set.findByName("home:home-1"); + if (!work || !leisure || !home) { + printf(" *** fixture produced no work/leisure/home " + "***\n"); + return false; + } + /* Work: money +5/h, stress +1/h, back-door anchor. */ + { + auto money = work->statEffects.find("money"); + auto stress = work->statEffects.find("stress"); + if (money == work->statEffects.end() || + money->second != 5.0 || + stress == work->statEffects.end() || + stress->second != 1.0) { + printf(" *** shop work effects wrong (money %g " + "stress %g) ***\n", + money == work->statEffects.end() ? + -99.0 : + money->second, + stress == work->statEffects.end() ? + -99.0 : + stress->second); + ok = false; + } + if (work->position != town.buildings[0].backDoorstep || + work->entranceRole != "back") { + printf(" *** shop employee approach not the " + "back door ***\n"); + ok = false; + } + } + /* Leisure: money -2/h, stress -4/h, customer front. */ + { + auto money = leisure->statEffects.find("money"); + auto stress = leisure->statEffects.find("stress"); + if (money == leisure->statEffects.end() || + money->second != -2.0 || + stress == leisure->statEffects.end() || + stress->second != -4.0) { + printf(" *** park leisure effects wrong " + "(money %g stress %g) ***\n", + money == leisure->statEffects.end() ? + -99.0 : + money->second, + stress == leisure->statEffects.end() ? + -99.0 : + stress->second); + ok = false; + } + if (leisure->entranceRole != "front") { + printf(" *** leisure not customer-front ***\n"); + ok = false; + } + } + /* Home: idle rest with a weaker stress-recovery effect. */ + { + auto stress = home->statEffects.find("stress"); + if (stress == home->statEffects.end() || + stress->second != -1.0) { + printf(" *** home rest effect wrong ***\n"); + ok = false; + } + } + + /* Entry POIs per role, with stat effects intact through + * the unified feed/projection. */ + { + PoiDatabase::Set pois = PoiDatabase::feedLocationSet(set); + PoiDatabase::appendEntries(pois, set); + pois.finalize(); + const PoiDatabase::Record *back = + PoiDatabase::entryOfBuildingForRole(pois, "back", 0, + "shop-1"); + if (!back || back->entranceRole != "back") { + printf(" *** back entry missing for ref-shop " + "***\n"); + ok = false; + } + const PoiDatabase::Record *workRec = + pois.findById("work:shop-1"); + if (!workRec || + workRec->statEffects.at("money") != 5.0 || + workRec->statEffects.at("stress") != 1.0) { + printf(" *** unified feed dropped shop effects " + "***\n"); + ok = false; + } + } + + /* The applier: exact per-hour application, clamped and + * idempotent per key. */ + { + CharacterStatStore &store = + CharacterStatStore::getInstance(); + const uint64_t id = 42424242; + StatEffectsApplier::applyLocationEffects(store, id, *work, + 2.0f); + if (store.get(id, "money") != 10.0 || + store.get(id, "stress") != 2.0) { + printf(" *** shop entry effects not applied " + "exactly (money %g stress %g) ***\n", + store.get(id, "money"), + store.get(id, "stress")); + ok = false; + } + StatEffectsApplier::applyLocationEffects(store, id, + *leisure, 1.0f); + if (store.get(id, "money") != 8.0 || + store.get(id, "stress") != -2.0) { + printf(" *** park entry effects not applied " + "exactly (money %g stress %g) ***\n", + store.get(id, "money"), + store.get(id, "stress")); + ok = false; + } + /* Home rest applies the weaker effect. */ + StatEffectsApplier::applyLocationEffects(store, id, *home, + 3.0f); + if (store.get(id, "stress") != -5.0) { + printf(" *** home rest effect not applied " + "(stress %g) ***\n", + store.get(id, "stress")); + ok = false; + } + } + + printf(" %s (tags/entries/effects per layout; application on " + "completed entries)\n", + ok ? "passed" : "FAILURES above"); + return ok; +} + +static bool runEntranceRoleProbe() +{ + printf("town entrance role probe (6.8.8)\n"); + bool ok = true; + + TownGen::TownResult town; + town.buildings.resize(1); + town.buildings[0].address = "shop-1"; + town.buildings[0].tags = { "commercial", "shop" }; + town.buildings[0].doorstep = Ogre::Vector3(11, 0, 13); + /* A declared back door + a declared side door. */ + town.buildings[0].hasBackDoor = true; + town.buildings[0].backDoorstep = Ogre::Vector3(11, 0, 5); + town.buildings[0].hasSideDoor = true; + town.buildings[0].sideDoorstep = Ogre::Vector3(4, 0, 10); + town.pois.push_back({ "shop-1 pickup", + Ogre::Vector3(10, 0, 9), 0, + TownGen::TownPoi::Pickup }); + + TownLocations::LocationSet set = + TownLocations::buildLocations(town, 42, 1.0f); + const TownLocations::NpcLocation *work = + set.findByName("work:shop-1"); + const TownLocations::NpcLocation *leisure = + set.findByName("leisure:shop-1"); + const TownLocations::NpcLocation *home = + set.findByName("home:shop-1"); + if (!work || !leisure || !home) { + printf(" *** fixture produced no work/leisure/home " + "***\n"); + return false; + } + if (work->position != town.buildings[0].backDoorstep || + work->entranceRole != "back") { + printf(" *** employee approach not the back door " + "***\n"); + ok = false; + } + if (leisure->position != town.pois[0].position || + leisure->entranceRole != "front") { + printf(" *** customer approach changed from the front " + "***\n"); + ok = false; + } + if (home->position != town.buildings[0].doorstep) { + printf(" *** home anchor moved ***\n"); + ok = false; + } + + /* The entry POI set: the front entry plus the declared back + * entry. No duplicate ids on re-append. */ + PoiDatabase::Set pois = PoiDatabase::feedLocationSet(set); + int appended = PoiDatabase::appendEntries(pois, set); + if (appended != 2) { + printf(" *** appended %d entries, want 2 (front + " + "back) ***\n", appended); + ok = false; + } + int again = PoiDatabase::appendEntries(pois, set); + if (again != 0) { + printf(" *** re-append duplicated %d entries ***\n", + again); + ok = false; + } + pois.finalize(); + const PoiDatabase::Record *front = + PoiDatabase::entryOfBuilding(pois, 0, "shop-1"); + const PoiDatabase::Record *back = + PoiDatabase::entryOfBuildingForRole(pois, "back", 0, + "shop-1"); + if (!front || front->entranceRole != "front" || + front->position != town.buildings[0].doorstep) { + printf(" *** front entry fields wrong ***\n"); + ok = false; + } + if (!back || back->entranceRole != "back" || + back->position != town.buildings[0].backDoorstep) { + printf(" *** back entry fields wrong ***\n"); + ok = false; + } + /* The interior Work POI's approach goes through the back + * entry; Leisure through the front. */ + const PoiDatabase::Record *workRec = + pois.findById("work:shop-1"); + if (!workRec || + PoiDatabase::approachPosition(pois, *workRec) != + town.buildings[0].backDoorstep) { + printf(" *** Work approach does not resolve to the " + "back entry ***\n"); + ok = false; + } + + /* Single-door building: no back entry, no role changes. */ + TownGen::TownResult plain; + plain.buildings.resize(1); + plain.buildings[0].address = "plain-1"; + plain.buildings[0].tags = { "shop" }; + plain.buildings[0].doorstep = Ogre::Vector3(1, 0, 1); + TownLocations::LocationSet plainSet = + TownLocations::buildLocations(plain, 42, 1.0f); + const TownLocations::NpcLocation *pw = + plainSet.findByName("work:plain-1"); + if (!pw || pw->entranceRole != "front" || + pw->position != plain.buildings[0].doorstep) { + printf(" *** single-door building changed behavior " + "***\n"); + ok = false; + } + PoiDatabase::Set plainPois = + PoiDatabase::feedLocationSet(plainSet); + PoiDatabase::appendEntries(plainPois, plainSet); + plainPois.finalize(); + if (plainPois.findById("entrance:plain-1:back") != nullptr) { + printf(" *** single-door building gained a back " + "entry ***\n"); + ok = false; + } + + printf(" %s (role endpoints differ for back-door buildings; " + "single-door unchanged)\n", + ok ? "passed" : "FAILURES above"); + return ok; +} + static bool runTownTagIndexProbe() { printf("town location tag/index probe\n"); @@ -7369,6 +7908,472 @@ static bool runTownTagIndexProbe() * job resolution by tag, per-day leisure, determinism, no-candidate * fallbacks, hook contract and usage spread. */ +static bool runTownRostersProbe() +{ + printf("town rosters probe\n"); + bool ok = true; + + /* A special-services town: two police stations + an ambulance + * station (the 6.8.7 tags join the commercial zoning, so + * every adult lands a special job). */ + TownLocations::LocationSet set; + auto addLoc = [&](TownLocations::LocationKind kind, + const std::string &name, + const std::vector &tags, + int building, Ogre::Vector3 pos) { + TownLocations::NpcLocation loc; + loc.id = (int)set.locations.size(); + loc.kind = kind; + loc.name = name; + loc.address = name.substr(name.find(':') + 1); + loc.building = building; + loc.position = pos; + loc.tags = tags; + set.locations.push_back(loc); + }; + addLoc(TownLocations::LocationKind::Home, "home:hpd-1", {}, 0, + Ogre::Vector3(30, 0, 0)); + addLoc(TownLocations::LocationKind::Home, "home:hpd-2", {}, 1, + Ogre::Vector3(40, 0, 0)); + addLoc(TownLocations::LocationKind::Home, "home:amb-1", {}, 2, + Ogre::Vector3(50, 0, 0)); + addLoc(TownLocations::LocationKind::Work, + "work:hpd-1", + { "commercial", "police", + TownLocations::jobTagForBuilding({ "commercial", + "police" }) }, + 0, + Ogre::Vector3(30, 0, 6)); + addLoc(TownLocations::LocationKind::Work, + "work:hpd-2", + { "police", + TownLocations::jobTagForBuilding({ "police" }) }, + 1, + Ogre::Vector3(40, 0, 6)); + addLoc(TownLocations::LocationKind::Work, + "work:amb-1", + { "ambulance", + TownLocations::jobTagForBuilding({ "ambulance" }) }, + 2, + Ogre::Vector3(50, 0, 6)); + + TownPopulation::Population pop; + pop.households.resize(3); + pop.households[0].address = "hpd-1"; + pop.households[1].address = "hpd-2"; + pop.households[2].address = "amb-1"; + for (int i = 0; i < 3; ++i) { + TownPopulation::PopMember pm; + pm.registryId = 44001 + (uint64_t)i; + pm.stableKey = "roster-" + std::to_string((char)('a' + i)); + pm.household = (size_t)i; + pm.age = "adult"; + pop.members.push_back(pm); + } + + /* Rules default: all locations open, unlimited capacity (the + * special buildings get the same zoning treatment). */ + std::vector weeks = + TownSchedule::generateWeekSchedules( + pop, set, 42, TownSchedule::WeekScheduleRules()); + if (weeks.size() != 3) { + printf(" *** weeks %zu, want 3 ***\n", weeks.size()); + ok = false; + } + + /* The roster derivation (6.8.7): the weekly build flattens + * into member summaries, then the derivation slices the + * special ones. */ + TownRosters::Rosters rosters = TownRosters::deriveRosters( + TownRosters::weekJobMembers(weeks)); + size_t rosterSizeSum = 0; + for (const auto &kv : rosters) + rosterSizeSum += kv.second.size(); + if (rosterSizeSum != 3) { + printf(" *** roster size %zu, want 3 (with no " + "non-special Work locations every adult must " + "land a special job) ***\n", rosterSizeSum); + ok = false; + } + /* Only special tags, registry-id order inside each roster. */ + for (const auto &kv : rosters) { + if (!TownRosters::isSpecialJobTag(kv.first)) { + printf(" *** roster tag '%s' is not special " + "***\n", kv.first.c_str()); + ok = false; + } + for (size_t i = 1; i < kv.second.size(); ++i) + if (kv.second[i].registryId <= + kv.second[i - 1].registryId) { + printf(" *** roster '%s' ordering " + "broken ***\n", kv.first.c_str()); + ok = false; + } + } + /* Home/work bindings are real names. */ + for (const auto &kv : rosters) + for (const TownRosters::RosterMember &m : kv.second) { + if (!set.findByName(m.homeLocation) || + !set.findByName(m.workLocation) || + m.homeLocation.rfind("home:", 0) != 0 || + m.workLocation.rfind("work:", 0) != 0) { + printf(" *** roster '%s' member " + "bindings broken (home '%s' work " + "'%s') ***\n", kv.first.c_str(), + m.homeLocation.c_str(), + m.workLocation.c_str()); + ok = false; + } + } + /* Determinism: the same build re-derives the same rosters. */ + TownRosters::Rosters again = TownRosters::deriveRosters( + TownRosters::weekJobMembers(weeks)); + bool same = again.size() == rosters.size(); + if (same) { + auto a = again.begin(); + auto b = rosters.begin(); + for (; same && a != again.end(); ++a, ++b) { + same = a->first == b->first && + a->second.size() == b->second.size(); + for (size_t i = 0; same && i < a->second.size(); + ++i) + same = a->second[i].registryId == + b->second[i].registryId && + a->second[i].workLocation == + b->second[i].workLocation; + } + } + if (!same) { + printf(" *** roster derivation not deterministic " + "***\n"); + ok = false; + } + /* Filtered derivation: asking for an unrepresented tag keeps + * the others out. */ + TownRosters::Rosters filtered = + TownRosters::deriveRosters( + TownRosters::weekJobMembers(weeks), + { "firefighter" }); + if (!filtered.empty()) { + printf(" *** filtered derivation returned %zu rows " + "***\n", filtered.size()); + ok = false; + } + printf(" %s (%zu rosters, %zu members)\n", + ok ? "passed" : "FAILURES above", rosters.size(), + rosterSizeSum); + return ok; +} + +static bool runWeekVarietyProbe() +{ + printf("town week variety + revision probe (6.8.10)\n"); + bool ok = true; + + /* Fixture town: two Work venues and a Leisure venue; the + * members get explicit stat snapshots. */ + TownLocations::LocationSet set; + auto addLoc = [&](TownLocations::LocationKind kind, + const std::string &name, + const std::vector &tags, + Ogre::Vector3 pos, + std::unordered_map effects = + {}) { + TownLocations::NpcLocation loc; + loc.id = (int)set.locations.size(); + loc.kind = kind; + loc.name = name; + loc.address = name.substr(name.find(':') + 1); + loc.position = pos; + loc.tags = tags; + loc.statEffects = std::move(effects); + set.locations.push_back(loc); + }; + addLoc(TownLocations::LocationKind::Home, "home:house-1", {}, + Ogre::Vector3(10, 0, 0)); + addLoc(TownLocations::LocationKind::Work, "work:shop-1", + { "shop", "salesman" }, Ogre::Vector3(50, 0, 0), + { { "money", 4.0 } }); + addLoc(TownLocations::LocationKind::Work, "work:shop-2", + { "shop", "salesman" }, Ogre::Vector3(60, 0, 0), + { { "stress", -2.0 } }); + addLoc(TownLocations::LocationKind::Leisure, "leisure:park-1", + { "leisure" }, Ogre::Vector3(80, 0, 0), + { { "stress", -4.0 } }); + + TownPopulation::Population pop; + pop.households.resize(1); + pop.households[0].address = "house-1"; + auto member = [&](uint64_t id, const char *key) { + TownPopulation::PopMember pm; + pm.registryId = id; + pm.stableKey = key; + pm.household = 0; + pm.age = "adult"; + pop.members.push_back(pm); + }; + member(70001, "alpha"); + member(70002, "bravo"); + member(70003, "charlie"); + member(70004, "delta"); + + TownSchedule::WeekScheduleRules rules; + std::vector baseline = + TownSchedule::generateWeekSchedules(pop, set, 42, rules); + + /* The empty-snapshot build must be BIT-IDENTICAL to the + * baseline (defaults inert). */ + { + auto same = TownSchedule::generateWeekSchedules(pop, set, 42, + rules); + if (same.size() != baseline.size()) { + printf(" *** baseline size mismatch ***\n"); + ok = false; + } + for (size_t i = 0; i < baseline.size(); ++i) + if (same[i].entries != baseline[i].entries) { + printf(" *** default stats changed the " + "baseline week ***\n"); + ok = false; + break; + } + } + + /* health = 10: exactly one home-only rest day at the hashed + * day index; the rest of the week keeps its shape. */ + { + std::unordered_map + stats; + stats[70001] = { { "health", 10.0 } }; + auto weeks = TownSchedule::generateWeekSchedules(pop, set, 42, + rules, stats); + const TownSchedule::WeekSchedule *ws = nullptr; + for (const auto &w : weeks) + if (w.stableKey == "alpha") + ws = &w; + if (!ws) { + printf(" *** alpha missing ***\n"); + ok = false; + } else { + const std::string wantKey = std::string("alpha") + + "|health-rest"; + uint32_t h = 2166136261u; + for (char c : wantKey) { + h ^= (unsigned char)c; + h *= 16777619u; + } + int restDay = (int)(h % 7); + for (int day = 0; day < 7; ++day) { + bool workOrLeisure = false; + for (const auto &e : ws->entries) + if (e.day == day && + (e.activity == "work" || + e.activity == "leisure")) + workOrLeisure = true; + if (day == restDay) { + if (workOrLeisure) { + printf(" *** health rest " + "day %d still has " + "work/leisure ***\n", + day); + ok = false; + } + } else if (workOrLeisure == false && + (day < 5)) { + /* Non-rest workdays must keep + * the job. */ + bool hasWork = false; + for (const auto &e : ws->entries) + if (e.day == day && + e.activity == "work") + hasWork = true; + if (!hasWork) { + printf(" *** health rest " + "day leaked into " + "workday %d ***\n", + day); + ok = false; + } + } + } + } + } + + /* money = 5: a weekend WORK entry at the sticky venue + * (extra job block for poor NPCs). */ + { + std::unordered_map + stats; + stats[70002] = { { "money", 5.0 } }; + auto weeks = TownSchedule::generateWeekSchedules(pop, set, 42, + rules, stats); + bool weekendWork = false; + for (const auto &w : weeks) + if (w.stableKey == "bravo") + for (const auto &e : w.entries) + if ((e.day == 5 || e.day == 6) && + e.activity == "work") + weekendWork = true; + if (!weekendWork) { + printf(" *** money=5 produced no weekend " + "job shift ***\n"); + ok = false; + } + } + + /* stress = 90: a deterministic home-rest day exists. */ + { + std::unordered_map + stats; + stats[70003] = { { "stress", 90.0 } }; + auto weeks = TownSchedule::generateWeekSchedules(pop, set, 42, + rules, stats); + bool restDaySeen = false; + for (const auto &w : weeks) + if (w.stableKey == "charlie") { + int restDays = 0; + for (int day = 0; day < 7; ++day) { + bool anyWork = false; + for (const auto &e : w.entries) + if (e.day == day && + (e.activity == "work" || + e.activity == "leisure")) + anyWork = true; + if (!anyWork) + ++restDays; + } + if (restDays == 0) + printf(" *** stress=90 gave no " + "home-rest day ***\n"); + else + restDaySeen = true; + } + if (!restDaySeen) { + printf(" *** stress=90 rest check failed " + "***\n"); + ok = false; + } + } + + /* The delta week (no snapshot) is identical to baseline. */ + { + const auto &base = baseline.back(); + if (base.stableKey != "delta") + printf(" *** delta ordering assumption ***\n"); + } + + /* Stat effects ride the candidate into the score hook. */ + { + std::vector moneySeen, stressSeen; + TownSchedule::ScoreHook hook = [&]( + const TownSchedule::WeekCandidate &c, + const TownSchedule::StatsSnapshot &) { + for (const auto &se : c.effects) { + if (se.first == "money") + moneySeen.push_back(c.name); + if (se.first == "stress") + stressSeen.push_back(c.name); + } + return TownScheduleScore::defaultScore( + c, TownScheduleScore::StatsSnapshot()); + }; + auto weeks = TownSchedule::generateWeekSchedules(pop, set, 42, + rules, {}, hook); + (void)weeks; + if (moneySeen.empty() || stressSeen.empty()) { + printf(" *** score hook never saw stat " + "effects (money %zu, stress %zu) ***\n", + moneySeen.size(), stressSeen.size()); + ok = false; + } + } + + /* Revision splice: the in-flight entry is kept WHOLE and the + * rest of the week comes from the fresh build. */ + { + TownSchedule::WeekSchedule stored; + stored.registryId = 1; + stored.stableKey = "splice"; + auto push = [&](TownSchedule::WeekSchedule &w, int day, + float a, float b, const char *loc, + const char *act) { + TownSchedule::WeekEntry e; + e.day = day; + e.startHour = a; + e.endHour = b; + e.location = loc; + e.activity = act; + w.entries.push_back(e); + }; + for (int day = 0; day < 7; ++day) { + push(stored, day, 0, 6, "home:x", "sleep"); + push(stored, day, 9, 17, "work:old", "work"); + push(stored, day, 17, 22, "home:x", "home"); + push(stored, day, 22, 24, "home:x", "sleep"); + } + TownSchedule::WeekSchedule fresh = stored; + fresh.entries.clear(); + for (int day = 0; day < 7; ++day) { + push(fresh, day, 0, 6, "home:x", "sleep"); + push(fresh, day, 9, 17, "work:new", "work"); + push(fresh, day, 17, 22, "home:x", "home"); + push(fresh, day, 22, 24, "home:x", "sleep"); + } + const double tabs = 2 * 24.0 + 13.5; + auto merged = TownSchedule::spliceWeekAfter(stored, fresh, + tabs); + /* Days 0/1 unchanged; day 2 keeps the OLD work entry + * (in-flight) and takes the FRESH home/sleep tail; + * days 3+ are fully fresh. */ + for (const auto &e : merged.entries) { + if (e.day < 2) { + if (e.location != "work:old" && + e.activity == "work") { + printf(" *** past day %d got " + "rewritten ***\n", e.day); + ok = false; + } + } + if (e.day == 2 && e.activity == "work" && + e.location != "work:old") { + printf(" *** in-flight work entry " + "replaced (got %s) ***\n", + e.location.c_str()); + ok = false; + } + if (e.day > 2 && e.activity == "work" && + e.location != "work:new") { + printf(" *** future day %d not " + "rewritten ***\n", e.day); + ok = false; + } + } + /* Every day tiles [0, 24) with no overlaps. */ + for (int day = 0; day < 7; ++day) { + float last = 0.0f; + for (const auto &e : merged.entries) { + if (e.day != day) + continue; + if (e.startHour < last - 1e-4f) { + printf(" *** splice overlap on " + "day %d ***\n", day); + ok = false; + } + last = e.endHour; + } + if (last < 24.0f - 1e-3f) { + printf(" *** day %d not tiled to 24 " + "***\n", day); + ok = false; + } + } + } + + printf(" %s (stats shape weeks; splices keep in-flight " + "goals)\n", ok ? "passed" : "FAILURES above"); + return ok; +} + static bool runTownWeekScheduleProbe() { printf("town week schedule probe\n"); @@ -7583,28 +8588,47 @@ static bool runTownWeekScheduleProbe() } } - /* The default score ignores stats: a populated snapshot map - * changes nothing (hook contract for the default path). */ + /* The default candidate SCORE still ignores the snapshot + * (hook contract for the default path). The generator's + * shape may vary with EXPLICIT stats (6.8.10) — what stays + * constant is the score kernel. */ { std::unordered_map stats; stats[pop.members.front().registryId] = { { "money", 5.0 }, { "stress", 90.0 } }; - std::vector withStats = - TownSchedule::generateWeekSchedules(pop, set, 42, - rules, stats); - if (withStats.size() == week.size()) { - for (size_t i = 0; i < week.size(); ++i) + TownScheduleScore::Candidate c; + c.name = "work:shop-1"; + c.distanceMeters = 10.0; + c.usage = 1; + const double a = TownScheduleScore::defaultScore( + c, TownScheduleScore::StatsSnapshot()); + const double b = TownScheduleScore::defaultScore( + c, stats[pop.members.front().registryId]); + if (a != b) { + printf(" *** defaultScore reads the snapshot " + "(%g != %g) ***\n", a, b); + ok = false; + } + /* And the stats-driven week stays deterministic. */ + auto withStats = TownSchedule::generateWeekSchedules( + pop, set, 42, rules, stats); + auto again = TownSchedule::generateWeekSchedules(pop, set, 42, + rules, + stats); + if (withStats.size() != again.size()) { + printf(" *** stats week size unstable ***\n"); + ok = false; + } else { + for (size_t i = 0; i < withStats.size(); ++i) if (withStats[i].entries != - week[i].entries) { - printf(" *** stats changed the " - "default build ***\n"); + again[i].entries) { + printf(" *** stats week not " + "deterministic ***\n"); ok = false; break; } - } else { - ok = false; } } @@ -7688,7 +8712,9 @@ static bool runTownWeekScheduleProbe() } /* The reported tally counts PICKS (decision 4): one per * member's sticky job (which then spans 5 work entries) - * plus one per day's leisure pick. */ + * plus one per day's leisure pick. The default rules + * never pull a job home (empty open-hours table), so + * every work-bearing week is a venue pick. */ std::map usageOut; TownSchedule::generateWeekSchedules(pop, set, 42, rules, {}, {}, nullptr, @@ -7718,6 +8744,64 @@ static bool runTownWeekScheduleProbe() } } + /* Day views (6.8.4b): the runtime adapter — one daily-shaped + * view per day equal to that day's week slice, tiling [0,24), + * and entryAt() on a view agreeing with weekEntryAt(). */ + { + std::array views = + TownSchedule::splitWeekToDays(week.front()); + for (int d = 0; d < 7; ++d) { + std::vector raw; + for (const TownSchedule::WeekEntry &e : + week.front().entries) + if (e.day == d) + raw.push_back(&e); + if (views[d].entries.size() != raw.size()) { + printf(" *** day view %d: %zu entries, " + "week has %zu ***\n", d, + views[d].entries.size(), raw.size()); + ok = false; + continue; + } + for (size_t i = 0; i < raw.size(); ++i) { + if (views[d].entries[i].startHour != + raw[i]->startHour || + views[d].entries[i].endHour != + raw[i]->endHour || + views[d].entries[i].location != + raw[i]->location || + views[d].entries[i].activity != + raw[i]->activity) { + printf(" *** day view %d entry " + "%zu differs from the " + "week slice ***\n", d, i); + ok = false; + break; + } + } + } + static const float probeHours[] = { 0.5f, 8.0f, 12.0f, + 14.0f, 20.5f, 23.5f }; + for (int d = 0; d < 7; ++d) { + for (float h : probeHours) { + const TownSchedule::ScheduleEntry *sv = + TownSchedule::entryAt(views[d], h); + const TownSchedule::WeekEntry *we = + TownSchedule::weekEntryAt( + week.front(), d, h); + if ((sv == nullptr) != (we == nullptr) || + (sv && we && + (sv->location != we->location || + sv->activity != we->activity))) { + printf(" *** day view %d h %.1f " + "entryAt/weekEntryAt " + "disagree ***\n", d, h); + ok = false; + } + } + } + } + /* No-candidate fallbacks (decision 7): a tagless town with only * Interest+curb keeps the daily binding (work -> interest * fallback) and leisure falls back to curb without crashing; @@ -7787,6 +8871,211 @@ static bool runTownWeekScheduleProbe() } } + /* 6.8.5 realism rules: opening hours per tag + soft capacity. + * The earlier blocks ran with the (no-op) pure defaults, so + * default towns stay bit-identical; these fixtures opt in. */ + { + /* --- tag-order lookup + closed-at-slot filtering --- + * Shop venues open 9-18; the fixture keeps a second + * always-open shop channel via a venue whose FIRST tag + * carries a wider window ({"late","shop"} resolves to + * 0-24; {"shop",...} would resolve to 9-18). Workday + * adult errands land at [17, len], so both an exact + * boundary fit ([17,18]) and late slots exist. */ + TownLocations::LocationSet setR; + auto addR = [&](TownLocations::LocationKind kind, + const std::string &name, + const std::vector &tags, + Ogre::Vector3 pos) { + TownLocations::NpcLocation loc; + loc.id = (int)setR.locations.size(); + loc.kind = kind; + loc.name = name; + loc.address = name.substr(name.find(':') + 1); + loc.position = pos; + loc.tags = tags; + setR.locations.push_back(loc); + }; + addR(TownLocations::LocationKind::Home, "home:h1", {}, + Ogre::Vector3(0, 0, 0)); + addR(TownLocations::LocationKind::Work, "work:shop", + { "shop", "salesman" }, Ogre::Vector3(30, 0, 0)); + addR(TownLocations::LocationKind::Leisure, "leisure:shop", + { "shop" }, Ogre::Vector3(31, 0, 0)); + addR(TownLocations::LocationKind::Leisure, + "leisure:late", { "late", "shop" }, + Ogre::Vector3(32, 0, 0)); + addR(TownLocations::LocationKind::Curb, "curb:late", + {}, Ogre::Vector3(34, 0, 0)); + addR(TownLocations::LocationKind::Interest, + "interest:park", {}, Ogre::Vector3(36, 0, 0)); + for (int i = 0; i < 8; ++i) { + TownPopulation::PopMember pm; + pm.registryId = 19001 + (uint64_t)i; + pm.stableKey = "realism-" + + std::to_string((char)('a' + i)); + pm.household = 2; + pm.age = "adult"; + pop.members.push_back(pm); + } + pop.households.resize(3); + pop.households[2].address = "h1"; + TownSchedule::WeekScheduleRules rulesR; + rulesR.openHoursByTag["shop"] = { 9.0f, 18.0f }; + std::vector wr = + TownSchedule::generateWeekSchedules(pop, setR, 42, + rulesR, {}, {}, + nullptr); + auto findLoc = [&](const std::string &name) { + for (const TownLocations::NpcLocation &l : + setR.locations) + if (l.name == name) + return &l; + return (const TownLocations::NpcLocation + *)nullptr; + }; + /* Property (the done-criterion): no schedule entry sits + * at a closed venue, even in part. Unmatched venues + * (home/curb/interest) are open all day. */ + int closedEntries = 0; + for (const TownSchedule::WeekSchedule &ws : wr) + for (const TownSchedule::WeekEntry &e : + ws.entries) { + const TownLocations::NpcLocation *loc = + findLoc(e.location); + if (!loc) + continue; + std::pair win{ + 0.0f, 24.0f + }; + for (const std::string &tag : loc->tags) { + auto it = rulesR.openHoursByTag + .find(tag); + if (it != + rulesR.openHoursByTag.end()) { + win = it->second; + break; + } + } + if (!(win.first <= e.startHour + 1e-3f && + win.second >= e.endHour - 1e-3f)) + ++closedEntries; + } + if (closedEntries != 0) { + printf(" *** realism: %d entries at closed " + "venues ***\n", closedEntries); + ok = false; + } + /* Tag-order: the {"late","shop"} venue stays usable at + * an evening slot ("late" wins). */ + bool lateVenueUsed = false; + for (const TownSchedule::WeekSchedule &ws : wr) + for (const TownSchedule::WeekEntry &e : + ws.entries) + if (e.location == "leisure:late") + lateVenueUsed = true; + if (!lateVenueUsed) { + printf(" *** realism: tag-order lookup lost " + "the always-open venue ***\n"); + ok = false; + } + + /* --- closing pull: shops shut before every work block + * ends --- every adult's workday stays home, no work + * entry at a closed venue, and the warning reports the + * pull. */ + { + TownSchedule::WeekScheduleRules rulesP; + rulesP.openHoursByTag["shop"] = { 9.0f, 8.0f }; + std::vector wp = + TownSchedule::generateWeekSchedules( + pop, setR, 42, rulesP, {}, {}, + nullptr); + int atVenue = 0; + for (const TownSchedule::WeekSchedule &ws : wp) { + if (ws.stableKey.rfind("realism-", 0) != + 0) + continue; + for (const TownSchedule::WeekEntry &e : + ws.entries) + if (e.activity == "work" && + e.location != "home:h1") + ++atVenue; + } + if (atVenue != 0) { + printf(" *** realism: %d work entries " + "survived a fully-shut venue " + "***\n", atVenue); + ok = false; + } + } + + /* --- soft capacity: capacity 1 forces the equal-distance + * leisure pair to share picks; the re-run of the same + * rules stays bit-identical --- */ + { + TownSchedule::WeekScheduleRules rulesC; + rulesC.softCapacity = 1; + std::vector wc = + TownSchedule::generateWeekSchedules( + pop, setR, 42, rulesC, {}, {}, + nullptr); + std::map leisurePicks; + for (const TownSchedule::WeekSchedule &ws : wc) + for (const TownSchedule::WeekEntry &e : + ws.entries) + if (e.activity == "leisure") + ++leisurePicks + [e.location]; + int popular = 0; + for (const auto &kv : leisurePicks) + popular = std::max( + popular, kv.second); + /* Spread check: capacity sinks over-capacity + * candidates, so the two leisure venues stay + * within 2 picks of each other while a no-capacity + * build lets one venue absorb every pick. */ + std::map openPicks; + std::vector wo = + TownSchedule::generateWeekSchedules( + pop, setR, 42, + TownSchedule::WeekScheduleRules(), + {}, {}, nullptr); + for (const TownSchedule::WeekSchedule &ws : wo) + for (const TownSchedule::WeekEntry &e : + ws.entries) + if (e.activity == "leisure") + ++openPicks[e.location]; + int openPopular = 0; + for (const auto &kv : openPicks) + openPopular = std::max( + openPopular, kv.second); + if (popular == 0 || openPopular == 0 || + popular > openPopular) { + printf(" *** realism: capacity did not " + "spread picks (cap max %d vs open " + "max %d) ***\n", popular, + openPopular); + ok = false; + } + /* Bit-identical regen. */ + std::vector wc2 = + TownSchedule::generateWeekSchedules( + pop, setR, 42, rulesC, {}, {}, + nullptr); + if (wc2.size() == wc.size()) + for (size_t i = 0; i < wc.size(); ++i) + if (wc2[i].entries != wc[i].entries) { + printf(" *** capacity " + "rules build not " + "bit-identical " + "***\n"); + ok = false; + break; + } + } + } + printf(" %s (%zu members)\n", ok ? "passed" : "FAILURES above", week.size()); return ok; @@ -10350,7 +11639,11 @@ int main(int argc, char **argv) ok &= runTownPopulationProbe(); ok &= runTownLocationsProbe(); ok &= runTownTagIndexProbe(); + ok &= runEntranceRoleProbe(); + ok &= runReferenceLayoutsProbe(); ok &= runTownWeekScheduleProbe(); + ok &= runWeekVarietyProbe(); + ok &= runTownRostersProbe(); ok &= runTownParkingProbe(); ok &= runPoiDatabaseProbe(); ok &= runTownScheduleProbe(); diff --git a/src/features/editScene/tests/rosters_lua_test.cpp b/src/features/editScene/tests/rosters_lua_test.cpp new file mode 100644 index 0000000..cc64f06 --- /dev/null +++ b/src/features/editScene/tests/rosters_lua_test.cpp @@ -0,0 +1,228 @@ +/** + * @file rosters_lua_test.cpp + * @brief Headless smoke case for the phase 6.8.7 roster Lua binding. + * + * Links TownRosters (the pure derivation) + the Lua binding only — + * no OGRE, no flecs. The provider pattern is exercised with a fake + * area table built from a synthetic weekly build, mirroring what + * NpcPopulationSystem exposes at runtime. + */ + +#include +#include +#include + +#include "../lua/LuaRostersApi.hpp" +#include "../roadlib/TownRosters.hpp" + +extern "C" { +#include +#include +#include +} + +static int testCount = 0; +static int passCount = 0; + +#define TEST(name) \ + do { \ + testCount++; \ + printf(" TEST %d: %s ... ", testCount, name); \ + } while (0) + +#define PASS() \ + do { \ + passCount++; \ + printf("PASS\n"); \ + } while (0) + +#define FAIL(msg) \ + do { \ + printf("FAIL: %s\n", std::string(msg).c_str()); \ + return 1; \ + } while (0) + +static TownRosters::Rosters g_fake; + +static bool fakeProvide(int areaIndex, void *, + TownRosters::Rosters &out) +{ + if (areaIndex != 1 || g_fake.empty()) + return false; + out = g_fake; + return true; +} + +static bool runScript(lua_State *L, const char *script) +{ + if (luaL_dostring(L, script) != LUA_OK) { + const char *err = lua_tostring(L, -1); + printf("LUA ERROR: %s\n", err ? err : "unknown"); + lua_pop(L, 1); + return false; + } + return true; +} + +/* The fixture: hand-built member summaries standing in for a + * weekly build (the pure derivation core is the same call the + * runtime makes; the week-flatten adapter is probed in + * road_geometry_overlap_test). */ +static void buildFake() +{ + std::vector members; + + TownRosters::MemberJob officer; + officer.registryId = 55101; + officer.stableKey = "lua-roster-a"; + officer.homeLocation = "home:hpd-1"; + officer.workLocation = "work:hpd-1"; + officer.tagQuery = "job:police"; + members.push_back(officer); + + TownRosters::MemberJob medic; + medic.registryId = 55201; + medic.stableKey = "lua-roster-b"; + medic.homeLocation = "home:amb-1"; + medic.workLocation = "work:amb-1"; + medic.tagQuery = "job:medic"; + members.push_back(medic); + + TownRosters::MemberJob shopkeep; + shopkeep.registryId = 55301; + shopkeep.stableKey = "lua-roster-c"; + shopkeep.homeLocation = "home:shop-1"; + shopkeep.workLocation = "work:shop-1"; + shopkeep.tagQuery = "job:salesman"; + members.push_back(shopkeep); + + TownRosters::MemberJob child; + child.registryId = 55302; + child.stableKey = "lua-roster-d"; + child.homeLocation = "home:shop-1"; + child.tagQuery = ""; /* no job */ + members.push_back(child); + + g_fake = TownRosters::deriveRosters(members); +} + +int main() +{ + printf("rosters lua test\n"); + int failures = 0; + + TEST("special-job tag vocabulary"); + { + auto &tags = TownRosters::specialJobTags(); + bool police = false, medic = false; + for (const std::string &t : tags) + if (t == "police") + police = true; + else if (t == "medic") + medic = true; + if (!police || !medic) + FAIL("special tag list incomplete"); + if (!TownRosters::isSpecialJobTag("firefighter")) + FAIL("firefighter must be special"); + if (TownRosters::isSpecialJobTag("salesman")) + FAIL("salesman must not be special"); + PASS(); + } + + TEST("derive: registry id order + non-specials dropped"); + { + buildFake(); + size_t members = 0; + for (const auto &kv : g_fake) + members += kv.second.size(); + if (members != 2) + FAIL("fixture roster must hold 2 members " + "(salesman + jobless are out)"); + if (g_fake.count("police") != 1 || + g_fake.count("medic") != 1) + FAIL("roster tags missing"); + PASS(); + } + + { + /* The Lua state with the registered rosters binding. */ + lua_State *L = luaL_newstate(); + luaL_openlibs(L); + lua_newtable(L); + lua_setglobal(L, "ecs"); + editScene::setLuaRostersProvider(fakeProvide, nullptr); + editScene::registerLuaRostersApi(L); + + TEST("ecs.rosters.area returns the roster table"); + if (!runScript( + L, + "local r = ecs.rosters.area(1)\n" + "assert(type(r) == 'table', 'area is nil')\n" + "local total = 0\n" + "local tagsSeen = {}\n" + "for tag, members in pairs(r) do\n" + " tagsSeen[tag] = #members\n" + " total = total + #members\n" + "end\n" + "assert(total == 2, 'members '..total)\n" + "assert(tagsSeen['police'] == 1, 'police')\n" + "assert(tagsSeen['medic'] == 1, 'medic')\n" + "local hit = 0\n" + "for _, tag in ipairs(ecs.rosters.tags()) do\n" + " if tagsSeen[tag] then hit = hit + 1 end\n" + "end\n" + "local special = ecs.rosters.is_special('police')\n" + "assert(special == true, 'police not special')\n" + "assert(ecs.rosters.is_special('salesman') == false)\n" + "global_ok = true\n")) + FAIL("roster script failed"); + lua_getglobal(L, "global_ok"); + if (!lua_toboolean(L, -1)) + FAIL("roster assertions did not run"); + lua_pop(L, 1); + PASS(); + + TEST("unknown area -> nil"); + if (!runScript(L, "assert(ecs.rosters.area(9) == nil)")) + FAIL("unknown area script failed"); + PASS(); + + TEST("member fields: id/home/work/key"); + if (!runScript( + L, + "local r = ecs.rosters.area(1)\n" + "for tag, members in pairs(r) do\n" + " for _, m in ipairs(members) do\n" + " assert(type(m.id) == 'number', 'id')\n" + " assert(m.id >= 55101, 'id band')\n" + " assert(type(m.key) == 'string' and " + "m.key ~= '', 'key')\n" + " assert(string.sub(m.home, 1, 5) == " + "'home:', 'home anchor')\n" + " assert(string.sub(m.work, 1, 5) == " + "'work:', 'work anchor')\n" + " end\n" + "end\n" + "fields_ok = true\n")) + FAIL("fields script failed"); + lua_getglobal(L, "fields_ok"); + if (!lua_toboolean(L, -1)) + FAIL("fields assertions did not run"); + lua_pop(L, 1); + PASS(); + + TEST("cleared provider -> nil"); + editScene::setLuaRostersProvider(nullptr, nullptr); + if (!runScript(L, "assert(ecs.rosters.area(1) == nil)")) + FAIL("cleared-provider script failed"); + PASS(); + + lua_close(L); + } + + printf("%s (%d tests, %d passed)\n", + failures == 0 ? "rosters lua test PASSED" : + "rosters lua test FAILED", + testCount, passCount); + return failures == 0 && testCount == passCount ? 0 : 1; +}