Phase 6.8 checkpoint

This commit is contained in:
2026-10-30 00:27:47 +03:00
parent 19c5528f8e
commit a3ec423cca
45 changed files with 6331 additions and 310 deletions
+221 -14
View File
@@ -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:<address>`, 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:<address>`, 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:<address>: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:<address>` and the first
`leisure:<address>` 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:<address>` 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 ("<address> 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.<gridUid>.<roomName>[-fN].<name>`, grid uid
`town-<address>`, 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:<address>`, 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:<address>: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
+34
View File
@@ -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
+48 -1
View File
@@ -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. */
+432 -146
View File
@@ -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.<gridUid>.<roomName>
[-fN].<name>` (the grid uid is `town-<address>`, 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:<tag>"
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:<address>` (front) and
`entrance:<address>: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:<address>`, 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:<address>: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:<address>` 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
File diff suppressed because it is too large Load Diff
@@ -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`
@@ -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))))
@@ -2,6 +2,7 @@
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
/*
@@ -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<std::string> tags;
/** Stat name -> delta per in-game hour (decl order kept). */
std::vector<std::pair<std::string, double>> 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<RoomCompositionRules> 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<std::string, double>;
using RoomStatEffectList = std::vector<RoomStatEffectPair>;
std::vector<RoomStatEffectList> roomStatEffects;
std::vector<PlanRoof> roofs;
std::vector<PlanParking> parkings;
std::vector<PlanPlacement> placements;
/** DSL-authored POIs (6.8.6), in declaration order. */
std::vector<PlanPoi> pois;
bool valid() const
{
+244 -5
View File
@@ -1348,11 +1348,182 @@ static bool parseTagList(const SExpr &form, std::vector<std::string> &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<std::string> furnitureTags;
RoomCompositionRules compositions;
using StatEffectPair = std::pair<std::string, double>;
std::vector<StatEffectPair> 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<std::string, Value> base;
@@ -3,6 +3,7 @@
#include <functional>
#include <map>
#include <random>
#include <set>
#include <string>
#include <vector>
@@ -338,6 +339,9 @@ private:
/* roomIds index for a room name of the current evaluation:
* name -> index into BuildingPlan::roomNames. */
std::map<std::string, int> m_roomNameIds;
/** Rooms that declared :poi entries (first declaration wins,
* matching the room-table dedupe discipline). */
std::set<std::string> m_roomPois;
};
} /* namespace LayoutLib */
@@ -0,0 +1,121 @@
#include "LuaEmergencyApi.hpp"
#include "../systems/CharacterStatStore.hpp"
#include "../systems/NpcPopulationSystem.hpp"
#include <lualib.h>
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
@@ -0,0 +1,19 @@
#pragma once
extern "C" {
#include <lua.h>
#include <lauxlib.h>
}
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
@@ -0,0 +1,102 @@
#include "LuaRostersApi.hpp"
extern "C" {
#include <lauxlib.h>
}
namespace editScene {
namespace {
RostersProvider g_provider = nullptr;
void *g_user = nullptr;
/** ecs.rosters.area(areaIndex) -> table or nil
*
* { "<tag>" = { { id = <registryId>, key = <stableKey>,
* home = "<loc>", work = "<loc>" }, ... }, ... }
* 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<std::string> &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
@@ -0,0 +1,24 @@
#pragma once
#include "../roadlib/TownRosters.hpp"
extern "C" {
#include <lua.h>
}
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
+46 -2
View File
@@ -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<std::string>{ 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). */
@@ -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
@@ -884,6 +884,83 @@ TownResult generateTown(const RoadGraph &graph, const TownParams &params,
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"). */
@@ -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;
/** "<street> <n>" or "Lot <lotIndex+1>". */
std::string address;
/** Frontage edge (index into RoadGraph::edges) and side
+106 -16
View File
@@ -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<std::string> &buildingTags)
@@ -67,19 +79,6 @@ bool isCommercialBuilding(const std::vector<std::string> &buildingTags)
return false;
}
const char *jobTagForBuilding(const std::vector<std::string> &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<std::string> &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<std::string, double> &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;
}
@@ -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<std::string, double> statEffects;
};
/** The approach endpoint of a location: its entry POI position when
@@ -0,0 +1,63 @@
#include "TownRosters.hpp"
#include <algorithm>
namespace TownRosters {
const std::vector<std::string> &specialJobTags()
{
static const std::vector<std::string> 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<MemberJob> &members,
const std::vector<std::string> &tags)
{
Rosters out;
/* Registry-id order (decision 4 discipline): the source
* vector's build order is irrelevant. */
std::vector<const MemberJob *> 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 */
@@ -0,0 +1,68 @@
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
/**
* 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:<tag>") 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<std::string, std::vector<RosterMember>>;
/** 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:<tag>",
* "" 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<std::string> &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<MemberJob> &members,
const std::vector<std::string> &tags = specialJobTags());
} /* namespace TownRosters */
@@ -0,0 +1,29 @@
#include "TownRostersWeeks.hpp"
namespace TownRosters {
std::vector<MemberJob> weekJobMembers(
const std::vector<TownSchedule::WeekSchedule> &weeks)
{
std::vector<MemberJob> 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 */
@@ -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<MemberJob> weekJobMembers(
const std::vector<TownSchedule::WeekSchedule> &weeks);
} /* namespace TownRosters */
+315 -14
View File
@@ -1,7 +1,9 @@
#include "TownSchedule.hpp"
#include <algorithm>
#include <climits>
#include <cmath>
#include <map>
#include <random>
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<float, float> openWindowOf(
const WeekScheduleRules &rules,
const TownLocations::NpcLocation &loc)
{
for (const std::string &tag : loc.tags) {
std::map<std::string,
std::pair<float, float> >::const_iterator it =
rules.openHoursByTag.find(tag);
if (it != rules.openHoursByTag.end())
return it->second;
}
return std::pair<float, float>(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<float, float> &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<std::string, int>::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<WeekSchedule> 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<WeekSchedule> 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<int> 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<WeekSchedule> 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<WeekSchedule> 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<WeekSchedule> 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<WeekSchedule> 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<int> 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<WeekSchedule> 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<NpcSchedule, 7> splitWeekToDays(const WeekSchedule &week)
{
std::array<NpcSchedule, 7> 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 */
@@ -1,5 +1,6 @@
#pragma once
#include <array>
#include <cstdint>
#include <functional>
#include <map>
@@ -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<int> 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<std::string, std::pair<float, float> > 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<std::string, int> capacityByTag;
};
/** Generate the baseline week for every population member.
@@ -196,4 +221,26 @@ std::vector<WeekSchedule> 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<NpcSchedule, 7> 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 */
@@ -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
@@ -50,6 +50,11 @@ struct Candidate {
int usage = 0;
/** Stat effects offered per in-game hour (e.g. money=+10). */
std::unordered_map<std::string, double> 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{});
@@ -5,6 +5,9 @@
#include <set>
#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)
@@ -149,6 +149,25 @@ private:
flecs::world &m_world;
std::map<uint64_t, NpcMotion> 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<uint64_t, TownSchedule::ScheduleEntry> m_lastEntry;
std::set<EntryKey> m_completedEntries;
/* NPCs driven externally (taxi rides); skipped by update(). */
std::set<uint64_t> m_suspended;
@@ -2,7 +2,13 @@
#include <Ogre.h>
#include "CharacterStatStore.hpp"
#include "CharacterRegistry.hpp"
#include "GameClockSystem.hpp"
#include "NpcTaxiSystem.hpp"
#include "NpcTrafficSystem.hpp"
#include <nlohmann/json.hpp>
#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<std::string> weekWarns;
std::map<std::string, int> 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<uint64_t, TownSchedule::StatsSnapshot>
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<TownSchedule::WeekSchedule> 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<int, std::vector<AreaPopulation::WeekRevision> >
savedRevisions;
std::map<int,
std::unordered_map<uint64_t,
AreaPopulation::EmergencyState> >
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<TerrainComponent>())
return false;
const RoadGraph *graph = &terrainEnt.get<TerrainComponent>().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<uint64_t, TownSchedule::StatsSnapshot>
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<std::string> warns;
std::map<std::string, int> usage;
std::vector<TownSchedule::WeekSchedule> 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<uint64_t, TownSchedule::WeekSchedule> 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<TownSchedule::NpcSchedule, 7> >::
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 &reg = 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<double>();
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<int>();
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<TownSchedule::WeekSchedule> 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());
}
}
@@ -1,5 +1,6 @@
#pragma once
#include <array>
#include <cstdint>
#include <map>
#include <set>
@@ -8,10 +9,13 @@
#include <vector>
#include <flecs.h>
#include <nlohmann/json.hpp>
#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<std::string, int> *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<uint64_t, TownSchedule::WeekSchedule>
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<uint64_t,
std::array<TownSchedule::NpcSchedule, 7> >
weekDayViews;
/* The weekly build's location -> pick tally (decision
* 4; F7 week pane). */
std::map<std::string, int> weekUsage;
/* 6.8.10: the week-start stats snapshot the build was
* shaped by (only members with explicit values). */
std::unordered_map<uint64_t, TownSchedule::StatsSnapshot>
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<WeekRevision> 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<uint64_t, EmergencyState> 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
@@ -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();
@@ -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;
@@ -298,11 +298,17 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
}
std::vector<int> 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<ClaimEv> 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 &reg = 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 =
@@ -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;
@@ -2651,7 +2651,9 @@ nlohmann::json SceneSerializer::serializeCharacterSpawner(flecs::entity entity)
auto &spawner = entity.get<CharacterSpawnerComponent>();
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<CharacterSpawnerComponent>(spawner);
}
@@ -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);
}
@@ -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{});
@@ -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
@@ -0,0 +1,27 @@
#pragma once
#include <cstdint>
#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
@@ -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 <OgreRTShaderSystem.h>
#include <cmath>
#include <cfloat>
#include <algorithm>
#include <array>
#include <fstream>
#include <set>
@@ -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<LayoutLib::PlanRoomBounds> 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>(EntityNameComponent(
"poi " + pd.name + " " + roomName +
(rb->floor > 0 ?
" f" + std::to_string(rb->floor) :
"")));
poiEnt.add<RuntimeMarkerComponent>();
/* 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<TransformComponent>(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<PoiComponent>(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<CellGridComponent>());
/* 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<CellGridComponent>());
/* 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.
@@ -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.<gridUid>.<roomName>[-fN].<name>`, 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
File diff suppressed because it is too large Load Diff
@@ -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 <cstdio>
#include <map>
#include <string>
#include "../lua/LuaRostersApi.hpp"
#include "../roadlib/TownRosters.hpp"
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
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<TownRosters::MemberJob> 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;
}