diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md
index eb6cdba..46647e1 100644
--- a/src/features/editScene/AGENTS.md
+++ b/src/features/editScene/AGENTS.md
@@ -732,23 +732,45 @@ and the area's "Population Rules" node. `PopulationRules` defaults
`minOutfitLevel`/`maxOutfitLevel` to 2 (townsfolk are clothed in public;
widen per area via "Population Rules").
-### TownLocations + NpcLocationComponent (demo-taxi phase 4.7.3)
+### TownLocations + NpcLocationComponent (demo-taxi phase 4.7.3 + 6.8.2)
The symbolic location model. `roadlib/TownLocations::buildLocations()`
-(pure, headless — probe `runTownLocationsProbe` in
-road_geometry_overlap_test) builds a per-town `LocationSet`: one Home per
+(pure, headless — probes `runTownLocationsProbe` and
+`runTownTagIndexProbe` in road_geometry_overlap_test) builds a
+per-town `LocationSet`: one Home per
building (at the DOORSTEP — `TownBuilding::doorstep`, 2 m in front of the
front door at ground level, front-face center for doorless layouts; the
building center is inside the exterior-only shell and must never anchor an
NPC), one Curb per pickup POI and one Parking
per parking POI (`TownGen::TownPoi` has a `kind` field — Pickup/Parking — no
-name-suffix parsing), plus an Interest location per building whose hash roll
-(`seed ^ fnv1a(address)`, no shared stream) passes the interest ratio;
-Work/Leisure kinds exist but nothing generates them yet.
+name-suffix parsing), and — per building — either the **commercial pair
+(6.8.2)** or the legacy Interest roll: a building whose
+`TownBuilding::tags` (its picked layout's `:tags`, phase 5.9;
+`LayoutLibrary::builtinTags` for district defaults and fallbacks,
+`findEntry` for explicit edge-side library layouts; empty for
+inline sources and hand-built results) carries a commercial tag
+(`isCommercialBuilding`: shop/commercial/office/leisure) yields BOTH a
+Work location (`work:
`, tags + the job tag from
+`jobTagForBuilding`: shop→salesman, office→clerk, else worker) and a
+Leisure location (`leisure:`, the building's tags) — same
+building, different kind, tags and audience (a shop is a workplace for
+employees and a leisure venue for customers) — both anchored at the
+building's pickup POI (else the doorstep) with entrance role `front`;
+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
+`LocationSet` keeps a lazy tag→indices + building→indices index
+(deterministic, location order; `indicesWithTag()` /
+`indicesOfBuilding()`; call `reindex()` after direct mutation of
+`locations`).
`NpcPopulationSystem` keeps the set per area (`areaLocations(areaIndex)`) and
writes deterministic per-NPC bindings into registry stringColumns: `npcWork`
-(from the Interest locations — the workplace placeholder until zoning
-exists), `npcInterest` (Interest+Curb), both falling back to the home
+(prefers the Work locations once the town has tagged commercial
+buildings, else the Interest fallback) , `npcInterest` (Interest+Curb
++Leisure — Leisure locations are the customers' errand
+destinations), both falling back to the home
location, which is `"home:" + npcHome`. Authored locations use
`NpcLocationComponent` (kind as int mirroring `LocationKind`, name, radius;
serialized as `npcLocation`, editor widget in the Game group); merging
@@ -864,11 +886,14 @@ the camera become real walking entities and drop back to symbolic beyond
`despawnDistanceSq` (100 m — demo: 100 m) — hysteresis plus a `minDwellSeconds` (3 s)
dwell and a `spawnBudgetPerFrame` (2) cap, all public config fields. Spawn
eligibility (phase 4.9.1) gates the distance rule — `isSpawnEligible(id)`:
-an NPC AT an INDOOR location (`TownLocations::isIndoorKind` — Home, Work,
+an NPC AT an INDOOR location (the POI projection's stamped domain
+`NpcLocation::indoor`, 6.8.12; `TownLocations::isIndoorKind` is the
+fallback for un-projected sets — Home, Work,
Leisure, Interest; the Interest position sits at the curb but the NPC is
conceptually inside the building) never spawns into the exterior scene, and
a spawned NPC whose schedule moves it indoors keeps walking to the
-location point (the home doorstep) and despawns only once the LIVE entity
+approach anchor (the building ENTRY POI position, 6.8.12 — the home
+doorstep in the generated baseline) and despawns only once the LIVE entity
gets within 2.5 m of it — never mid-street where it stood when the
schedule flipped (the camera leaving the despawn band still despawns it
wherever it is); outdoor
@@ -887,7 +912,12 @@ capsuleOverlapsObstacles`, probe lifted 0.3 m so raised sidewalk/road edges
don't count): then a ring of candidates (8 directions x 0.6..5 m) walks
outward to the first free spot and a raycast snaps the feet onto the
surface (the symbolic position can land inside a raised road slab or a
-parked car).
+parked car — with 6.8.2 zoning, parked-fleet cars share curb anchors
+with commercial locations, so relocations legitimately happen).
+`spawnedRelocated(registryId)` reports whether THIS update's spawn
+used the ring (cleared each `update()`; the `--test-detail` probe
+accepts ring-relocated spawns within the 5 m ring radius, everything
+else must be exact).
inline-spawned population NPCs have no prefab, so spawn adds
`CharacterComponent`, `AnimationTreeComponent` (`animationTreeName`,
default "male1", root motion ON) and
@@ -1317,7 +1347,20 @@ creep retarget onto the pose; an approach that stops improving inside
`parkEngageM` (9 m) escalates after `parkFallbackSeconds` (5 s) to
`retargetToFallback()` — the nearest free curb-chain spot, warned
once. The claim does NOT move on fallback (the symbolic layer stays
-untouched; the deviation self-heals on the next leg or despawn). On
+untouched; the deviation self-heals on the next leg or despawn).
+Immobile recovery (6.8.2a): a car that is physically wedged after the
+fallback (high-centered on a pad edge — the AI keeps commanding
+~0.9 throttle at min-corner speed while the car reads ~zero speed) is
+TELEPORTED onto the fallback pose (`recoverParkedTeleport`; world
+space via the wrapper's RVec3 setPosition, velocities zeroed) after
+`parkRecoverSeconds` (4 s) of tracked immobility (`TrafficCar::
+parkStuckSec`, reset by approach progress and by the retarget) — the
+symbolic layer stays the ground truth and the observed physics is
+best-effort; a stranded live car must never deadlock the leg's
+alight (this deadlock was reproduced by the 6.8.2 leg-mix change and
+is what the 6.8.2a traffic-schedule run exercises). `TrafficInfo`
+additionally carries the live drop-off read-out: `parkTarget` (the
+pose the alight gate scores against) and `aiArrived`. On
despawn nothing is persisted back — the
next spawn reads the clock-pure symbolic state.
`NpcDetailSystem::isSpawnEligible` consults
@@ -1363,6 +1406,35 @@ sun/moon discs; the spheres hide once `sunElev` passes ~the horizon
(`-0.03` / `0.03`, roughly the sphere angular radius) so they cannot shine
through water or terrain.
+### Layout DSL macro blocks (phase 6.8.1)
+
+`(defmacro name (params...) body...)` declares a named, parametrized
+layout fragment next to `(layout ...)` entries in `town_layouts.txt`
+or at the top of an inline layout source. Invoking `(name arg1 ...)`
+expands the body before evaluation, with parameters substituted by
+NAME (deep copy, any s-expression as an argument — including
+arithmetic forms like `(+ x0 0.5)`). Expansion is purely syntactic:
+no evaluation happens during it, so per-lot RNG draw order and
+determinism are untouched (density-1 default towns stay
+bit-identical).
+
+- Macros may call other macros; definitions are order-independent
+ (collected before expansion). Inline `(defmacro ...)` forms are
+ local to that one evaluation; library definitions are shared and
+ inline ones win.
+- Errors are hard and readable: unknown arity reports the expected
+ and actual counts, duplicate definitions are rejected, and
+ recursive expansions stop at a depth/expansion cap with the macro
+ call chain. Macro names may not shadow built-in verbs/special
+ forms (`shell`, `room`, `if`, `let`, ...).
+- Authoring rule: room rects are footprint FRACTIONS (0..1), so
+ macro parameters compose naturally with `+`/`*` arithmetic.
+- Verification: `road_geometry_overlap_test` macro block (expansion
+ vs hand-expanded `BuildingPlan` equality, inline macros, arity,
+ recursion cap, duplicates, reserved names, unused-macro
+ no-op) and the help-window example "Macro blocks (defmacro +
+ reuse)" in `LayoutLibrary::examples()`.
+
### TerrainPrefabSpawnerSystem
Distance-based spawn/despawn of prefab instances on terrain
@@ -2186,6 +2258,134 @@ file while it runs — always `removeTree` on PASS, and re-stage the
file from the source tree after test runs (a killed run otherwise
leaves the synthetic tree behind).
+### Unified POI model (phase 6.8.11)
+
+`roadlib/PoiDatabase.hpp/.cpp` is the pure, ECS-free core of the
+unified POI model. A POI is ONE record with TWO aspects:
+
+- **Planner data** (the `PoiDatabase::Record`): stable id, `domain`
+ (interior | exterior), `kind` ("home"/"work"/"leisure"/"interest"/
+ "curb"/"parking"/"entrance"/"interaction"/...), address/building,
+ world position (+optional parking heading), tags, `entranceRole`,
+ opening hours (openHour < 0 = always open), stat effects per
+ in-game hour, and an activation spec.
+- **Mechanical activation** (runtime, NOT in the pure core):
+ `ActivationKind::None/Manual/OnApproach/OnArrive` + behavior tree
+ (default) or Lua script + radius/cooldown/once. Runtime entities
+ are created per terrain page and never persisted in the db.
+
+The `PoiDatabase::Set` is a UNIFIED QUERY MODEL, not the source of
+truth: every authoring path (generated town POIs, DSL-emitted POIs,
+component-authored POIs) feeds it, and `finalize()` sorts by id and
+builds the tag index so queries are deterministic and id-ordered
+regardless of insertion order. `Query` filters by domain, kind,
+`tagsAll` (must carry every tag), address and opening hours
+(including overnight ranges like 22-6). `add()` rejects empty and
+duplicate ids. `TownLocations::LocationSet` is a PROJECTION
+(`projectLocations`): only location kinds (home/work/leisure/
+interest/curb/parking) become `NpcLocation`s — interaction and
+entrance POIs are not schedule locations — so schedules, motion,
+taxi and traffic keep one location model.
+
+Verification: `runPoiDatabaseProbe` in
+`road_geometry_overlap_test` (add/finalize/findById/duplicates,
+domain/kind/tagsAll/address/opening-hours filters, hasEffect, and
+the projection's kind mapping + field preservation), plus the
+feed/projection round trip: `feedLocationSet` converts a generated
+LocationSet into POI records and projecting back is equivalent to
+the original set BY NAME for every kind (ids, domains from
+`isIndoorKind`, positions, headings).
+
+`NpcPopulationSystem` owns the POI set per area: generated
+locations feed it (`feedLocationSet`), authored
+`NpcLocationComponent` markers merge into it
+(`mergeAuthoredPoiMarkers` — name, kind int→string, domain,
+render-space position via RenderOriginSystem when
+`hasWorldPosition` is set, else the node position; unnamed markers
+and id collisions are skipped with a log), and `areaLocations()`
+returns the projection, so motion/taxi/traffic/population consume
+the unified database while keeping their existing LocationSet API
+(motion/determinism/taxi/parking/detail batteries all pass
+bit-identically).
+
+Runtime activation (step 3, same phase): `PoiComponent`
+(`components/Poi.hpp`, "Game" group, serialized as `poi`) authors
+a full POI on a scene entity — planner data (poiId, kind, tags,
+entranceRole, openHour/closeHour, statEffects) AND the activation
+spec (`activationKind` int mirroring
+`PoiDatabase::ActivationKind`, a behaviorTree ActionDatabase
+action name or a luaScript path, radius/cooldownSeconds/once);
+the entity transform is the POI position. `NpcPopulationSystem`'s
+periodic re-merge (every 60 frames, replacing only the recorded
+authored ids — capture `rec.id` BEFORE
+`set.add(std::move(rec))`, the moved-from id silently empties the
+authored set) merges components into the area's POI set, so
+runtime-added POIs appear without a full regeneration.
+
+`PoiSystem` (`systems/PoiSystem.hpp/.cpp`, EditorApp-owned,
+`getPoiSystem()`, Game+Playing only, updated next to ZoneSystem)
+is the mechanical side: it ensures an `ActuatorComponent` for
+behavior-tree POIs every frame BEFORE the proximity pass and
+fires per trigger mode — Manual = the existing E-prompt path (no
+per-frame work), OnApproach = an actor (player or high-detail
+NPC, via `NpcDetailSystem::isHighDetail`) entering the radius,
+OnArrive = dwelling settled (speed < 0.3 m/s) for 0.5 s. A
+behaviorTree executes through `ActuatorSystem::executeAction`; a
+luaScript fires the `poi_activated` event (params: poi, script,
+actor, entity) for scene scripts. Cooldowns/once are per
+(actor, poi); all fire state is runtime-only. **Tools -> POI
+Database** (`PoiDatabasePanel`) lists every area's unified POIs
+(domain/kind/tags/effects/activation) with an id/kind/tag filter,
+so DSL-authored and component-authored POIs look identical.
+Headless probe: `demoTaxi --headless --test-poi` (db merge,
+approach→BT, arrive→Lua event, manual E-path, cooldown
+suppression). Lua: `ecs.get_component`/`set_component`/
+`set_field` with the component name "Poi" (fields mirror the
+serialized JSON; `tags` is an array, `statEffects` a string→number
+map) — covered by `component_lua_test` test 36.
+
+**Building entry contract (phase 6.8.12).** Every enterable
+building gets one generated EXTERIOR **entry POI** at its doorstep:
+`PoiDatabase::appendEntries(set, locations)` (pure, idempotent,
+called from `NpcPopulationSystem` right after the feed) creates id
+`entrance:`, kind `entrance`, domain Exterior, tag
+`entrance`, role `front`, position = the building's Home location
+(the doorstep — "one Home per building" is the enterability proxy).
+Resolution helpers (all pure): `entryId(address)`,
+`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
+entry), Exterior → the POI itself. `projectLocations` stamps every
+projected location with `NpcLocation::indoor` (the explicit domain)
+and, for interior destinations, `entryPosition`/`hasEntry`;
+`TownLocations::approachPosition(loc)` is the per-location endpoint
+helper. Consumers: `NpcMotionSystem` paths BOTH endpoints through
+the approach anchors and writes symbolic waiting/arrival positions
+AT the entry (reaching the entry = inside; values identical today
+because Home sits at the doorstep, so clock-pure results stay
+bit-identical), and `NpcDetailSystem::isSpawnEligible` reads the
+stamped domain with the `isIndoorKind` fallback for sets built
+without the projection (the indoor despawn-at-door rule anchors on
+the same entry). When Phase 8 moves Home/Work POIs to interior
+coordinates (or a scene switch), schedules/motion/taxi/traffic keep
+working unchanged — the interior cell is only the POI's `position`,
+never a path endpoint. The POI Database panel groups interior POIs
+under their building's entry. The weekly planner (6.8.2, not yet
+implemented) must compute candidate distances against
+`approachPosition`. Probe: the entry-contract block in
+`runPoiDatabaseProbe` (road_geometry_overlap_test), plus
+`--test-motion`/`--test-detail`/`--test-determinism`/
+`--test-taxi`/`--test-parking`/`--test-population-switch` all PASS
+unchanged.
+
+flecs gotcha (cost the probe a false fail): never add a component
+inside a query's `each()` — `set` of a NOT-yet-present component
+moves the entity to another table and the swap-and-popped row
+dangles the lambda's component references (the OnApproach POI
+executed the manual POI's behavior tree with its data). Ensure
+components before the pass (collect-then-apply); inside iteration
+only write component VALUES.
+
### Character facing convention (frontAxis)
The catalog character meshes face **+Z** in model space, and the locomotion
@@ -3064,6 +3264,34 @@ Note: setting the `Transform` component from Lua (`ecs.set_component` /
`ecs.set_field`) applies the new values to the scene node and bumps the
change-tracking version, so script-side moves/scales are visible immediately.
+### Per-demo Lua script packages
+
+Lua scripts are demo-owned, not globally shared. Each demo keeps its
+own `demos//lua-scripts/` directory in the source tree for
+demo-specific modules (e.g. demo-taxi's `character_stats_rules.lua`);
+`stage_runtime.cmake` assembles the build dir's `lua-scripts/` as a
+REAL directory of per-file symlinks: demo-owned files first-class,
+plus an EXPLICIT per-demo list of the shared modules the demo
+actually uses (unused shared scripts are not staged). A demo
+overrides a shared module simply by shipping the same filename.
+Do NOT add demo-specific logic to the shared pools (`lua-scripts/` at
+the repo root or `src/features/editScene/lua-scripts/`) — those are
+for genuinely shared code only; new demo logic belongs in the demo's
+own directory (other demos migrate to this layout as they are
+touched). Script edits are visible without a rebuild because
+everything is symlinked; `require("name")` resolves through the
+`LuaScripts` resource group as before.
+
+demo-taxi's package: `data2.lua` (required by `LuaState::lateSetup()`
+and the `vehicleEnter` node) + the demo-owned
+`character_stats_rules.lua` + `json/` (shared utility library) +
+`narrator/` and `stories/` (kept whole for now, no usage audit).
+The legacy `data.lua` scene-generation script and `sokoban.lua`
+(demo-sokoban only) were audited as UNUSED and are not staged.
+Both `data2.lua` and `character_stats_rules.lua` carry execute-once
+guards so re-evaluating them (scene switches, loadfile-based test
+harnesses) never duplicates registrations or module state.
+
### Save/Load
Game-mode saves are JSON files in the OS user-data directory (see
@@ -3142,8 +3370,18 @@ an independent script module with its own rules and lifecycle
- Persistence: only explicit values are serialized
(`character_stats.json` in the CWD, autoSave on write,
`setAutoSaveEnabled(false)` in tests); values of not-yet-
- re-declared stats survive. Save-game integration lands with the
- schedule store (6.8.10).
+ re-declared stats survive. Save games carry a `characterStats`
+ section (EditorApp::saveGame/loadGame; old saves start stat-less).
+
+Stat modules (`demos/demo-taxi/lua-scripts/character_stats_rules.lua`,
+loadable with `require("character_stats_rules")` from scene scripts): the
+health/money/stress definitions plus per-activity effects as DATA
+(`rules.work/leisure/home/sleep/hospital/clinic`, stat points per
+in-game hour) and one deterministic `apply_activity(id, activity,
+hours)` helper — unknown activities are a no-op. The same file holds
+the emergency thresholds (6.8.14: `hospitalBelowHealth` 10,
+`dischargeHealth` 100 — hysteresis so an NPC cannot oscillate at the
+trigger). The F7 overlay shows the selected NPC's live stat block.
Lua (`lua/LuaCharacterStatsApi.cpp`, registered in EditorApp next to
the global-state API): `ecs.stats.define{name,default,min,max}`,
@@ -3152,7 +3390,22 @@ the global-state API): `ecs.stats.define{name,default,min,max}`,
`lua-examples/character_stats_example.lua`. Tests:
`tests/character_stats_test.cpp` (target `character_stats_test`,
CTest `characterStatsTest`; links only the store + the Lua binding,
-no OGRE/flecs — 9 cases).
+no OGRE/flecs — 10 cases incl. the rules module with hardcoded
+activities).
+
+Planner scoring (step 3, `systems/ScheduleScoring.hpp/.cpp`):
+`score(candidate, snapshot)` ranks a location for an NPC (higher =
+better). The pure C++ `defaultScore` uses current usage, distance
+from home and a deterministic name-hash jitter (snapshot ignored).
+An optional read-only override (C++ `setHook`, or Lua
+`ecs.schedule.set_score_hook(fn)` / `clear_score_hook()`) receives
+only the candidate (name, tags, distance, usage, stat-effect vector)
+and the week-start stats snapshot — never live state — so stat
+effects on POIs steer the choice (poor → high-money POIs, stressed →
+calming POIs). Example:
+`lua-examples/schedule_scoring_example.lua`. Tests:
+`tests/schedule_scoring_test.cpp` (target `schedule_scoring_test`,
+CTest `scheduleScoringTest`, 3 cases with hardcoded snapshots).
### Scene Switching
diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt
index 8d3f424..05742d9 100644
--- a/src/features/editScene/CMakeLists.txt
+++ b/src/features/editScene/CMakeLists.txt
@@ -66,6 +66,7 @@ set(EDITSCENE_SOURCES
systems/ItemStateRegistry.cpp
systems/GlobalStateStore.cpp
systems/CharacterStatStore.cpp
+ systems/ScheduleScoring.cpp
systems/SaveLoadSystem.cpp
systems/SaveLoadDialog.cpp
systems/PlayerControllerSystem.cpp
@@ -105,8 +106,12 @@ set(EDITSCENE_SOURCES
ui/TargetZoneEditor.cpp
components/TargetZoneModule.cpp
ui/NpcLocationEditor.cpp
+ ui/PoiEditor.cpp
+ ui/PoiDatabasePanel.cpp
components/NpcLocationModule.cpp
+ components/PoiModule.cpp
systems/ZoneSystem.cpp
+ systems/PoiSystem.cpp
systems/GameHudSystem.cpp
systems/EventBus.cpp
systems/EventHandlerSystem.cpp
@@ -233,6 +238,7 @@ set(EDITSCENE_SOURCES
lua/LuaSaveLoadApi.cpp
lua/LuaGlobalStateApi.cpp
lua/LuaCharacterStatsApi.cpp
+ lua/LuaScheduleScoringApi.cpp
lua/LuaDoorApi.cpp
lua/LuaHudApi.cpp
lua/LuaFurnitureApi.cpp
@@ -837,6 +843,29 @@ add_test(NAME globalStateTest
COMMAND global_state_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
+# ---------------------------------------------------------------------------
+# Test: Phase 6.8.9 planner scoring hook (headless)
+# ---------------------------------------------------------------------------
+add_executable(schedule_scoring_test
+ tests/schedule_scoring_test.cpp
+ systems/ScheduleScoring.cpp
+ lua/LuaScheduleScoringApi.cpp
+)
+
+target_link_libraries(schedule_scoring_test
+ lua
+ nlohmann_json::nlohmann_json
+)
+
+target_include_directories(schedule_scoring_test PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
+)
+
+add_test(NAME scheduleScoringTest
+ COMMAND schedule_scoring_test
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
+
# ---------------------------------------------------------------------------
# Test: Phase 6.8.9 character stat container (headless)
# ---------------------------------------------------------------------------
@@ -858,6 +887,10 @@ target_include_directories(character_stats_test PRIVATE
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
+target_compile_definitions(character_stats_test PRIVATE
+ CHARACTER_STATS_RULES_LUA="${CMAKE_CURRENT_SOURCE_DIR}/demos/demo-taxi/lua-scripts/character_stats_rules.lua"
+)
+
add_test(NAME characterStatsTest
COMMAND character_stats_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
@@ -952,6 +985,8 @@ add_library(RoadGeometryLib STATIC
roadlib/TownLocations.hpp
roadlib/TownSchedule.cpp
roadlib/TownSchedule.hpp
+ roadlib/PoiDatabase.cpp
+ roadlib/PoiDatabase.hpp
)
target_include_directories(RoadGeometryLib PUBLIC
diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp
index 4f16c3a..2ac3bee 100644
--- a/src/features/editScene/EditorApp.cpp
+++ b/src/features/editScene/EditorApp.cpp
@@ -12,6 +12,7 @@
#include "systems/VehicleSystem.hpp"
#include "systems/AiDriverSystem.hpp"
#include "systems/ZoneSystem.hpp"
+#include "systems/PoiSystem.hpp"
#include "systems/GameHudSystem.hpp"
#include "systems/PhysicsSystem.hpp"
#include "systems/BuoyancySystem.hpp"
@@ -71,6 +72,8 @@
#include "lua/LuaFurnitureApi.hpp"
#include "lua/LuaGlobalStateApi.hpp"
#include "lua/LuaCharacterStatsApi.hpp"
+#include "lua/LuaScheduleScoringApi.hpp"
+#include "systems/CharacterStatStore.hpp"
#include "lua/LuaSceneSwitchApi.hpp"
#include "systems/PlayerControllerSystem.hpp"
#include "systems/VehicleControllerSystem.hpp"
@@ -495,6 +498,7 @@ void EditorApp::destroyEditorSystems()
/* Vehicles hold physics step listeners; tear down before physics. */
m_gameHudSystem.reset();
m_zoneSystem.reset();
+ m_poiSystem.reset();
m_aiDriverSystem.reset();
m_vehicleSystem.reset();
/* Hair ragdolls hold Jolt constraints; the destructor removes them
@@ -621,6 +625,9 @@ void EditorApp::setup()
m_zoneSystem = std::make_unique(
m_world, m_physicsSystem->getPhysicsWrapper());
+ /* Unified POI activation (phase 6.8.11). */
+ m_poiSystem = std::make_unique(m_world, this);
+
/* F12 game HUD overlay (ImGui, Lua-driven). */
m_gameHudSystem = std::make_unique(this);
@@ -1019,6 +1026,7 @@ void EditorApp::setup()
editScene::registerLuaSceneSwitchApi(L);
editScene::registerLuaGlobalStateApi(L);
editScene::registerLuaCharacterStatsApi(L);
+ editScene::registerLuaScheduleScoringApi(L);
editScene::registerLuaDoorApi(L);
editScene::registerLuaHudApi(L);
editScene::registerLuaFurnitureApi(L, &m_world);
@@ -1861,6 +1869,8 @@ void EditorApp::saveGame(const std::string &slotPath,
ContainerStateRegistry::getInstance().serialize();
saveData["itemState"] = ItemStateRegistry::getInstance().serialize();
saveData["globalState"] = GlobalStateStore::getInstance().serialize();
+ saveData["characterStats"] =
+ CharacterStatStore::getInstance().serialize();
/* Runtime entities — skip characters and player controller */
saveData["runtimeEntities"] = nlohmann::json::array();
@@ -1959,6 +1969,14 @@ void EditorApp::loadGame(const std::string &slotPath)
saveData["globalState"]);
else
GlobalStateStore::getInstance().clearToDefaults();
+ /* Character stats ride the save file too (6.8.9); old saves
+ * without the section start stat-less. */
+ if (saveData.contains("characterStats"))
+ CharacterStatStore::getInstance().deserialize(
+ saveData["characterStats"]);
+ else
+ CharacterStatStore::getInstance().deserialize(
+ nlohmann::json::object());
/* Destroy ALL spawned characters so the registry spawn is the
* only source of characters. */
@@ -2566,6 +2584,8 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
* (sokoban) through zone_entered/zone_left. */
if (m_zoneSystem)
m_zoneSystem->update();
+ if (m_poiSystem)
+ m_poiSystem->update(fixedEvt.timeSinceLastFrame);
/* Generic per-frame event for Lua activity rules
* (e.g. the sokoban yard reset); subscribers must
* throttle themselves. */
diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp
index cc1ab92..59ca966 100644
--- a/src/features/editScene/EditorApp.hpp
+++ b/src/features/editScene/EditorApp.hpp
@@ -20,6 +20,7 @@ class EditorUISystem;
class VehicleSystem;
class AiDriverSystem;
class ZoneSystem;
+class PoiSystem;
class GameHudSystem;
class NpcScheduleDebugOverlay;
class EditorCamera;
@@ -409,6 +410,10 @@ public:
{
return m_zoneSystem.get();
}
+ PoiSystem *getPoiSystem() const
+ {
+ return m_poiSystem.get();
+ }
GameHudSystem *getGameHudSystem() const
{
return m_gameHudSystem.get();
@@ -553,6 +558,7 @@ private:
* VehicleComponent input fields the player controller writes. */
std::unique_ptr m_aiDriverSystem;
std::unique_ptr m_zoneSystem;
+ std::unique_ptr m_poiSystem;
std::unique_ptr m_gameHudSystem;
/* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */
std::unique_ptr m_npcScheduleDebugOverlay;
diff --git a/src/features/editScene/components/Poi.hpp b/src/features/editScene/components/Poi.hpp
new file mode 100644
index 0000000..afb4e65
--- /dev/null
+++ b/src/features/editScene/components/Poi.hpp
@@ -0,0 +1,66 @@
+#ifndef EDITSCENE_POI_HPP
+#define EDITSCENE_POI_HPP
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include "../roadlib/PoiDatabase.hpp"
+
+/**
+ * POI component (demo-taxi phase 6.8.11): authors a full POI on a
+ * scene entity — the planner data AND the mechanical activation spec
+ * of the unified POI model. The entity's transform is the POI
+ * position (exterior POIs; interior POIs come from the DSL path,
+ * 6.8.6).
+ *
+ * Planner aspect (merged into the area's PoiDatabase by
+ * NpcPopulationSystem): stable poiId, kind, tags, entranceRole,
+ * opening hours and statEffects offered per in-game hour. Queries
+ * work with no runtime activation at all (symbolic tier).
+ *
+ * Mechanical aspect (PoiSystem): activation.kind selects what
+ * happens when an actor reaches the POI — a behavior tree (default,
+ * executed through ActuatorSystem) or a `poi_activated` Lua event
+ * carrying luaScript for scene scripts to handle. Manual = the
+ * player E prompt; OnApproach = fires on entering radius;
+ * OnArrive = fires after settling at the POI. Fired flags and
+ * cooldowns are runtime-only.
+ */
+struct PoiComponent {
+ /** Stable unique POI id (required; e.g. "poi:bus-stop-1"). */
+ Ogre::String poiId;
+ /** Planner kind: "home"/"work"/"leisure"/"interest"/"curb"/
+ * "parking"/"entrance"/"interaction" (free-form string). */
+ Ogre::String kind = "interest";
+ /** Planner tags (job tags, entrance roles, ...). */
+ std::vector tags;
+ /** Entrance role: "" | "front" | "back" | "side". */
+ Ogre::String entranceRole;
+ /** Opening hours in [0,24); openHour < 0 = always open. */
+ float openHour = -1.0f;
+ float closeHour = -1.0f;
+ /** Stat effects offered per in-game hour (stat -> delta). */
+ std::unordered_map statEffects;
+
+ /** Activation: kind as int mirroring PoiDatabase::ActivationKind
+ * (0 none, 1 manual, 2 onApproach, 3 onArrive), behaviorTree
+ * (ActionDatabase action/behavior-tree name) or luaScript
+ * (script path carried by the `poi_activated` event),
+ * radius/cooldownSeconds/once. */
+ int activationKind = 0;
+ Ogre::String behaviorTree;
+ Ogre::String luaScript;
+ float radius = 1.5f;
+ float cooldownSeconds = 0.0f;
+ bool once = false;
+
+ PoiDatabase::Domain domain() const
+ {
+ return PoiDatabase::Domain::Exterior;
+ }
+};
+
+#endif // EDITSCENE_POI_HPP
diff --git a/src/features/editScene/components/PoiModule.cpp b/src/features/editScene/components/PoiModule.cpp
new file mode 100644
index 0000000..57374bd
--- /dev/null
+++ b/src/features/editScene/components/PoiModule.cpp
@@ -0,0 +1,17 @@
+#include "Poi.hpp"
+#include "../ui/ComponentRegistration.hpp"
+#include "../ui/PoiEditor.hpp"
+
+REGISTER_COMPONENT_GROUP("Poi", "Game", PoiComponent, PoiEditor)
+{
+ registry.registerComponent(
+ "Poi", "Game", std::make_unique(),
+ [](flecs::entity e) {
+ if (!e.has())
+ e.set({});
+ },
+ [](flecs::entity e) {
+ if (e.has())
+ e.remove();
+ });
+}
diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md
index 507c703..4d6f395 100644
--- a/src/features/editScene/demos/demo-taxi/PLAN.md
+++ b/src/features/editScene/demos/demo-taxi/PLAN.md
@@ -4537,10 +4537,12 @@ feet sliding.
### Phase 6.8 — DSL macros, symbolic schedules and POIs
-**Status: PLANNED (not started).** 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 done criteria.
+**Status: IN PROGRESS — 6.8.9 stats foundation and 6.8.1 DSL macros
+DONE (2026-10-28); POI model and schedules next.** 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
+done criteria.
#### What this phase is really about
@@ -4674,7 +4676,7 @@ vocabulary but must not be implemented as one blob:
#### Tasks
-**Task 6.8.1 — DSL macro blocks (workstream A).**
+**Task 6.8.1 — DSL macro blocks (workstream A). DONE (2026-10-28).**
- Add `(defmacro name (params...) body...)` (or `(defblock ...)`) to
the layout DSL: named, parametrized fragments stored in
@@ -4692,9 +4694,26 @@ vocabulary but must not be implemented as one blob:
Help window and `LayoutLibrary::examples()` gain a macro section
with one worked example, and `editScene/AGENTS.md` documents the
syntax.
+- **Implemented:** `LayoutLibrary::defineMacro`/`findMacro` +
+ `(defmacro name (params...) body...)` in library sources and in
+ inline layout sources (local per-evaluation definitions, inline
+ wins). Expansion in `LayoutEval::evaluate` before `evalForms`:
+ parameters substituted by NAME (deep copy), nested macro calls,
+ depth cap 16 + 4096-expansion cap, call-chain error messages,
+ duplicate-definition and built-in-name rejection. Tests:
+ `road_geometry_overlap_test` macro block — expansion vs
+ hand-expanded plan equality (cells/rooms/roofs), inline macros,
+ arity errors, recursion cap, duplicates, reserved names, and an
+ unused-macro no-op equality check; help-window example "Macro
+ blocks (defmacro + reuse)" in `LayoutLibrary::examples()`
+ (evaluates cleanly); `editScene/AGENTS.md` "Layout DSL macro
+ blocks (phase 6.8.1)". ctest 8/8.
**Task 6.8.2 — POI and job tag model + index (workstream B, data).**
+**Status: 6.8.2a pure core DONE (2026-10-29); 6.8.2b per-lot/area
+tag overrides + editor UI next.**
+
- Extend `TownLocations` ON TOP of the unified POI database
(6.8.11): Work and Leisure stop being reserved — locations gain
a `tags` set and an optional `entranceRole`; building generation
@@ -4702,6 +4721,40 @@ vocabulary but must not be implemented as one blob:
per-lot/area overrides (shop → `shop`, plus a job tag such as
`salesman`). The LocationSet is a projection of the POI db, so
planner queries see one model.
+- **Implemented (6.8.2a):** `TownBuilding::tags` records the picked
+ layout's `:tags` at template-pick time (library entries,
+ `LayoutLibrary::builtinTags` for district defaults and fallbacks,
+ `findEntry` for explicit edge-side library layouts; inline source
+ and hand-built results stay untagged). `NpcLocation` carries
+ `tags` + `entranceRole`; `buildLocations()` gives every commercial
+ building (its tags carry one of shop/commercial/office/leisure —
+ `isCommercialBuilding`) BOTH a Work location
+ (`work:`, building tags + job tag from
+ `jobTagForBuilding`: shop→salesman, office→clerk, else worker)
+ and a Leisure location (`leisure:`, the building's tags),
+ both anchored at the building's pickup POI (else doorstep) with
+ entrance role `front`, and zoned buildings join NO Interest roll;
+ tagless buildings keep the legacy roll exactly. The
+ `LocationSet` gains a lazy tag→indices + building→indices index
+ (`reindex()`/`indicesWithTag()`/`indicesOfBuilding()`, location
+ order = deterministic). `computeBindings()` prefers Work
+ locations for `npcWork` (Interest fallback for tagless towns) and
+ adds Leisure to the `npcInterest` visit pool. The unified feed
+ carries the source (`feedLocationSet` keeps a location's tags —
+ `{kindName}` for untagged legacy locations — and `entranceRole`)
+ and `projectLocations` preserves both, so planner queries, the
+ POI Database panel and future schedules see one model. "Old
+ towns without tags keep the Interest fallback": every legacy
+ behavior is bit-identical when no building carries commercial
+ tags. Probes: `runTownLocationsProbe` rewritten (commercial
+ zoning on the CityCenter stock town: 12 work + 12 leisure, no
+ interest; Interest-ratio checks moved to a Village town) and the
+ new `runTownTagIndexProbe` (dual audience, job tags, index
+ determinism + building buckets, bindings Work-fallback-Interest,
+ tagless-town compat, feed/projection tag round trip). 6.8.2b
+ remains: per-lot/area tag overrides and the editor's tag
+ display/editing on buildings/lots.
+
- A commercial building yields BOTH kinds: a shop is a Work location
for its employees AND a Leisure location for its customers (same
building, different kind, tags and default entrance role). Do not
@@ -4718,7 +4771,9 @@ vocabulary but must not be implemented as one blob:
stable tags; queries by tag are indexed and deterministic; old
towns without tags keep the Interest fallback.
- Verification: `road_geometry_overlap_test` location/tag/index
- probes; `--test-determinism` unchanged.
+ probes; `--test-determinism` unchanged. (Both green; the full
+ NPC battery passes: determinism/motion/traffic-schedule/detail/
+ taxi/parking/population-switch.)
**Task 6.8.3 — Weekly symbolic schedule generator (workstream B, pure).**
@@ -4824,8 +4879,10 @@ vocabulary but must not be implemented as one blob:
**Task 6.8.9 — Character stats foundation: script-defined stats
(health/money/stress) (workstream B, foundation — do FIRST).**
-**Status: step 1 DONE (2026-10-28) — the standalone container, the
-`ecs.stats.*` Lua API and the headless tests are in.**
+**Status: steps 1-3 DONE (2026-10-28) — the stats foundation is
+complete: the standalone container, the `ecs.stats.*` Lua API, the
+stat rules module, save-game integration and the planner scoring
+hook are in and headless-tested.**
- ONE generic per-character stat container in the engine, NOT three
hardcoded systems: the standalone `CharacterStatStore`
@@ -4877,11 +4934,26 @@ vocabulary but must not be implemented as one blob:
CTest `characterStatsTest`, 9/9: defaults/clamps, opaque ids,
undefine, effective-change listeners, snapshot, serialize/file round
trip, clearCharacter, full Lua round trip) and
-`lua-examples/character_stats_example.lua`. (2) Next: the
-health/money/stress Lua stat modules with per-entry update rules,
-the save-game section and the F7 stat display. (3) Later: the
-planner `scoreLocations` hook (C++ default + Lua override) and the
-hardcoded-stats planner probes.
+`lua-examples/character_stats_example.lua`. (2) DONE 2026-10-28 —
+the health/money/stress stat modules as
+`lua-scripts/character_stats_rules.lua` (demo-owned script package —
+`demos/demo-taxi/lua-scripts/character_stats_rules.lua`; per-activity
+effects as
+DATA + `apply_activity(id, activity, hours)`, emergency thresholds
+with discharge hysteresis), save-game `characterStats` section in
+EditorApp::saveGame/loadGame (old saves start stat-less), the F7
+overlay's per-NPC stat block and test 10/10 for the rules module.
+(3) DONE 2026-10-28 — the planner scoring hook
+`ScheduleScoring` (systems/ScheduleScoring.hpp/.cpp): the pure C++
+`defaultScore` (usage + distance + deterministic name-hash jitter,
+stats ignored) behind `score()`, with an optional read-only override
+hook (C++ or Lua `ecs.schedule.set_score_hook` /
+`clear_score_hook`). Candidates carry name/tags/distance/usage and
+stat-effect vectors; the hook sees only the candidate and the
+week-start snapshot. New headless target `schedule_scoring_test`
+(CTest `scheduleScoringTest`, 3/3: default ordering + jitter,
+C++ override/clear, stats-driven Lua choice with hardcoded
+snapshots) and `lua-examples/schedule_scoring_example.lua`.
**Task 6.8.10 — Stat-driven weekly variety + schedule persistence &
revisions (workstream B, new).**
@@ -4916,6 +4988,10 @@ revisions (workstream B, new).**
**Task 6.8.11 — Unified POI model: shared database + page-managed
activation (workstreams B+C, ground-up POI definition).**
+**Status: steps 1-2 DONE (2026-10-28) — the pure POI database core
+and the generated/authored location feed with a behavior-preserving
+planner projection are in.**
+
- A POI is ONE record with TWO aspects:
- **Planner aspect** (data only, queryable with NO entities
spawned): stable id, anchor (world position / building+cell),
@@ -4967,9 +5043,98 @@ activation (workstreams B+C, ground-up POI definition).**
(approach fires BT, manual E path unchanged); page load/unload
round trip keeps planner output bit-identical.
+**Steps:** (1) DONE 2026-10-28 — `roadlib/PoiDatabase.hpp/.cpp`:
+the pure POI record (id, domain, kind, address/building, position,
+tags, entranceRole, opening hours, stat effects, activation spec),
+the `Set` with id-ordered deterministic queries
+(domain/kind/tagsAll/address/opening hours incl. overnight wrap),
+duplicate/empty-id rejection, tag indexing on finalize, and
+`projectLocations()` mapping only location kinds into
+`TownLocations::LocationSet` (interaction/entrance POIs excluded,
+names/positions/headings preserved). `runPoiDatabaseProbe` in
+`road_geometry_overlap_test` covers all of it; ctest 8/8.
+(2) DONE 2026-10-28 — `PoiDatabase::feedLocationSet` converts the
+generated `LocationSet` into POI records (id = location name, kind
+string, domain from `isIndoorKind`, address/building/position/
+heading, tags = {kind}); `NpcPopulationSystem` stores the POI set
+per area, merges authored `NpcLocationComponent` markers into it
+(name, kind int→string, domain, render-space position via
+RenderOriginSystem for world-position entities, node position
+otherwise; unnamed markers and id collisions are skipped with a
+log), and `areaLocations()` now returns the projection. Pure
+probe: feed + projection is equivalent to the original LocationSet
+by NAME for every kind (incl. parking headings and domains).
+Behavior battery: motion/determinism/taxi/parking/detail all PASS
+bit-identically; ctest 8/8.
+(3) DONE 2026-10-28 — `PoiComponent` (`components/Poi.hpp`, "Game"
+group, `ui/PoiEditor`, serialized as `poi`) authors a full POI on
+a scene entity: planner data (poiId, kind, tags, entranceRole,
+openHour/closeHour, statEffects) plus the activation spec
+(activationKind none/manual/onApproach/onArrive, behaviorTree
+action name or luaScript path, radius/cooldownSeconds/once).
+`NpcPopulationSystem::update` re-merges the authored layer
+(markers + PoiComponents) every 60 frames, replacing exactly the
+recorded authored ids so runtime-added POIs appear within a
+second (gotcha: capture `rec.id` BEFORE
+`set.add(std::move(rec))` — the moved-from id silently emptied
+the authored set and spammed duplicate-id warnings).
+`PoiSystem` (EditorApp-owned, Game+Playing) is the mechanical
+side: per-frame actuator ensure + proximity triggers (player and
+high-detail NPCs; OnArrive = 0.5 s settled dwell), BT through
+`ActuatorSystem::executeAction`, luaScript through the
+`poi_activated` event, per-(actor, poi) cooldowns/once, all
+runtime-only. Editor: Tools -> POI Database (`PoiDatabasePanel`)
+over the unified db with an id/kind/tag filter. Probe
+`--test-poi` (db merge, approach→BT latch, arrive→Lua event,
+manual E-path, cooldown suppression) — it also caught the flecs
+mid-iteration structural-change bug (adding a component inside
+`each()` dangles the iteration's component references; the ensure
+is collect-then-apply BEFORE the pass). (4) 6.8.12 entry POIs
+per enterable building.
+
**Task 6.8.12 — POI domains + the building entry contract
(workstream B, two-tier indoor scheduling).**
+**DONE 2026-10-28.** `PoiDatabase::appendEntries(set, locations)`
+(pure, idempotent) appends one exterior entry POI per enterable
+building — keyed on the "one Home per building at its doorstep"
+invariant: id `entrance:`, kind `entrance`, domain
+Exterior, tag `entrance`, entrance role `front` (generated buildings
+are single-door; multi-door role matching waits for 6.8.8/6.8.13
+content), position = the Home location (the doorstep). The POI set
+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)
+and `approachPosition(set, rec)` (Interior -> the building entry's
+position, entryless/standalone falls back to its own position,
+Exterior -> itself). `projectLocations` now stamps each projected
+location with the explicit domain (`NpcLocation::indoor`) and, for
+interior destinations, the approach anchor
+(`entryPosition`/`hasEntry`); `TownLocations::approachPosition(loc)`
+is the endpoint helper. Consumers: `NpcMotionSystem` paths both
+endpoints through the approach anchors and writes symbolic
+waiting/arrival positions AT the entry (reaching it means "inside" —
+identical values today because Home sits at the doorstep, so all
+clock-pure results stay bit-identical); `NpcDetailSystem::
+isSpawnEligible` reads the stamped domain with the isIndoorKind
+fallback for un-projected sets (the indoor despawn-at-door rule
+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:
+`runPoiDatabaseProbe` extended (append count + idempotence, entry
+fields/doorstep position, index/address lookup, interior/exterior/
+orphan approach resolution, projection domain+entry stamps, no
+entries in the LocationSet); `--test-motion`/`--test-detail` PASS
+(bit-identical positions, 196 transit sightings / 12 spawns),
+`--test-determinism`/`--test-taxi`/`--test-parking`/
+`--test-population-switch`/`--test-poi` PASS unchanged,
+component_lua_test 67/67. Phase 8 later turns the entry POI into a
+real interior scene switch with the interior POI as the final
+destination — the schedule model is unchanged.
+
- Every POI is `domain = exterior | interior`; queries filter by
domain and interior POIs are grouped under their building anchor.
`isIndoorKind` and the spawn-eligibility rules read the domain.
diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp
index 6bb7482..495b479 100644
--- a/src/features/editScene/demos/demo-taxi/demo_main.cpp
+++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp
@@ -24,6 +24,8 @@
#include "systems/ActuatorSystem.hpp"
#include "components/CellGrid.hpp"
#include "components/Actuator.hpp"
+#include "components/Poi.hpp"
+#include "systems/PoiSystem.hpp"
#include "components/AnimationTree.hpp"
#include "components/CharacterSpawner.hpp"
#include "components/RuntimeMarker.hpp"
@@ -1080,12 +1082,28 @@ struct DetailTestListener : public Ogre::FrameListener {
.node
->_getDerivedPosition();
float d = p.distance(rec->position);
- if (d > 0.5f)
- return fail(
- "spawn off the symbolic "
- "position for NPC " +
- std::to_string(id) + " by " +
- std::to_string(d) + " m");
+ /* Phase 4.9.4: a symbolic position that
+ * lands inside a solid (e.g. a car
+ * parked at the same curb anchor the
+ * schedule targets) relocates to the
+ * first free ring spot — legitimate,
+ * bounded by the ring's 5 m max. */
+ if (d > 0.5f) {
+ if (detail->spawnedRelocated(id) &&
+ d <= 5.0f)
+ continue;
+ std::cout << "[dbg] NPC " << id
+ << " d=" << d
+ << " relocated="
+ << detail
+ ->spawnedRelocated(
+ id)
+ << std::endl;
+ return fail("spawn off the symbolic "
+ "position for NPC " +
+ std::to_string(id) + " by " +
+ std::to_string(d) + " m");
+ }
}
if (newSpawns > detail->spawnBudgetPerFrame)
return fail("spawn budget exceeded");
@@ -5107,10 +5125,10 @@ struct TrafficScheduleTestListener : public Ogre::FrameListener {
app->getRoot()->queueEndRendering();
return true;
}
- if (phaseFrame > 20000)
- return fail("car never arrived / driver never "
- "alighted");
- return true;
+ if (phaseFrame > 20000)
+ return fail("car never arrived / driver never "
+ "alighted");
+ return true;
}
return true;
}
@@ -8373,6 +8391,309 @@ struct AnimTravelTestListener : public Ogre::FrameListener {
}
};
+/*
+ * --test-poi (phase 6.8.11): unified POI activation on the exterior
+ * scene (the town is needed for the POI database merge). Spawns
+ * three POI entities near the player and checks:
+ * - the component is merged into the area's PoiDatabase::Set with
+ * its tags/effects/activation spec;
+ * - an OnApproach behavior-tree POI fires when the player comes
+ * within radius (the ActuatorSystem executes the probe action,
+ * which latches a global-state flag);
+ * - an OnArrive Lua POI fires the poi_activated event after the
+ * dwell time;
+ * - a Manual behavior-tree POI executes through the E-press path
+ * (ActuatorSystem::executeAction);
+ * - the cooldown suppresses an immediate re-fire.
+ */
+struct PoiTestListener : public Ogre::FrameListener {
+ EditorApp *app;
+ int frame = 0;
+ int phase = 0;
+ int stepFrame = 0;
+ bool failed = false;
+ Ogre::String failReason;
+ flecs::entity poiA, poiB, poiC;
+ bool manualLogged = false;
+
+ explicit PoiTestListener(EditorApp *a)
+ : app(a)
+ {
+ }
+
+ void fail(const Ogre::String &msg)
+ {
+ failed = true;
+ failReason = msg;
+ std::cout << "[test] poi FAIL: " << msg << std::endl;
+ app->getRoot()->queueEndRendering();
+ }
+
+ void enter(int newPhase)
+ {
+ phase = newPhase;
+ stepFrame = frame;
+ }
+
+ void setupScript()
+ {
+ const char *script = R"(
+ ecs.behavior_tree.register_node("poiProbeMark",
+ function(entity_id, params)
+ ecs.global.set("poi_probe_fired", 1)
+ return "success"
+ end)
+ ecs.action_db.add_action("poi_probe_action", 1, {}, {},
+ { type = "sequence", children = {
+ ecs.behavior_tree.create_node(
+ "poiProbeMark", "") } })
+ ecs.behavior_tree.register_node("poiProbeManual",
+ function(entity_id, params)
+ ecs.global.set("poi_manual_fired", 1)
+ return "success"
+ end)
+ ecs.action_db.add_action("poi_probe_manual_action", 1, {}, {},
+ { type = "sequence", children = {
+ ecs.behavior_tree.create_node(
+ "poiProbeManual", "") } })
+ ecs.subscribe_event("poi_activated",
+ function(event, params)
+ ecs.global.set("poi_arrive_fired", 1)
+ end)
+ )";
+ lua_State *L = app->getLuaState()->getState();
+ if (luaL_dostring(L, script) != LUA_OK) {
+ const char *err = lua_tostring(L, -1);
+ fail(std::string("probe Lua script failed: ") +
+ (err ? err : "unknown error"));
+ lua_pop(L, 1);
+ }
+ }
+
+ flecs::entity makePoi(const char *id, int kind,
+ const Ogre::Vector3 &pos, float radius,
+ float cooldown)
+ {
+ flecs::entity e = app->getWorld()->entity();
+ e.add();
+ TransformComponent t;
+ t.node = app->getSceneManager()
+ ->getRootSceneNode()
+ ->createChildSceneNode();
+ t.position = pos;
+ t.applyToNode();
+ e.set(t);
+ PoiComponent poi;
+ poi.poiId = id;
+ poi.kind = "interest";
+ poi.tags = { "probe" };
+ poi.statEffects = { { "money", 5.0 } };
+ poi.activationKind = kind;
+ poi.behaviorTree = kind == 3 ? "" :
+ (kind == 1 ? "poi_probe_manual_action" :
+ "poi_probe_action");
+ poi.luaScript = kind == 3 ? "probe_arrive.lua" : "";
+ poi.radius = radius;
+ poi.cooldownSeconds = cooldown;
+ e.set(poi);
+ return e;
+ }
+
+ flecs::entity playerChar() const
+ {
+ return app->getPlayerCharacterEntity();
+ }
+
+ void teleportPlayer(const Ogre::Vector3 &pos)
+ {
+ flecs::entity player = playerChar();
+ if (!player.is_alive())
+ return;
+ auto &t = player.get_mut();
+ if (t.node)
+ t.node->setPosition(pos);
+ t.position = pos;
+ t.applyToNode();
+ }
+
+ bool frameRenderingQueued(const Ogre::FrameEvent &) override
+ {
+ frame++;
+ if (failed || phase == 9) {
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ if (frame > 6000) {
+ fail("timeout in phase " + std::to_string(phase));
+ return true;
+ }
+
+ NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
+ PoiSystem *poiSys = app->getPoiSystem();
+ switch (phase) {
+ case 0: {
+ if (frame == 1)
+ setupScript();
+ if (!pop || pop->areaIds().empty())
+ return true;
+ const PoiDatabase::Set *set =
+ pop->areaPoiSet(pop->areaIds()[0]);
+ if (!set || set->pois.empty())
+ return true;
+ flecs::entity player = playerChar();
+ if (!player.is_alive() ||
+ !player.has())
+ return true;
+ const auto &t = player.get();
+ if (!t.node)
+ return true;
+ Ogre::Vector3 base = t.node->getPosition();
+ poiA = makePoi("poi:test-approach", 2,
+ base + Ogre::Vector3(1.0f, 0, 0), 2.0f,
+ 1000.0f);
+ poiB = makePoi("poi:test-arrive", 3,
+ base + Ogre::Vector3(0, 0, 1.0f), 2.0f,
+ 1.0f);
+ poiC = makePoi("poi:test-manual", 1,
+ base + Ogre::Vector3(-1.0f, 0, 0),
+ 2.0f, 0.0f);
+ enter(1);
+ return true;
+ }
+ case 1: {
+ /* The database merge must carry the component's
+ * planner data. */
+ const PoiDatabase::Set *set =
+ pop->areaPoiSet(pop->areaIds()[0]);
+ const PoiDatabase::Record *rec =
+ set ? set->findById("poi:test-approach") :
+ nullptr;
+ if (!rec) {
+ if (frame - stepFrame > 600)
+ fail("POI not merged into the database");
+ return true;
+ }
+ if (rec->kind != "interest" ||
+ rec->tags.size() != 1 ||
+ rec->tags[0] != "probe" ||
+ rec->statEffects.find("money") ==
+ rec->statEffects.end() ||
+ rec->statEffects.at("money") != 5.0 ||
+ rec->activation.kind !=
+ PoiDatabase::ActivationKind::OnApproach) {
+ fail("merged POI record has wrong fields");
+ return true;
+ }
+ /* Stand next to POI A: OnApproach must fire the
+ * behavior tree, which latches the flag. */
+ const auto &t = poiA.get();
+ teleportPlayer(t.node->getPosition() +
+ Ogre::Vector3(0.5f, 0, 0));
+ enter(2);
+ return true;
+ }
+ case 2: {
+ if (GlobalStateStore::getInstance().getInt(
+ "poi_probe_fired", 0) == 1) {
+ std::cout << "[test] OnApproach fired the "
+ "behavior tree"
+ << std::endl;
+ enter(3);
+ return true;
+ }
+ if (frame - stepFrame > 300) {
+ int n = poiSys->getActivationCount(
+ "poi:test-approach");
+ fail(n > 0 ?
+ "OnApproach fired but the behavior "
+ "tree did not latch its flag" :
+ "OnApproach never fired");
+ }
+ return true;
+ }
+ case 3: {
+ /* Stand next to POI B and dwell: poi_activated
+ * must arrive after the 0.5 s settle. */
+ const auto &t = poiB.get();
+ teleportPlayer(t.node->getPosition() +
+ Ogre::Vector3(0.5f, 0, 0));
+ enter(4);
+ return true;
+ }
+ case 4: {
+ if (GlobalStateStore::getInstance().getInt(
+ "poi_arrive_fired", 0) == 1) {
+ std::cout << "[test] OnArrive fired the Lua "
+ "event"
+ << std::endl;
+ enter(5);
+ return true;
+ }
+ if (frame - stepFrame > 300)
+ fail("OnArrive did not fire");
+ return true;
+ }
+ case 5: {
+ /* Manual: the E-press path through
+ * ActuatorSystem::executeAction. */
+ flecs::entity player = playerChar();
+ if (!player.is_alive())
+ return true;
+ app->getActuatorSystem()->executeAction(
+ player, poiC, "poi_probe_manual_action");
+ enter(6);
+ return true;
+ }
+ case 6: {
+ if (GlobalStateStore::getInstance().getInt(
+ "poi_manual_fired", 0) != 1) {
+ if (frame - stepFrame > 120)
+ fail("manual activation never fired");
+ return true;
+ }
+ if (!manualLogged) {
+ manualLogged = true;
+ std::cout << "[test] manual activation "
+ "executed the behavior tree"
+ << std::endl;
+ }
+ /* Cooldown: leave POI A and come back — its
+ * 1000 s cooldown must suppress any re-fire. */
+ if (frame - stepFrame < 30)
+ return true;
+ const auto &t = poiA.get();
+ Ogre::Vector3 pos = t.node->getPosition();
+ teleportPlayer(pos + Ogre::Vector3(50.0f, 0, 0));
+ enter(7);
+ return true;
+ }
+ case 7: {
+ const auto &t = poiA.get();
+ teleportPlayer(t.node->getPosition() +
+ Ogre::Vector3(0.5f, 0, 0));
+ enter(8);
+ return true;
+ }
+ case 8: {
+ if (frame - stepFrame < 60)
+ return true;
+ if (poiSys->getActivationCount("poi:test-approach") >
+ 1) {
+ fail("cooldown allowed a re-fire");
+ return true;
+ }
+ std::cout << "[test] PASS: poi activation (db merge, "
+ "approach, arrive, manual, cooldown)"
+ << std::endl;
+ phase = 9;
+ app->getRoot()->queueEndRendering();
+ return true;
+ }
+ }
+ return true;
+ }
+};
+
/*
* demo-taxi: taxi driver demo. Starts as an exact copy of
* demos/demo-interior-exterior-dynamics (same scene-switch-door setup,
@@ -8586,6 +8907,7 @@ int main(int argc, char *argv[])
bool testParking = false;
bool testFurniture = false;
bool testAnimTravel = false;
+ bool testPoi = false;
bool benchmark = false;
bool benchmarkDrive = false;
float driveSpeed = 13.0f;
@@ -8656,6 +8978,8 @@ int main(int argc, char *argv[])
testFurniture = true;
} else if (arg == "--test-anim-travel") {
testAnimTravel = true;
+ } else if (arg == "--test-poi") {
+ testPoi = true;
} else if (arg == "--benchmark") {
benchmark = true;
} else if (arg == "--benchmark-drive") {
@@ -8938,6 +9262,12 @@ int main(int argc, char *argv[])
if (testAnimTravel)
app.getRoot()->addFrameListener(&animTravelListener);
+ /* Phase 6.8.11 unified POI activation probe: runs on the
+ * exterior scene (town + POI database required). */
+ PoiTestListener poiListener(&app);
+ if (testPoi)
+ app.getRoot()->addFrameListener(&poiListener);
+
BenchmarkListener benchmarkListener(&app);
if (benchmark) {
app.getRoot()->addFrameListener(&benchmarkListener);
@@ -9376,6 +9706,24 @@ int main(int argc, char *argv[])
}
}
+ if (testPoi) {
+ if (poiListener.failed) {
+ std::cerr << "[test] FAIL: "
+ << poiListener.failReason << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ if (poiListener.phase != 9) {
+ std::cerr << "[test] FAIL: incomplete (poi "
+ "phase "
+ << poiListener.phase << ")" << std::endl;
+ app.clearScene();
+ app.closeApp();
+ return 1;
+ }
+ }
+
if (benchmark) {
if (benchmarkListener.failed) {
std::cerr << "[benchmark] FAIL: "
diff --git a/src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua b/src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua
new file mode 100644
index 0000000..7093e72
--- /dev/null
+++ b/src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua
@@ -0,0 +1,70 @@
+-- Character stat rules (phase 6.8.9 step 2): the health/money/stress
+-- stat modules as DATA plus one deterministic apply helper.
+--
+-- The engine stays dumb: this module defines the three stats and how
+-- each schedule ACTIVITY changes them per in-game hour. Everything
+-- here is content -- edit the tables to tune the game, no C++ needed.
+-- Load with require("character_stats_rules") from a scene script (the
+-- LuaScripts resource-group loader maps the module name to this file).
+--
+-- Testing: call M.define() once, then M.apply_activity(id, activity,
+-- hours) with hardcoded ids/activities for exact, predictable values.
+
+local M = {}
+
+-- Execute-once guard: require() caches by module name, but a direct
+-- loadfile()+pcall (test harnesses) bypasses that cache. Always
+-- return the same module table, and never re-register anything.
+if _G.__character_stats_rules_loaded then
+ return _G.__character_stats_rules_loaded
+end
+_G.__character_stats_rules_loaded = M
+
+-- Per-activity stat effects, in stat points PER IN-GAME HOUR.
+-- money: work earns, leisure costs. stress: work adds, leisure and
+-- home rest remove (home recovers slower than leisure). hospital
+-- heals and calms. Activities missing here have no effects.
+M.rules = {
+ work = { money = 10.0, stress = 5.0 },
+ leisure = { money = -5.0, stress = -10.0 },
+ home = { stress = -2.0 },
+ sleep = { stress = -4.0 },
+ hospital = { health = 20.0, stress = -5.0 },
+ clinic = { health = 10.0 },
+}
+
+-- Emergency thresholds (data for 6.8.14): below hospitalBelowHealth
+-- the NPC goes to hospital immediately; health == 0 means
+-- incapacitation and hospitalization until dischargeHealth is
+-- reached. Hysteresis: discharge is well above the trigger so an
+-- NPC cannot oscillate between hospital and work at the threshold.
+M.emergency = {
+ hospitalBelowHealth = 10.0,
+ dischargeHealth = 100.0,
+}
+
+-- Declare the three stats (idempotent; definitions are NOT
+-- persisted, so call this once per session before using the store).
+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 }
+end
+
+-- Apply one completed schedule activity's effects for `hours`
+-- in-game hours (default 1). Unknown activities are a no-op:
+-- locations without effects are valid schedule targets.
+function M.apply_activity(id, activity, hours)
+ hours = hours or 1.0
+ local effects = M.rules[activity]
+ if not effects then
+ return
+ end
+ for stat, perHour in pairs(effects) do
+ if ecs.stats.is_defined(stat) then
+ ecs.stats.add(id, stat, perHour * hours)
+ end
+ end
+end
+
+return M
diff --git a/src/features/editScene/demos/demo-taxi/stage_runtime.cmake b/src/features/editScene/demos/demo-taxi/stage_runtime.cmake
index 5ef1a4a..0d786f9 100644
--- a/src/features/editScene/demos/demo-taxi/stage_runtime.cmake
+++ b/src/features/editScene/demos/demo-taxi/stage_runtime.cmake
@@ -59,7 +59,7 @@ foreach(f char_2.json)
endif()
endforeach()
-foreach(dir resources characters lua-scripts)
+foreach(dir resources characters)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
@@ -67,6 +67,45 @@ foreach(dir resources characters lua-scripts)
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
+# lua-scripts: the demo owns its script package. Demo-specific
+# scripts live in /lua-scripts in the SOURCE tree and are
+# SYMLINKED individually (edits are visible without a rebuild);
+# only the shared modules the demo ACTUALLY USES are linked from
+# the editScene staged pool (explicit per-demo list — unused
+# shared scripts are not staged).
+# Do NOT put demo-specific logic into the shared pools
+# (lua-scripts/ or src/features/editScene/lua-scripts/) — a demo
+# overrides a shared script simply by shipping the same filename.
+set(demo_scripts "${DEMO_DIR}/lua-scripts")
+if(EXISTS "${demo_scripts}" OR IS_SYMLINK "${demo_scripts}")
+ file(REMOVE_RECURSE "${demo_scripts}")
+endif()
+file(MAKE_DIRECTORY "${demo_scripts}")
+# Used by demo-taxi: data2.lua is required by LuaState::lateSetup()
+# and provides the vehicleEnter behavior-tree node. (data.lua,
+# sokoban.lua and json/ are not used by this demo.)
+foreach(name data2.lua)
+ file(CREATE_LINK "${EDITSCENE_BIN}/lua-scripts/${name}"
+ "${demo_scripts}/${name}" SYMBOLIC)
+endforeach()
+# narrator + stories are kept whole for now (no usage audit yet);
+# json/ is kept as a shared utility library for upcoming scripts.
+foreach(sub json narrator stories)
+ if(EXISTS "${EDITSCENE_BIN}/lua-scripts/${sub}")
+ file(CREATE_LINK "${EDITSCENE_BIN}/lua-scripts/${sub}"
+ "${demo_scripts}/${sub}" SYMBOLIC)
+ endif()
+endforeach()
+file(GLOB demo_lua "${SRC_DIR}/lua-scripts/*.lua")
+foreach(src ${demo_lua})
+ get_filename_component(name "${src}" NAME)
+ if(EXISTS "${demo_scripts}/${name}" OR
+ IS_SYMLINK "${demo_scripts}/${name}")
+ file(REMOVE "${demo_scripts}/${name}")
+ endif()
+ file(CREATE_LINK "${src}" "${demo_scripts}/${name}" SYMBOLIC)
+endforeach()
+
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest — the demo needs
# character_registry.json for the spawner's registryId 2). They
diff --git a/src/features/editScene/layoutlib/LayoutEval.cpp b/src/features/editScene/layoutlib/LayoutEval.cpp
index b67b045..f57aac4 100644
--- a/src/features/editScene/layoutlib/LayoutEval.cpp
+++ b/src/features/editScene/layoutlib/LayoutEval.cpp
@@ -31,12 +31,89 @@ void LayoutLibrary::registerLayout(const std::string &name,
e.score = score;
}
+bool LayoutLibrary::defineMacro(const std::string &name,
+ std::vector params,
+ std::vector body, std::string *error)
+{
+ /* Macro names must not shadow evaluation: built-in verbs and
+ * special forms win, so defining one here is an authoring
+ * error, not silent unreachable code. */
+ static const char *reserved[] = {
+ "shell", "floor", "cut", "door", "windows",
+ "room", "idoor", "connect", "roof", "parking",
+ "place", "cell-size", "let", "if", "cond",
+ "dotimes", "for-range", "layout", "defmacro"
+ };
+ for (const char *r : reserved) {
+ if (name == r) {
+ if (error)
+ *error = "macro name '" + name +
+ "' collides with a built-in form";
+ return false;
+ }
+ }
+ if (m_macros.find(name) != m_macros.end()) {
+ if (error)
+ *error = "duplicate macro '" + name + "'";
+ return false;
+ }
+ if (body.empty()) {
+ if (error)
+ *error = "macro '" + name + "' has an empty body";
+ return false;
+ }
+ MacroDef def;
+ def.params = std::move(params);
+ def.body = std::move(body);
+ m_macros[name] = std::move(def);
+ return true;
+}
+
+const LayoutLibrary::MacroDef *
+LayoutLibrary::findMacro(const std::string &name) const
+{
+ auto it = m_macros.find(name);
+ return it == m_macros.end() ? nullptr : &it->second;
+}
+
bool LayoutLibrary::loadSource(const std::string &source, std::string *error)
{
std::vector forms;
if (!parseSExprs(source, forms, error))
return false;
for (const SExpr &f : forms) {
+ /* (defmacro name (params...) body...) — macro blocks
+ * (phase 6.8.1) live next to layouts in the library. */
+ if (f.isList() && f.list.size() >= 2 &&
+ f.list[0].isSymbol("defmacro")) {
+ if (f.list.size() < 4 || !f.list[1].isSymbol() ||
+ !f.list[2].isList()) {
+ if (error)
+ *error = "line " +
+ std::to_string(f.line) +
+ ": expected (defmacro name "
+ "(params...) body...)";
+ return false;
+ }
+ std::vector params;
+ for (const SExpr &p : f.list[2].list) {
+ if (!p.isSymbol()) {
+ if (error)
+ *error = "line " +
+ std::to_string(p.line) +
+ ": macro parameters "
+ "must be symbols";
+ return false;
+ }
+ params.push_back(p.text);
+ }
+ std::vector body(f.list.begin() + 3,
+ f.list.end());
+ if (!defineMacro(f.list[1].text, std::move(params),
+ std::move(body), error))
+ return false;
+ continue;
+ }
if (!f.isList() || f.list.size() < 2 ||
!f.list[0].isSymbol("layout") || !f.list[1].isSymbol()) {
if (error)
@@ -247,6 +324,14 @@ const std::vector &LayoutLibrary::examples()
" (windows all :spacing 4))\n"
" (roof flat 0 0 w (/ d 2))\n"
" (roof flat 0 (/ d 2) (/ w 2) d))\n" },
+ { "Macro blocks (defmacro + reuse)",
+ "(defmacro rect-room (name x0 z0 x1 z1)\n"
+ " (room name (rect x0 z0 x1 z1)))\n"
+ "(defmacro suite (name x0 z0)\n"
+ " (rect-room name x0 z0 (+ x0 0.5) (+ z0 0.5)))\n"
+ "(shell 12 12 :empty)\n"
+ "(rect-room living 0 0 0.5 0.5)\n"
+ "(suite kitchen 0.5 0.5)\n" },
{ "U-shaped house (courtyard to the street)",
"(let ((w 12) (d 12))\n"
" (shell w d :floors 1)\n"
@@ -2071,6 +2156,133 @@ void LayoutEval::warnUnreachableRooms()
/* Entry point */
/* ------------------------------------------------------------------ */
+const LayoutLibrary::MacroDef *
+LayoutEval::findMacro(const std::string &name) const
+{
+ auto it = m_localMacros.find(name);
+ if (it != m_localMacros.end())
+ return &it->second;
+ return m_lib ? m_lib->findMacro(name) : nullptr;
+}
+
+std::string LayoutEval::macroChain() const
+{
+ std::string chain;
+ for (const auto &name : m_macroChain) {
+ if (!chain.empty())
+ chain += " -> ";
+ chain += name;
+ }
+ return chain.empty() ? std::string("") : chain;
+}
+
+bool LayoutEval::collectMacroDef(
+ const SExpr &form,
+ std::map &env)
+{
+ if (form.list.size() < 4 || !form.list[1].isSymbol() ||
+ !form.list[2].isList()) {
+ m_error = "line " + std::to_string(form.line) +
+ ": expected (defmacro name (params...) body...)";
+ return false;
+ }
+ LayoutLibrary::MacroDef def;
+ for (const SExpr &p : form.list[2].list) {
+ if (!p.isSymbol()) {
+ m_error = "line " + std::to_string(p.line) +
+ ": macro parameters must be symbols";
+ return false;
+ }
+ def.params.push_back(p.text);
+ }
+ def.body.assign(form.list.begin() + 3, form.list.end());
+ if (env.find(form.list[1].text) != env.end()) {
+ m_error = "duplicate macro '" + form.list[1].text + "'";
+ return false;
+ }
+ env[form.list[1].text] = std::move(def);
+ return true;
+}
+
+SExpr LayoutEval::substitute(
+ const SExpr &form, const std::map &args)
+{
+ if (form.isSymbol()) {
+ auto it = args.find(form.text);
+ if (it != args.end())
+ return *it->second; /* deep copy of the argument */
+ return form;
+ }
+ SExpr copy = form;
+ copy.list.clear();
+ for (const SExpr &child : form.list)
+ copy.list.push_back(substitute(child, args));
+ return copy;
+}
+
+bool LayoutEval::expandForm(const SExpr &form, std::vector &out,
+ int depth)
+{
+ if (!form.isList() || form.list.empty() ||
+ !form.list[0].isSymbol()) {
+ out.push_back(form);
+ return true;
+ }
+
+ const LayoutLibrary::MacroDef *def = findMacro(form.list[0].text);
+ if (!def) {
+ /* Not a macro call: copy, expanding nested forms. */
+ SExpr copy = form;
+ copy.list.clear();
+ for (const SExpr &child : form.list) {
+ if (!expandForm(child, copy.list, depth))
+ return false;
+ }
+ out.push_back(std::move(copy));
+ return true;
+ }
+
+ if (m_macroExpansions >= 4096 || depth >= 16) {
+ m_error = "macro expansion limit reached at '" +
+ form.list[0].text +
+ "' (recursion? call chain: " + macroChain() + ")";
+ return false;
+ }
+ if (form.list.size() - 1 != def->params.size()) {
+ m_error = "macro '" + form.list[0].text + "' expects " +
+ std::to_string(def->params.size()) + " args, got " +
+ std::to_string(form.list.size() - 1) +
+ " (call chain: " + macroChain() + ")";
+ return false;
+ }
+
+ ++m_macroExpansions;
+ std::map args;
+ for (size_t i = 0; i < def->params.size(); ++i)
+ args[def->params[i]] = &form.list[i + 1];
+ m_macroChain.push_back(form.list[0].text);
+ for (const SExpr &bodyForm : def->body) {
+ SExpr substituted = substitute(bodyForm, args);
+ if (!expandForm(substituted, out, depth + 1))
+ return false;
+ }
+ m_macroChain.pop_back();
+ return true;
+}
+
+bool LayoutEval::expandMacros(const std::vector &in,
+ std::vector &out)
+{
+ out.clear();
+ m_macroChain.clear();
+ m_macroExpansions = 0;
+ for (const SExpr &form : in) {
+ if (!expandForm(form, out, 0))
+ return false;
+ }
+ return true;
+}
+
bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
std::string *error,
std::vector *warnings)
@@ -2111,6 +2323,8 @@ bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
const std::vector *body = nullptr;
std::vector parsed;
+ std::vector expandedBody;
+ m_localMacros.clear();
size_t first = sourceOrName.find_first_not_of(" \t\r\n");
if (first != std::string::npos && sourceOrName[first] == '(') {
if (!parseSExprs(sourceOrName, parsed, &m_error)) {
@@ -2118,7 +2332,28 @@ bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
*error = m_error;
return false;
}
- body = &parsed;
+ /* Inline sources may define their own macros: collect
+ * the definitions first (order-independent), then
+ * expand the remaining forms. */
+ std::vector forms;
+ for (const SExpr &form : parsed) {
+ if (form.isList() && !form.list.empty() &&
+ form.list[0].isSymbol("defmacro")) {
+ if (!collectMacroDef(form, m_localMacros)) {
+ if (error)
+ *error = m_error;
+ return false;
+ }
+ } else {
+ forms.push_back(form);
+ }
+ }
+ if (!expandMacros(forms, expandedBody)) {
+ if (error)
+ *error = m_error;
+ return false;
+ }
+ body = &expandedBody;
} else {
/* Layout name: explicit library first, then built-ins. */
const std::vector *found =
@@ -2131,7 +2366,12 @@ bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
"'";
return false;
}
- body = found;
+ if (!expandMacros(*found, expandedBody)) {
+ if (error)
+ *error = m_error;
+ return false;
+ }
+ body = &expandedBody;
}
if (!evalForms(*body, 0)) {
diff --git a/src/features/editScene/layoutlib/LayoutEval.hpp b/src/features/editScene/layoutlib/LayoutEval.hpp
index b3c83c2..8fe23e1 100644
--- a/src/features/editScene/layoutlib/LayoutEval.hpp
+++ b/src/features/editScene/layoutlib/LayoutEval.hpp
@@ -105,6 +105,21 @@ public:
/** Register (or replace) a named layout with metadata. */
void registerLayout(const std::string &name, std::vector body,
std::vector tags, double score);
+
+ /** One named macro (phase 6.8.1): positional parameters
+ * substituted by NAME into the body at expansion time. Purely
+ * syntactic — no evaluation happens during expansion, so lot
+ * RNG draw order and determinism are untouched. */
+ struct MacroDef {
+ std::vector params;
+ std::vector body;
+ };
+ /** Define a macro. Rejects duplicates and names of built-in
+ * verbs/special forms (which would shadow evaluation). */
+ bool defineMacro(const std::string &name,
+ std::vector params,
+ std::vector body, std::string *error);
+ const MacroDef *findMacro(const std::string &name) const;
/** Load "(layout name [:tags (tag ...)] [:score n] body...)" forms
* from source text. */
bool loadSource(const std::string &source, std::string *error);
@@ -148,6 +163,7 @@ public:
private:
std::map m_layouts;
+ std::map m_macros;
};
class LayoutEval
@@ -260,6 +276,24 @@ private:
bool verbParking(const SExpr &form);
bool verbPlace(const SExpr &form);
+ /* Phase 6.8.1 macro expansion, run over the resolved body
+ * before evaluation. Local (inline-source) definitions win
+ * over library ones. Depth and total-expansion caps plus a
+ * call chain make recursive/broken macros hard errors with a
+ * readable message instead of a hang. */
+ bool expandMacros(const std::vector &in,
+ std::vector &out);
+ bool expandForm(const SExpr &form, std::vector &out,
+ int depth);
+ SExpr substitute(const SExpr &form,
+ const std::map &args);
+ const LayoutLibrary::MacroDef *findMacro(
+ const std::string &name) const;
+ bool collectMacroDef(const SExpr &form,
+ std::map
+ &env);
+ std::string macroChain() const;
+
int sideIndex(const std::string &side) const;
/* True when the cell belongs to a room declared with
* :windows none (scans the current floor's room rects, so it
@@ -297,6 +331,10 @@ private:
std::vector > m_scopes;
/* rooms per floor: floor -> (name -> rect) */
std::map > m_rooms;
+ /* Macro definitions local to one inline-source evaluation. */
+ std::map m_localMacros;
+ std::vector m_macroChain;
+ int m_macroExpansions = 0;
/* roomIds index for a room name of the current evaluation:
* name -> index into BuildingPlan::roomNames. */
std::map m_roomNameIds;
diff --git a/src/features/editScene/lua-examples/schedule_scoring_example.lua b/src/features/editScene/lua-examples/schedule_scoring_example.lua
new file mode 100644
index 0000000..c3ffef9
--- /dev/null
+++ b/src/features/editScene/lua-examples/schedule_scoring_example.lua
@@ -0,0 +1,57 @@
+-- Planner location scoring (phase 6.8.9 step 3): how the weekly
+-- scheduler answers "which location is best for this NPC?".
+--
+-- The default score is pure C++ (less usage + shorter distance +
+-- deterministic jitter, stats ignored). Install a READ-ONLY Lua hook
+-- to let stats steer the choice: it receives the candidate location
+-- and the NPC's week-start stats snapshot and returns one number
+-- (higher = better). Hardcode the snapshot in tests for exact,
+-- predictable scores.
+
+local function scoreForNpc(candidate, snapshot)
+ local score = 0.0
+
+ -- candidate: { name, tags = {...}, distance, usage,
+ -- effects = { stat = perHour, ... } }
+ -- snapshot: { stat = value, ... } (health/money/stress/...)
+
+ -- Poor NPCs (money < 50) value locations that pay well.
+ if (snapshot.money or 0) < 50 then
+ score = score + (candidate.effects.money or 0) * 2
+ end
+
+ -- Stressed NPCs (stress > 60) value calming locations and
+ -- avoid stressful ones (work effects add stress, leisure
+ -- effects remove it).
+ if (snapshot.stress or 0) > 60 then
+ score = score - (candidate.effects.stress or 0) * 3
+ end
+
+ -- Low health prefers rest/clinic effects over everything else.
+ if (snapshot.health or 100) < 20 then
+ score = score + (candidate.effects.health or 0) * 5
+ end
+
+ return score
+end
+
+ecs.schedule.set_score_hook(scoreForNpc)
+
+-- The scheduler then simply ranks candidates by ecs.schedule.score.
+-- Example with hardcoded data (this is exactly how the headless
+-- schedule_scoring_test drives it):
+local jobA = { name = "job:a", tags = { "job" }, distance = 0,
+ usage = 0, effects = { money = 10, stress = 5 } }
+local jobB = { name = "job:b", tags = { "job" }, distance = 0,
+ usage = 0, effects = { money = 1, stress = -10 } }
+
+-- Poor: A (money 10 * 2 = 20) beats B (money 1 * 2 = 2).
+assert(ecs.schedule.score(jobA, { money = 10, stress = 0 }) == 20.0)
+assert(ecs.schedule.score(jobB, { money = 10, stress = 0 }) == 2.0)
+
+-- Rich and stressed: B (stress -10 * -3 = 30) beats A (5 * -3 = -15).
+assert(ecs.schedule.score(jobA, { money = 1000, stress = 90 }) == -15.0)
+assert(ecs.schedule.score(jobB, { money = 1000, stress = 90 }) == 30.0)
+
+-- clear_score_hook() restores the pure C++ default scoring.
+ecs.schedule.clear_score_hook()
diff --git a/src/features/editScene/lua-scripts/data2.lua b/src/features/editScene/lua-scripts/data2.lua
index 9ff5b94..aa68f32 100644
--- a/src/features/editScene/lua-scripts/data2.lua
+++ b/src/features/editScene/lua-scripts/data2.lua
@@ -1,3 +1,11 @@
+-- Execute-once guard: lateSetup() runs once per session, but scene
+-- switches and test harnesses may evaluate this file again. Node
+-- registrations and event subscriptions must not duplicate.
+if _G.__data2_loaded then
+ return
+end
+_G.__data2_loaded = true
+
print("hello!")
ecs.behavior_tree.register_node("luaHello", function(entity_id, params)
diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp
index aa0ae35..51ac63a 100644
--- a/src/features/editScene/lua/LuaComponentApi.cpp
+++ b/src/features/editScene/lua/LuaComponentApi.cpp
@@ -38,6 +38,7 @@
#include "components/CharacterIdentity.hpp"
#include "systems/CharacterRegistry.hpp"
#include "components/AnimationTree.hpp"
+#include "components/Poi.hpp"
#include "components/Character.hpp"
#include "components/StartupMenu.hpp"
#include "components/PlayerController.hpp"
@@ -788,6 +789,93 @@ static void registerAllComponents()
c.enabled = lua_toboolean(L, -1) != 0;
lua_pop(L, 1););
+ // --- Poi (phase 6.8.11 unified POI) ---
+ REGISTER_COMPONENT(
+ PoiComponent, "Poi",
+ lua_pushstring(L, c.poiId.c_str());
+ lua_setfield(L, -2, "poiId");
+ lua_pushstring(L, c.kind.c_str());
+ lua_setfield(L, -2, "kind");
+ pushStringVector(L, c.tags);
+ lua_setfield(L, -2, "tags");
+ lua_pushstring(L, c.entranceRole.c_str());
+ lua_setfield(L, -2, "entranceRole");
+ lua_pushnumber(L, c.openHour);
+ lua_setfield(L, -2, "openHour");
+ lua_pushnumber(L, c.closeHour);
+ lua_setfield(L, -2, "closeHour");
+ lua_newtable(L);
+ for (const auto &kv : c.statEffects) {
+ lua_pushnumber(L, kv.second);
+ lua_setfield(L, -2, kv.first.c_str());
+ }
+ lua_setfield(L, -2, "statEffects");
+ lua_pushinteger(L, c.activationKind);
+ lua_setfield(L, -2, "activationKind");
+ lua_pushstring(L, c.behaviorTree.c_str());
+ lua_setfield(L, -2, "behaviorTree");
+ lua_pushstring(L, c.luaScript.c_str());
+ lua_setfield(L, -2, "luaScript");
+ lua_pushnumber(L, c.radius);
+ lua_setfield(L, -2, "radius");
+ lua_pushnumber(L, c.cooldownSeconds);
+ lua_setfield(L, -2, "cooldownSeconds");
+ lua_pushboolean(L, c.once ? 1 : 0);
+ lua_setfield(L, -2, "once");
+ , if (lua_getfield(L, idx, "poiId"), lua_isstring(L, -1))
+ c.poiId = lua_tostring(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "kind"), lua_isstring(L, -1))
+ c.kind = lua_tostring(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "tags"), lua_istable(L, -1))
+ c.tags = readStringVector(L, lua_gettop(L));
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "entranceRole"),
+ lua_isstring(L, -1))
+ c.entranceRole = lua_tostring(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "openHour"), lua_isnumber(L, -1))
+ c.openHour = (float)lua_tonumber(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "closeHour"), lua_isnumber(L, -1))
+ c.closeHour = (float)lua_tonumber(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "statEffects"),
+ lua_istable(L, -1)) {
+ c.statEffects.clear();
+ lua_pushnil(L);
+ while (lua_next(L, -2) != 0) {
+ if (lua_isstring(L, -2) &&
+ lua_isnumber(L, -1))
+ c.statEffects[lua_tostring(L, -2)] =
+ lua_tonumber(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "activationKind"),
+ lua_isnumber(L, -1))
+ c.activationKind = (int)lua_tointeger(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "behaviorTree"),
+ lua_isstring(L, -1))
+ c.behaviorTree = lua_tostring(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "luaScript"), lua_isstring(L, -1))
+ c.luaScript = lua_tostring(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "radius"), lua_isnumber(L, -1))
+ c.radius = (float)lua_tonumber(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "cooldownSeconds"),
+ lua_isnumber(L, -1))
+ c.cooldownSeconds = (float)lua_tonumber(L, -1);
+ lua_pop(L, 1);
+ if (lua_getfield(L, idx, "once"), lua_isboolean(L, -1))
+ c.once = lua_toboolean(L, -1) != 0;
+ lua_pop(L, 1););
+
// --- SceneScript ---
REGISTER_COMPONENT(
SceneScriptComponent, "SceneScript",
diff --git a/src/features/editScene/lua/LuaScheduleScoringApi.cpp b/src/features/editScene/lua/LuaScheduleScoringApi.cpp
new file mode 100644
index 0000000..7021c5c
--- /dev/null
+++ b/src/features/editScene/lua/LuaScheduleScoringApi.cpp
@@ -0,0 +1,172 @@
+#include "LuaScheduleScoringApi.hpp"
+#include "../systems/ScheduleScoring.hpp"
+#include
+
+namespace editScene
+{
+
+namespace
+{
+
+lua_State *g_L = nullptr;
+int g_hookRef = LUA_NOREF;
+
+void pushSnapshot(lua_State *L, const ScheduleScoring::StatsSnapshot &snap)
+{
+ lua_newtable(L);
+ for (const auto &pair : snap) {
+ lua_pushnumber(L, pair.second);
+ lua_setfield(L, -2, pair.first.c_str());
+ }
+}
+
+void pushCandidate(lua_State *L, const ScheduleScoring::Candidate &c)
+{
+ lua_newtable(L);
+ lua_pushstring(L, c.name.c_str());
+ lua_setfield(L, -2, "name");
+ lua_newtable(L);
+ for (size_t i = 0; i < c.tags.size(); ++i) {
+ lua_pushstring(L, c.tags[i].c_str());
+ lua_rawseti(L, -2, (lua_Integer)i + 1);
+ }
+ lua_setfield(L, -2, "tags");
+ lua_pushnumber(L, c.distanceMeters);
+ lua_setfield(L, -2, "distance");
+ lua_pushinteger(L, c.usage);
+ lua_setfield(L, -2, "usage");
+ pushSnapshot(L, c.effects);
+ lua_setfield(L, -2, "effects");
+}
+
+ScheduleScoring::Candidate readCandidate(lua_State *L, int index)
+{
+ ScheduleScoring::Candidate c;
+ luaL_checktype(L, index, LUA_TTABLE);
+ lua_getfield(L, index, "name");
+ c.name = luaL_optstring(L, -1, "");
+ lua_pop(L, 1);
+ lua_getfield(L, index, "tags");
+ if (lua_istable(L, -1)) {
+ for (int i = 1;; ++i) {
+ lua_rawgeti(L, -1, i);
+ if (lua_isnil(L, -1)) {
+ lua_pop(L, 1);
+ break;
+ }
+ c.tags.push_back(lua_tostring(L, -1));
+ lua_pop(L, 1);
+ }
+ }
+ lua_pop(L, 1);
+ lua_getfield(L, index, "distance");
+ c.distanceMeters = luaL_optnumber(L, -1, 0.0);
+ lua_pop(L, 1);
+ lua_getfield(L, index, "usage");
+ c.usage = (int)luaL_optinteger(L, -1, 0);
+ lua_pop(L, 1);
+ lua_getfield(L, index, "effects");
+ if (lua_istable(L, -1)) {
+ lua_pushnil(L);
+ while (lua_next(L, -2) != 0) {
+ c.effects[lua_tostring(L, -2)] = lua_tonumber(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+ lua_pop(L, 1);
+ return c;
+}
+
+ScheduleScoring::StatsSnapshot readSnapshot(lua_State *L, int index)
+{
+ ScheduleScoring::StatsSnapshot snap;
+ luaL_checktype(L, index, LUA_TTABLE);
+ lua_pushnil(L);
+ while (lua_next(L, index) != 0) {
+ snap[lua_tostring(L, -2)] = lua_tonumber(L, -1);
+ lua_pop(L, 1);
+ }
+ return snap;
+}
+
+double luaScoreHook(const ScheduleScoring::Candidate &candidate,
+ const ScheduleScoring::StatsSnapshot &snapshot)
+{
+ lua_State *L = g_L;
+ lua_rawgeti(L, LUA_REGISTRYINDEX, g_hookRef);
+ pushCandidate(L, candidate);
+ pushSnapshot(L, snapshot);
+ if (lua_pcall(L, 2, 1, 0) != LUA_OK) {
+ std::cerr << "[schedule] score hook error: "
+ << lua_tostring(L, -1) << std::endl;
+ lua_pop(L, 1);
+ return ScheduleScoring::defaultScore(candidate, snapshot);
+ }
+ double result = luaL_optnumber(L, -1, 0.0);
+ lua_pop(L, 1);
+ return result;
+}
+
+int luaSetScoreHook(lua_State *L)
+{
+ luaL_checktype(L, 1, LUA_TFUNCTION);
+ if (g_hookRef != LUA_NOREF)
+ luaL_unref(L, LUA_REGISTRYINDEX, g_hookRef);
+ g_L = L;
+ lua_pushvalue(L, 1);
+ g_hookRef = luaL_ref(L, LUA_REGISTRYINDEX);
+ ScheduleScoring::setHook(luaScoreHook);
+ return 0;
+}
+
+int luaClearScoreHook(lua_State *L)
+{
+ if (g_hookRef != LUA_NOREF) {
+ luaL_unref(L, LUA_REGISTRYINDEX, g_hookRef);
+ g_hookRef = LUA_NOREF;
+ }
+ ScheduleScoring::clearHook();
+ return 0;
+}
+
+int luaScore(lua_State *L)
+{
+ auto candidate = readCandidate(L, 1);
+ auto snapshot = readSnapshot(L, 2);
+ lua_pushnumber(L, ScheduleScoring::score(candidate, snapshot));
+ return 1;
+}
+
+int luaDefaultScore(lua_State *L)
+{
+ auto candidate = readCandidate(L, 1);
+ auto snapshot = readSnapshot(L, 2);
+ lua_pushnumber(L, ScheduleScoring::defaultScore(candidate, snapshot));
+ return 1;
+}
+
+} // namespace
+
+void registerLuaScheduleScoringApi(lua_State *L)
+{
+ lua_getglobal(L, "ecs");
+ if (!lua_istable(L, -1)) {
+ lua_pop(L, 1);
+ return;
+ }
+
+ lua_newtable(L);
+ lua_pushcfunction(L, luaSetScoreHook);
+ lua_setfield(L, -2, "set_score_hook");
+ lua_pushcfunction(L, luaClearScoreHook);
+ lua_setfield(L, -2, "clear_score_hook");
+ lua_pushcfunction(L, luaScore);
+ lua_setfield(L, -2, "score");
+ lua_pushcfunction(L, luaDefaultScore);
+ lua_setfield(L, -2, "default_score");
+ lua_setfield(L, -2, "schedule");
+
+ lua_pop(L, 1);
+}
+
+} // namespace editScene
diff --git a/src/features/editScene/lua/LuaScheduleScoringApi.hpp b/src/features/editScene/lua/LuaScheduleScoringApi.hpp
new file mode 100644
index 0000000..8cca454
--- /dev/null
+++ b/src/features/editScene/lua/LuaScheduleScoringApi.hpp
@@ -0,0 +1,31 @@
+#ifndef EDITSCENE_LUASCHEDULESCORINGAPI_HPP
+#define EDITSCENE_LUASCHEDULESCORINGAPI_HPP
+#pragma once
+
+#include
+
+namespace editScene
+{
+
+/**
+ * Lua binding for the planner location scoring hook (phase 6.8.9
+ * step 3), creating the "schedule" table inside "ecs":
+ *
+ * ecs.schedule.set_score_hook(function(candidate, snapshot)
+ * -- READ-ONLY: compute a number (higher = better location).
+ * -- candidate: { name, tags = {...}, distance, usage,
+ * -- effects = { stat = perHour, ... } }
+ * -- snapshot: { stat = value, ... } (week-start NPC stats)
+ * return candidate.effects.money or 0
+ * end)
+ * ecs.schedule.clear_score_hook()
+ * ecs.schedule.score(candidate, snapshot) --> number
+ * ecs.schedule.default_score(candidate, snapshot) --> number
+ *
+ * The hook must not mutate game state; it only reads its arguments.
+ */
+void registerLuaScheduleScoringApi(lua_State *L);
+
+} // namespace editScene
+
+#endif // EDITSCENE_LUASCHEDULESCORINGAPI_HPP
diff --git a/src/features/editScene/roadlib/PoiDatabase.cpp b/src/features/editScene/roadlib/PoiDatabase.cpp
new file mode 100644
index 0000000..966c08f
--- /dev/null
+++ b/src/features/editScene/roadlib/PoiDatabase.cpp
@@ -0,0 +1,280 @@
+#include "PoiDatabase.hpp"
+#include
+
+namespace PoiDatabase
+{
+
+bool Set::add(Record rec, std::string *error)
+{
+ if (rec.id.empty()) {
+ if (error)
+ *error = "POI with an empty id";
+ return false;
+ }
+ for (const Record &other : pois) {
+ if (other.id == rec.id) {
+ if (error)
+ *error = "duplicate POI id '" + rec.id + "'";
+ return false;
+ }
+ }
+ pois.push_back(std::move(rec));
+ m_finalized = false;
+ return true;
+}
+
+void Set::finalize()
+{
+ std::sort(pois.begin(), pois.end(),
+ [](const Record &a, const Record &b) { return a.id < b.id; });
+ m_tagIndex.clear();
+ for (size_t i = 0; i < pois.size(); ++i) {
+ for (const auto &tag : pois[i].tags)
+ m_tagIndex[tag].push_back((int)i);
+ }
+ m_finalized = true;
+}
+
+const Record *Set::findById(const std::string &id) const
+{
+ for (const Record &rec : pois) {
+ if (rec.id == id)
+ return &rec;
+ }
+ return nullptr;
+}
+
+std::vector Set::query(const Query &q) const
+{
+ (void)m_finalized; /* queries also work before finalize, just
+ * unsorted and unindexed */
+ std::vector out;
+ /* Start from the smallest required-tag bucket when an index
+ * exists; otherwise scan everything. */
+ std::vector candidates;
+ bool indexed = false;
+ for (const auto &tag : q.tagsAll) {
+ auto it = m_tagIndex.find(tag);
+ if (it == m_tagIndex.end()) {
+ /* An indexed tag that no POI carries: the
+ * intersection is empty by construction. */
+ if (m_finalized)
+ return out;
+ continue;
+ }
+ if (!indexed || it->second.size() < candidates.size()) {
+ candidates = it->second;
+ indexed = true;
+ }
+ }
+ if (!indexed) {
+ candidates.reserve(pois.size());
+ for (size_t i = 0; i < pois.size(); ++i)
+ candidates.push_back((int)i);
+ }
+
+ for (int i : candidates) {
+ const Record &rec = pois[i];
+ if (q.domain >= 0 && (int)rec.domain != q.domain)
+ continue;
+ if (!q.kind.empty() && rec.kind != q.kind)
+ continue;
+ if (!q.address.empty() && rec.address != q.address)
+ continue;
+ bool tagsOk = true;
+ for (const auto &tag : q.tagsAll) {
+ if (std::find(rec.tags.begin(), rec.tags.end(), tag) ==
+ rec.tags.end()) {
+ tagsOk = false;
+ break;
+ }
+ }
+ if (!tagsOk)
+ continue;
+ if (q.openAtHour && rec.openHour >= 0.0f) {
+ float open = rec.openHour;
+ float close = rec.closeHour;
+ bool openNow =
+ (open <= close) ?
+ (q.hour >= open && q.hour < close) :
+ (q.hour >= open || q.hour < close);
+ if (!openNow)
+ continue;
+ }
+ out.push_back(i);
+ }
+ return out;
+}
+
+bool hasEffect(const Record &rec, const std::string &stat)
+{
+ auto it = rec.statEffects.find(stat);
+ return it != rec.statEffects.end() && it->second != 0.0;
+}
+
+/* ----- Building entry contract (6.8.12) ----- */
+
+std::string entryId(const std::string &address)
+{
+ return "entrance:" + address;
+}
+
+int appendEntries(Set &set, const TownLocations::LocationSet &locations)
+{
+ int appended = 0;
+ for (const TownLocations::NpcLocation &loc : locations.locations) {
+ if (loc.kind != TownLocations::LocationKind::Home ||
+ loc.building < 0 || loc.address.empty())
+ continue;
+ const std::string id = entryId(loc.address);
+ 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;
+ /* The entry sits AT the doorstep — the same anchor the
+ * Home location uses — so the generated baseline keeps
+ * today's paths bit-identical. */
+ rec.position = loc.position;
+ rec.tags = { "entrance" };
+ /* Generated buildings are single-door: front. */
+ rec.entranceRole = "front";
+ if (set.add(std::move(rec)))
+ ++appended;
+ }
+ return appended;
+}
+
+const Record *entryOfBuilding(const Set &set, int building,
+ const std::string &address)
+{
+ const Record *byAddress = nullptr;
+ for (const Record &rec : set.pois) {
+ if (rec.kind != "entrance")
+ continue;
+ if (building >= 0 && rec.building == building)
+ return &rec;
+ if (byAddress == nullptr && !address.empty() &&
+ rec.address == address)
+ byAddress = &rec;
+ }
+ return byAddress;
+}
+
+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);
+ return entry ? entry->position : rec.position;
+}
+
+TownLocations::LocationSet projectLocations(const Set &set)
+{
+ TownLocations::LocationSet out;
+ for (const Record &rec : set.pois) {
+ TownLocations::LocationKind kind;
+ if (rec.kind == "home")
+ kind = TownLocations::LocationKind::Home;
+ else if (rec.kind == "work")
+ kind = TownLocations::LocationKind::Work;
+ else if (rec.kind == "leisure")
+ kind = TownLocations::LocationKind::Leisure;
+ else if (rec.kind == "interest")
+ kind = TownLocations::LocationKind::Interest;
+ else if (rec.kind == "curb")
+ kind = TownLocations::LocationKind::Curb;
+ else if (rec.kind == "parking")
+ kind = TownLocations::LocationKind::Parking;
+ else
+ continue; /* interaction/entrance POIs are not
+ * schedule locations */
+
+ TownLocations::NpcLocation loc;
+ loc.id = (int)out.locations.size();
+ loc.kind = kind;
+ loc.name = rec.id;
+ loc.address = rec.address;
+ loc.position = rec.position;
+ loc.building = rec.building;
+ loc.hasHeading = rec.hasHeading;
+ loc.headingYaw = rec.headingYaw;
+ /* 6.8.12: the explicit domain and, for interior
+ * destinations, the building entry as the approach
+ * anchor (paths/arrivals use it; reaching it means
+ * "inside"). */
+ loc.indoor = rec.domain == Domain::Interior;
+ if (loc.indoor) {
+ const Record *entry =
+ entryOfBuilding(set, rec.building, rec.address);
+ if (entry) {
+ loc.entryPosition = entry->position;
+ loc.hasEntry = true;
+ }
+ }
+ /* 6.8.2: tags and the default entrance role travel with
+ * the projection (planner tag queries index the
+ * LocationSet). */
+ loc.tags = rec.tags;
+ loc.entranceRole = rec.entranceRole;
+ out.locations.push_back(loc);
+ }
+ return out;
+}
+
+const char *kindName(TownLocations::LocationKind kind)
+{
+ switch (kind) {
+ case TownLocations::LocationKind::Home:
+ return "home";
+ case TownLocations::LocationKind::Work:
+ return "work";
+ case TownLocations::LocationKind::Leisure:
+ return "leisure";
+ case TownLocations::LocationKind::Interest:
+ return "interest";
+ case TownLocations::LocationKind::Curb:
+ return "curb";
+ case TownLocations::LocationKind::Parking:
+ return "parking";
+ default:
+ return "interest";
+ }
+}
+
+Set feedLocationSet(const TownLocations::LocationSet &locations)
+{
+ Set set;
+ for (const TownLocations::NpcLocation &loc : locations.locations) {
+ Record rec;
+ rec.id = loc.name;
+ rec.kind = kindName(loc.kind);
+ rec.domain = TownLocations::isIndoorKind(loc.kind) ?
+ Domain::Interior :
+ Domain::Exterior;
+ rec.address = loc.address;
+ rec.building = loc.building;
+ rec.position = loc.position;
+ rec.hasHeading = loc.hasHeading;
+ rec.headingYaw = loc.headingYaw;
+ /* 6.8.2: a tagged location carries its real tags into
+ * the database; untagged (legacy) locations keep the
+ * kind-name tag so tag queries still see them. */
+ rec.tags = loc.tags.empty() ?
+ std::vector{ rec.kind } :
+ loc.tags;
+ rec.entranceRole = loc.entranceRole;
+ /* Stat effects and activation are left empty for the
+ * generated baseline (richer authoring paths fill
+ * them). */
+ set.add(std::move(rec));
+ }
+ set.finalize();
+ return set;
+}
+
+} // namespace PoiDatabase
diff --git a/src/features/editScene/roadlib/PoiDatabase.hpp b/src/features/editScene/roadlib/PoiDatabase.hpp
new file mode 100644
index 0000000..8ad1efd
--- /dev/null
+++ b/src/features/editScene/roadlib/PoiDatabase.hpp
@@ -0,0 +1,184 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include "TownLocations.hpp"
+
+/**
+ * Unified POI model (demo-taxi phase 6.8.11).
+ *
+ * A POI is ONE record with TWO aspects:
+ * - Planner data (this file, pure C++, no ECS): stable id, domain
+ * (interior | exterior), kind, anchor (world position / building),
+ * tags, entrance role, opening hours and stat effects offered per
+ * in-game hour. Queryable with NO entities spawned, so symbolic
+ * (low-detail) NPCs and unloaded terrain pages schedule fine.
+ * - Mechanical activation (runtime, not here): an activation spec
+ * saying what runs when an NPC or player reaches the POI — a
+ * behavior tree by default or a Lua script, triggered manually
+ * (E), on approach, or on schedule-entry arrival. Runtime
+ * entities are created per terrain page, never persisted here.
+ *
+ * The database is a UNIFIED QUERY MODEL, not the source of truth:
+ * every authoring path (generated town POIs, DSL-emitted POIs,
+ * component-authored exterior POIs) feeds records into it and the
+ * set is rebuilt deterministically from those sources. Ids are
+ * caller-assigned from stable inputs; queries return id-ordered
+ * results, so downstream planners stay deterministic.
+ *
+ * TownLocations::LocationSet becomes a PROJECTION of the POI set
+ * (projectLocations below): schedules, motion, taxi and traffic keep
+ * one location model instead of a parallel registry.
+ */
+namespace PoiDatabase
+{
+
+enum class Domain { Exterior = 0, Interior };
+
+enum class ActivationKind {
+ None = 0,
+ Manual, /* E prompt on the actuator */
+ OnApproach, /* fires when someone comes within radius */
+ OnArrive /* fires on schedule-entry arrival */
+};
+
+struct Activation {
+ ActivationKind kind = ActivationKind::None;
+ /** Behavior tree name (default) or a Lua script path; at most
+ * one is used (tree first). */
+ std::string behaviorTree;
+ std::string luaScript;
+ /** OnApproach trigger radius in meters. */
+ float radius = 0.0f;
+ /** Minimum seconds between firings (0 = every approach). */
+ float cooldownSeconds = 0.0f;
+ /** Fire only once per runtime instance. */
+ bool once = false;
+};
+
+struct Record {
+ /** Stable unique id, caller-assigned from stable inputs (e.g.
+ * "curb:", "work:", "poi:::"). */
+ std::string id;
+ Domain domain = Domain::Exterior;
+ /** Planner kind: "home" | "work" | "leisure" | "interest" |
+ * "curb" | "parking" | "entrance" | "interaction" | ... .
+ * Only location kinds project into LocationSet. */
+ std::string kind;
+ std::string name; /* display name */
+ /** Owning building's address ("" for standalone). */
+ std::string address;
+ /** Index into TownResult::buildings, -1 when standalone. */
+ int building = -1;
+ /** World XZ of the caller's coordinate space. */
+ Ogre::Vector3 position = Ogre::Vector3::ZERO;
+ /** Optional parked-car heading copied from the POI (parking
+ * slots): yaw around +Y of the nose. false = the traffic system
+ * derives the pose from the nearest road edge. */
+ bool hasHeading = false;
+ float headingYaw = 0.0f;
+ std::vector tags;
+ /** Entrance role: "" | "front" | "back" | "side" (6.8.8). */
+ std::string entranceRole;
+ /** Opening hours in [0,24); openHour < 0 = always open. */
+ float openHour = -1.0f;
+ float closeHour = -1.0f;
+ /** Stat effects offered per in-game hour (stat name -> delta). */
+ std::unordered_map statEffects;
+ Activation activation;
+};
+
+struct Query {
+ /** -1 = either domain. */
+ int domain = -1;
+ /** Empty = any kind. */
+ std::string kind;
+ /** POI must carry ALL of these tags (empty = any). */
+ std::vector tagsAll;
+ /** Empty = any building/standalone. */
+ std::string address;
+ /** true = only POIs open at `hour` (opening-hours filter). */
+ bool openAtHour = false;
+ float hour = 0.0f;
+};
+
+struct Set {
+ /** Ids are unique; insertion order is irrelevant after
+ * finalize() (records are sorted by id). */
+ std::vector pois;
+
+ /** Add a record. Returns false on an empty id or a duplicate. */
+ bool add(Record rec, std::string *error = nullptr);
+ /** Sort by id and build the tag index; call once after a
+ * (deterministic) build, before querying. */
+ void finalize();
+
+ const Record *findById(const std::string &id) const;
+ /** Ids in id order matching the query. */
+ std::vector query(const Query &q) const;
+
+private:
+ bool m_finalized = false;
+ std::unordered_map > m_tagIndex;
+};
+
+/** True when a stat-effect table offers a nonzero effect on `stat`. */
+bool hasEffect(const Record &rec, const std::string &stat);
+
+/* ----- Building entry contract (6.8.12) -----
+ *
+ * Every ENTERABLE building gets one generated EXTERIOR entry POI at
+ * its doorstep (kind "entrance", tag "entrance", entrance role
+ * "front" — generated buildings are single-door; 6.8.8 multi-door
+ * role matching arrives with the DSL authoring). The entry links the
+ * building to its interior POIs: an interior destination resolves its
+ * approach THROUGH the entry — road paths, distance scores and
+ * symbolic arrivals use the entry position, never the interior cell
+ * (which may live in another scene's coordinates). */
+
+/** Stable entry id of a building address. */
+std::string entryId(const std::string &address);
+
+/** Append one entry record per distinct building of `locations`
+ * (a building's Home location is at its doorstep). Buildings that
+ * already have an entrance record (re-runs, authored entries) are
+ * skipped, so the call is idempotent. Returns how many entries were
+ * appended; the caller re-finalizes the set. */
+int appendEntries(Set &set, const TownLocations::LocationSet &locations);
+
+/** The entry POI of a building: matched by building index first,
+ * then by address; null when the building has no entry. */
+const Record *entryOfBuilding(const Set &set, 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
+ * own position when the building has no entry), for exterior POIs
+ * the POI position itself. */
+Ogre::Vector3 approachPosition(const Set &set, const Record &rec);
+
+/** Planner projection: only location kinds (home/work/leisure/
+ * interest/curb/parking) become NpcLocations, preserving names,
+ * positions, addresses, building indices and parking headings.
+ * Interaction/entrance POIs stay out of the LocationSet. */
+TownLocations::LocationSet projectLocations(const Set &set);
+
+/** Kind string of a TownLocations::LocationKind ("home", "work",
+ * "leisure", "interest", "curb", "parking"). */
+const char *kindName(TownLocations::LocationKind kind);
+
+/** Feed an existing generated LocationSet into the POI database:
+ * every NpcLocation becomes a POI record (id = location name, kind
+ * string, domain = isIndoorKind ? Interior : Exterior, address,
+ * building, position, heading, tags = the location's tags, or
+ * {kindName} for untagged legacy locations, entranceRole when set).
+ * Stat effects and activation specs are left empty — those come
+ * from the richer authoring paths (6.8.2b/6.8.6). The set is
+ * returned finalized. projectLocations(feedLocationSet(x)) is
+ * equivalent to x by location NAME (order is id-sorted, which is
+ * fine because all consumers bind by name). */
+Set feedLocationSet(const TownLocations::LocationSet &locations);
+
+} // namespace PoiDatabase
diff --git a/src/features/editScene/roadlib/TownGen.cpp b/src/features/editScene/roadlib/TownGen.cpp
index 2bfb88b..ff10d59 100644
--- a/src/features/editScene/roadlib/TownGen.cpp
+++ b/src/features/editScene/roadlib/TownGen.cpp
@@ -241,7 +241,8 @@ static std::string selectLayout(const TownParams ¶ms, District district,
const LayoutLib::LayoutLibrary *layouts,
std::mt19937 &rng,
std::uniform_real_distribution &unit,
- std::string *usedName)
+ std::string *usedName,
+ std::vector *usedTags = NULL)
{
const char *defLayout = defaultLayoutForDistrict(district);
if (layouts) {
@@ -254,10 +255,24 @@ static std::string selectLayout(const TownParams ¶ms, District district,
cands = layouts->matching(pref);
if (cands.empty()) {
*usedName = defLayout;
+ if (usedTags) {
+ /* A user library may override the
+ * district default's name — its entry
+ * tags win over the built-in's. */
+ const LayoutLib::LayoutLibrary::Entry
+ *de = layouts->findEntry(
+ defLayout);
+ *usedTags =
+ de ? de->tags :
+ LayoutLib::LayoutLibrary::
+ builtinTags(defLayout);
+ }
return defLayout;
}
if (cands.size() == 1 && cands[0].first == defLayout) {
*usedName = defLayout;
+ if (usedTags)
+ *usedTags = cands[0].second->tags;
return defLayout;
}
double total = 0.0;
@@ -281,9 +296,14 @@ static std::string selectLayout(const TownParams ¶ms, District district,
}
}
*usedName = picked;
+ if (usedTags)
+ *usedTags =
+ layouts->findEntry(picked)->tags;
return picked;
}
*usedName = defLayout;
+ if (usedTags)
+ *usedTags = LayoutLib::LayoutLibrary::builtinTags(defLayout);
return defLayout;
}
@@ -681,6 +701,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
* selection picks among tag-matching library
* entries. */
std::string usedLayoutName;
+ std::vector usedLayoutTags;
std::string layoutSrc;
if (!layout.empty()) {
layoutSrc = layout;
@@ -691,11 +712,24 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
" \t\n\r")] == '(' ?
"" :
layout;
+ /* 6.8.2: an explicit edge-side LIBRARY
+ * layout carries its entry's tags;
+ * inline source has none. */
+ if (usedLayoutName != "" &&
+ layouts) {
+ const LayoutLib::LayoutLibrary::
+ Entry *le =
+ layouts->findEntry(
+ usedLayoutName);
+ if (le)
+ usedLayoutTags = le->tags;
+ }
} else {
layoutSrc = selectLayout(params,
params.district,
layouts, rng, unit,
- &usedLayoutName);
+ &usedLayoutName,
+ &usedLayoutTags);
}
LayoutLib::BuildingPlan plan;
std::string layoutErr;
@@ -726,6 +760,10 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
continue;
}
usedLayoutName = defLayout;
+ usedLayoutTags =
+ LayoutLib::LayoutLibrary::
+ builtinTags(
+ defLayout);
}
for (const std::string &w : layoutWarns)
result.warnings.push_back(
@@ -788,6 +826,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
b.roofType = roofType;
b.layout = layout;
b.layoutName = usedLayoutName;
+ b.tags = usedLayoutTags;
b.floorY = floorY;
b.minGroundY = minGroundY;
b.plan = std::move(plan);
diff --git a/src/features/editScene/roadlib/TownGen.hpp b/src/features/editScene/roadlib/TownGen.hpp
index 9793865..f6b2fe3 100644
--- a/src/features/editScene/roadlib/TownGen.hpp
+++ b/src/features/editScene/roadlib/TownGen.hpp
@@ -99,6 +99,14 @@ struct TownBuilding {
* edge-side layout was inline source. Debug/test visibility only
* — the plan below is the build truth. */
std::string layoutName;
+ /**
+ * Tags of the picked layout (6.8.2): the library entry's
+ * `:tags` (built-ins carry their district tags). Empty for
+ * inline edge-side layouts and hand-built TownResults — those
+ * buildings keep the legacy Interest fallback (no Work/Leisure
+ * zoning).
+ */
+ std::vector tags;
/**
* Evaluated building plan (phase 4.5 item 2): grid metrics,
* per-cell flags, roofs, lot features, placements. Always
diff --git a/src/features/editScene/roadlib/TownLocations.cpp b/src/features/editScene/roadlib/TownLocations.cpp
index a71e041..9523aa4 100644
--- a/src/features/editScene/roadlib/TownLocations.cpp
+++ b/src/features/editScene/roadlib/TownLocations.cpp
@@ -46,6 +46,40 @@ bool isIndoorKind(LocationKind kind)
}
}
+/* ----- 6.8.2 tag model (DATA, not logic) -----
+ *
+ * The commercial tag set and the job-tag mapping are the DEFAULT
+ * vocabulary for generated buildings; per-lot/area overrides
+ * (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). */
+static const char *const COMMERCIAL_TAGS[] = {
+ "shop", "commercial", "office", "leisure",
+};
+
+bool isCommercialBuilding(const std::vector &buildingTags)
+{
+ for (const std::string &tag : buildingTags)
+ for (const char *c : COMMERCIAL_TAGS)
+ if (tag == c)
+ return true;
+ return false;
+}
+
+const char *jobTagForBuilding(const std::vector &buildingTags)
+{
+ for (const std::string &tag : buildingTags) {
+ if (tag == "shop")
+ return "salesman";
+ if (tag == "office")
+ return "clerk";
+ }
+ /* Any other commercial tag (e.g. "commercial" on a city
+ * tower). */
+ return "worker";
+}
+
int LocationSet::homeOfBuilding(int building) const
{
for (const NpcLocation &loc : locations)
@@ -63,6 +97,37 @@ std::vector LocationSet::indicesOfKind(LocationKind kind) const
return out;
}
+void LocationSet::reindex() const
+{
+ if (!m_indexDirty)
+ return;
+ m_byTag.clear();
+ m_byBuilding.clear();
+ for (size_t i = 0; i < locations.size(); ++i) {
+ const NpcLocation &loc = locations[i];
+ for (const std::string &tag : loc.tags)
+ m_byTag[tag].push_back(loc.id);
+ m_byBuilding[loc.building].push_back(loc.id);
+ }
+ m_indexDirty = false;
+}
+
+std::vector LocationSet::indicesWithTag(const std::string &tag) const
+{
+ reindex();
+ std::unordered_map >::const_iterator
+ it = m_byTag.find(tag);
+ return it != m_byTag.end() ? it->second : std::vector();
+}
+
+std::vector LocationSet::indicesOfBuilding(int building) const
+{
+ reindex();
+ std::unordered_map >::const_iterator it =
+ m_byBuilding.find(building);
+ return it != m_byBuilding.end() ? it->second : std::vector();
+}
+
std::string pickLocationName(const LocationSet &locs,
const std::vector &candidates,
const std::string &stableKey,
@@ -103,7 +168,19 @@ NpcBindings computeBindings(const LocationSet &locs,
locs.indicesOfKind(LocationKind::Curb);
visit.insert(visit.end(), curbs.begin(), curbs.end());
}
- b.work = pickLocationName(locs, interest, stableKey, "work", b.home);
+ /* 6.8.2: real Work locations are the workplace candidates once
+ * the town has tagged commercial buildings; tagless (legacy)
+ * towns keep the Interest fallback. Leisure locations join
+ * the visit pool as the customers' errand destinations. */
+ std::vector work = locs.indicesOfKind(LocationKind::Work);
+ if (work.empty())
+ work = interest;
+ b.work = pickLocationName(locs, work, stableKey, "work", b.home);
+ {
+ std::vector leisure =
+ locs.indicesOfKind(LocationKind::Leisure);
+ visit.insert(visit.end(), leisure.begin(), leisure.end());
+ }
b.interest =
pickLocationName(locs, visit, stableKey, "interest", b.home);
return b;
@@ -148,9 +225,52 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
home.building = (int)bi;
add(home);
+ /* The anchor the symbolic tier approaches: the building
+ * is conceptually entered through its front (its
+ * pickup POI at the curb when one exists, else the
+ * doorstep). */
+ Ogre::Vector3 anchor = pickupOf[bi] >= 0 ?
+ town.pois[pickupOf[bi]].position :
+ (b.doorstep != Ogre::Vector3::ZERO ?
+ b.doorstep :
+ b.center);
+
+ if (isCommercialBuilding(b.tags)) {
+ /* 6.8.2 dual-audience: a commercial building is
+ * a Work location for its employees AND a
+ * Leisure location for its customers — same
+ * building, different kind, tags and audience.
+ * These buildings are "zoned" now, so they do
+ * NOT join the legacy Interest roll (that roll
+ * was the placeholder for exactly this). */
+ NpcLocation work;
+ work.kind = LocationKind::Work;
+ work.name = std::string("work:") + b.address;
+ work.address = b.address;
+ work.position = anchor;
+ work.building = (int)bi;
+ work.tags = b.tags;
+ work.tags.push_back(
+ jobTagForBuilding(b.tags));
+ work.entranceRole = "front";
+ add(work);
+
+ NpcLocation leisure;
+ leisure.kind = LocationKind::Leisure;
+ leisure.name =
+ std::string("leisure:") + b.address;
+ leisure.address = b.address;
+ leisure.position = anchor;
+ leisure.building = (int)bi;
+ leisure.tags = b.tags;
+ leisure.entranceRole = "front";
+ add(leisure);
+ continue;
+ }
+
/* Deterministic per-building interest roll — no shared
* stream, so one lot's edit never shifts another lot's
- * locations. */
+ * locations. Legacy fallback for tagless buildings. */
uint32_t h = fnv1a(b.address) ^ (seed * 16777619u);
float roll = (h % 1000) / 1000.0f;
if (roll < interestRatio) {
@@ -158,11 +278,7 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
interest.kind = LocationKind::Interest;
interest.name = std::string("interest:") + b.address;
interest.address = b.address;
- interest.position = pickupOf[bi] >= 0 ?
- town.pois[pickupOf[bi]].position :
- (b.doorstep != Ogre::Vector3::ZERO ?
- b.doorstep :
- b.center);
+ interest.position = anchor;
interest.building = (int)bi;
add(interest);
}
diff --git a/src/features/editScene/roadlib/TownLocations.hpp b/src/features/editScene/roadlib/TownLocations.hpp
index 0b1788c..13a4376 100644
--- a/src/features/editScene/roadlib/TownLocations.hpp
+++ b/src/features/editScene/roadlib/TownLocations.hpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include "TownGen.hpp"
@@ -26,9 +27,8 @@ namespace TownLocations {
enum class LocationKind {
Home = 0,
- Work, /* reserved: no work zoning yet; bindings pick
- * from Interest instead */
- Leisure, /* reserved for authored markers */
+ Work, /* commercial buildings' employees (6.8.2) */
+ Leisure, /* commercial buildings' customers (6.8.2) */
Interest,
Curb,
Parking,
@@ -37,6 +37,24 @@ enum class LocationKind {
const char *locationKindName(LocationKind kind);
+/**
+ * Tag vocabulary (6.8.2). A building whose tags (from its picked
+ * layout's `:tags`, phase 5.9) carry any commercial tag yields BOTH
+ * a Work location (its employees) and a Leisure location (its
+ * customers) — same building, different kind, tags and audience.
+ * Residential buildings (no commercial tag) keep the legacy Interest
+ * roll ("old towns without tags keep the Interest fallback").
+ */
+bool isCommercialBuilding(const std::vector &buildingTags);
+
+/**
+ * The default job tag of a commercial building's Work location
+ * (first match in a fixed order; DATA, not logic — per-lot/area
+ * overrides arrive in 6.8.2b): shop -> salesman, office -> clerk,
+ * any other commercial tag -> worker.
+ */
+const char *jobTagForBuilding(const std::vector &buildingTags);
+
/**
* Indoor kinds (demo-taxi phase 4.9.1): an NPC AT an indoor location
* is inside a building and must NOT be spawned into the exterior
@@ -67,8 +85,47 @@ struct NpcLocation {
*/
bool hasHeading = false;
float headingYaw = 0.0f;
+
+ /**
+ * Explicit indoor/outdoor domain (demo-taxi 6.8.12): stamped
+ * from the POI record's domain when the location comes from the
+ * unified POI projection; the pure buildLocations() baseline
+ * leaves it false and consumers fall back to isIndoorKind().
+ */
+ bool indoor = false;
+
+ /**
+ * Approach anchor (6.8.12 building entry contract): the owning
+ * building's exterior ENTRY POI position. Road paths, distance
+ * scores and symbolic arrivals for an interior destination use
+ * it instead of the location position (which may be an interior
+ * cell in later phases); reaching it means "inside". Stamped
+ * only for Interior-domain locations that have an entry.
+ */
+ Ogre::Vector3 entryPosition = Ogre::Vector3::ZERO;
+ bool hasEntry = false;
+
+ /**
+ * Stable planner tags (6.8.2): the owning building's tags (its
+ * layout's `:tags`), plus the job tag for Work locations
+ * (e.g. "salesman"). Empty for untagged (legacy) locations.
+ */
+ std::vector tags;
+
+ /**
+ * Default entrance role (6.8.2/6.8.8): "front" for generated
+ * locations (generated buildings are single-door).
+ */
+ std::string entranceRole;
};
+/** The approach endpoint of a location: its entry POI position when
+ * one is stamped (6.8.12), else the location position itself. */
+inline const Ogre::Vector3 &approachPosition(const NpcLocation &loc)
+{
+ return loc.hasEntry ? loc.entryPosition : loc.position;
+}
+
struct LocationSet {
std::vector locations;
@@ -78,17 +135,39 @@ struct LocationSet {
int homeOfBuilding(int building) const;
/** Indices of all locations of a kind, in location order. */
std::vector indicesOfKind(LocationKind kind) const;
+
+ /**
+ * Tag index (6.8.2): tag -> location indices and building ->
+ * location indices, built lazily by the queries below (all
+ * producers build fresh sets, so nothing invalidates the
+ * index in place; call reindex() after direct mutation of
+ * `locations`). Index vectors are in location order, so
+ * queries are deterministic.
+ */
+ void reindex() const;
+ /** Location indices carrying `tag`, in location order. */
+ std::vector indicesWithTag(const std::string &tag) const;
+ /** Location indices of one building (-1 = standalone), in
+ * location order. */
+ std::vector indicesOfBuilding(int building) const;
+
+private:
+ mutable bool m_indexDirty = true;
+ mutable std::unordered_map >
+ m_byTag;
+ mutable std::unordered_map > m_byBuilding;
};
/**
* Per-NPC location bindings (phase 4.7.4): the deterministic picks the
* population system stores in the registry stringColumns and the
* schedule generator consumes. `home` is "home:" + homeAddress;
- * `work` picks from the Interest locations (the workplace placeholder
- * until zoning exists), `interest` from Interest+Curb; both fall back
- * to `home` when the set has no candidates. The pick is a hash of
- * (stableKey, purpose), so a regeneration with the same town re-picks
- * the same bindings.
+ * `work` picks from the Work locations when the town has tagged
+ * commercial buildings, else from the Interest locations (the
+ * workplace fallback, 6.8.2); `interest` from Interest+Curb+Leisure;
+ * both fall back to `home` when the set has no candidates. The pick
+ * is a hash of (stableKey, purpose), so a regeneration with the same
+ * town re-picks the same bindings.
*/
struct NpcBindings {
std::string home;
@@ -113,9 +192,14 @@ NpcBindings computeBindings(const LocationSet &locs,
* Build the location set of a town: one Home per building (position
* = the doorstep, 2 m in front of the front door — the building
* center is inside the exterior-only shell), one Curb per pickup POI,
- * one Parking per parking POI, and an Interest location for every
- * building whose hash roll passes `interestRatio` (position = its
- * pickup POI when one exists, else the doorstep).
+ * one Parking per parking POI, and — per building — either the
+ * commercial pair (6.8.2: buildings whose layout tags carry a
+ * commercial tag get one Work location for employees AND one Leisure
+ * location for customers, both at the pickup POI when one exists,
+ * else the doorstep) or the legacy Interest roll for every building
+ * whose hash roll passes `interestRatio` (position = its pickup POI
+ * when one exists, else the doorstep). Buildings without tags
+ * (hand-built TownResults, inline layouts) keep the Interest roll.
*/
LocationSet buildLocations(const TownGen::TownResult &town,
uint32_t seed, float interestRatio = 0.15f);
diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp
index d7b084e..b41acf7 100644
--- a/src/features/editScene/systems/EditorUISystem.cpp
+++ b/src/features/editScene/systems/EditorUISystem.cpp
@@ -500,6 +500,11 @@ void EditorUISystem::update(float deltaTime)
&m_showAnimationTreeRegistry);
}
+ // Unified POI database inspector (phase 6.8.11)
+ if (m_showPoiDatabase) {
+ m_poiDatabasePanel.render(&m_showPoiDatabase);
+ }
+
// Render Inventory Dialog Config window
if (m_showInventoryConfig) {
renderInventoryConfigWindow();
@@ -631,6 +636,9 @@ void EditorUISystem::renderHierarchyWindow()
"Animation Tree Registry")) {
m_showAnimationTreeRegistry = true;
}
+ if (ImGui::MenuItem("POI Database")) {
+ m_showPoiDatabase = true;
+ }
ImGui::Separator();
if (ImGui::MenuItem(
"Inventory Dialog Config")) {
diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp
index 5291cf5..09388a5 100644
--- a/src/features/editScene/systems/EditorUISystem.hpp
+++ b/src/features/editScene/systems/EditorUISystem.hpp
@@ -11,6 +11,7 @@
#include "../ui/ActionDatabaseSingletonEditor.hpp"
#include "../ui/CharacterClassDatabaseEditor.hpp"
#include "../ui/AnimationTreeRegistryEditor.hpp"
+#include "../ui/PoiDatabasePanel.hpp"
#include "../ui/NavigationPanel.hpp"
#include "../ui/WorldMapPanel.hpp"
#include "../components/EntityName.hpp"
@@ -414,6 +415,8 @@ private:
// Animation tree registry
bool m_showAnimationTreeRegistry = false;
+ bool m_showPoiDatabase = false;
+ PoiDatabasePanel m_poiDatabasePanel;
AnimationTreeRegistryEditor m_animationTreeRegistryEditor;
// Road editing (M5.2)
diff --git a/src/features/editScene/systems/NpcDetailSystem.cpp b/src/features/editScene/systems/NpcDetailSystem.cpp
index 20816d8..a4c1502 100644
--- a/src/features/editScene/systems/NpcDetailSystem.cpp
+++ b/src/features/editScene/systems/NpcDetailSystem.cpp
@@ -94,7 +94,10 @@ bool NpcDetailSystem::isSpawnEligible(uint64_t registryId) const
locs ? locs->findByName(mi.location) : nullptr;
if (!loc)
return true;
- return !TownLocations::isIndoorKind(loc->kind);
+ /* 6.8.12: the projected domain wins; the kind check is the
+ * fallback for LocationSets built without the POI projection
+ * (loc.indoor defaults false there). */
+ return !(loc->indoor || TownLocations::isIndoorKind(loc->kind));
}
Ogre::Vector3 NpcDetailSystem::cameraPosition() const
@@ -209,6 +212,7 @@ bool NpcDetailSystem::spawnHighDetail(NpcDetail &det, uint64_t registryId)
* inside the road slab or a parked car — relocates. */
Ogre::Vector3 probe = posR + Ogre::Vector3(0.0f, 0.3f, 0.0f);
if (physics->capsuleOverlapsObstacles(probe, 0.9f, 0.3f)) {
+ m_relocatedSpawns.insert(registryId);
static const float radii[] = { 0.6f, 1.2f, 1.8f,
2.4f, 3.0f, 4.0f,
5.0f };
@@ -468,6 +472,11 @@ void NpcDetailSystem::driveHighDetail(uint64_t registryId,
}
}
+bool NpcDetailSystem::spawnedRelocated(uint64_t registryId) const
+{
+ return m_relocatedSpawns.count(registryId) != 0;
+}
+
void NpcDetailSystem::update(float dt)
{
if (!editScene::isGameMode())
@@ -483,6 +492,9 @@ void NpcDetailSystem::update(float dt)
CharacterRegistry ® = CharacterRegistry::getSingleton();
const Ogre::Vector3 camPos = cameraPosition();
const std::vector ids = pop->populationCharacterIds();
+ /* The relocation read-out is per-update (see
+ * spawnedRelocated). */
+ m_relocatedSpawns.clear();
/* Prune NPCs that left the population (town regen). */
for (auto it = m_npcs.begin(); it != m_npcs.end();) {
diff --git a/src/features/editScene/systems/NpcDetailSystem.hpp b/src/features/editScene/systems/NpcDetailSystem.hpp
index f569b73..ae0fa2d 100644
--- a/src/features/editScene/systems/NpcDetailSystem.hpp
+++ b/src/features/editScene/systems/NpcDetailSystem.hpp
@@ -4,6 +4,7 @@
#include
#include