From 54d9d4a28e7cec06125649fc794ee7c67d197aea Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Thu, 29 Oct 2026 01:31:02 +0300 Subject: [PATCH] phase 6.8 checkpoint --- src/features/editScene/AGENTS.md | 283 +++++- src/features/editScene/CMakeLists.txt | 35 + src/features/editScene/EditorApp.cpp | 20 + src/features/editScene/EditorApp.hpp | 6 + src/features/editScene/components/Poi.hpp | 66 ++ .../editScene/components/PoiModule.cpp | 17 + .../editScene/demos/demo-taxi/PLAN.md | 191 +++- .../editScene/demos/demo-taxi/demo_main.cpp | 368 ++++++- .../lua-scripts/character_stats_rules.lua | 70 ++ .../demos/demo-taxi/stage_runtime.cmake | 41 +- .../editScene/layoutlib/LayoutEval.cpp | 244 ++++- .../editScene/layoutlib/LayoutEval.hpp | 38 + .../lua-examples/schedule_scoring_example.lua | 57 ++ src/features/editScene/lua-scripts/data2.lua | 8 + .../editScene/lua/LuaComponentApi.cpp | 88 ++ .../editScene/lua/LuaScheduleScoringApi.cpp | 172 ++++ .../editScene/lua/LuaScheduleScoringApi.hpp | 31 + .../editScene/roadlib/PoiDatabase.cpp | 280 +++++ .../editScene/roadlib/PoiDatabase.hpp | 184 ++++ src/features/editScene/roadlib/TownGen.cpp | 43 +- src/features/editScene/roadlib/TownGen.hpp | 8 + .../editScene/roadlib/TownLocations.cpp | 130 ++- .../editScene/roadlib/TownLocations.hpp | 106 +- .../editScene/systems/EditorUISystem.cpp | 8 + .../editScene/systems/EditorUISystem.hpp | 3 + .../editScene/systems/NpcDetailSystem.cpp | 14 +- .../editScene/systems/NpcDetailSystem.hpp | 10 + .../editScene/systems/NpcMotionSystem.cpp | 33 +- .../editScene/systems/NpcPopulationSystem.cpp | 159 ++- .../editScene/systems/NpcPopulationSystem.hpp | 20 + .../systems/NpcScheduleDebugOverlay.cpp | 12 + .../editScene/systems/NpcTrafficSystem.cpp | 69 ++ .../editScene/systems/NpcTrafficSystem.hpp | 24 + src/features/editScene/systems/PoiSystem.cpp | 210 ++++ src/features/editScene/systems/PoiSystem.hpp | 78 ++ .../editScene/systems/SceneSerializer.cpp | 55 + .../editScene/systems/SceneSerializer.hpp | 2 + .../editScene/systems/ScheduleScoring.cpp | 55 + .../editScene/systems/ScheduleScoring.hpp | 69 ++ .../editScene/tests/character_stats_test.cpp | 52 + .../editScene/tests/component_lua_test.cpp | 67 ++ .../tests/road_geometry_overlap_test.cpp | 957 +++++++++++++++++- .../editScene/tests/schedule_scoring_test.cpp | 199 ++++ .../editScene/ui/PoiDatabasePanel.cpp | 168 +++ .../editScene/ui/PoiDatabasePanel.hpp | 23 + src/features/editScene/ui/PoiEditor.cpp | 151 +++ src/features/editScene/ui/PoiEditor.hpp | 17 + 47 files changed, 4850 insertions(+), 91 deletions(-) create mode 100644 src/features/editScene/components/Poi.hpp create mode 100644 src/features/editScene/components/PoiModule.cpp create mode 100644 src/features/editScene/demos/demo-taxi/lua-scripts/character_stats_rules.lua create mode 100644 src/features/editScene/lua-examples/schedule_scoring_example.lua create mode 100644 src/features/editScene/lua/LuaScheduleScoringApi.cpp create mode 100644 src/features/editScene/lua/LuaScheduleScoringApi.hpp create mode 100644 src/features/editScene/roadlib/PoiDatabase.cpp create mode 100644 src/features/editScene/roadlib/PoiDatabase.hpp create mode 100644 src/features/editScene/systems/PoiSystem.cpp create mode 100644 src/features/editScene/systems/PoiSystem.hpp create mode 100644 src/features/editScene/systems/ScheduleScoring.cpp create mode 100644 src/features/editScene/systems/ScheduleScoring.hpp create mode 100644 src/features/editScene/tests/schedule_scoring_test.cpp create mode 100644 src/features/editScene/ui/PoiDatabasePanel.cpp create mode 100644 src/features/editScene/ui/PoiDatabasePanel.hpp create mode 100644 src/features/editScene/ui/PoiEditor.cpp create mode 100644 src/features/editScene/ui/PoiEditor.hpp 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 +#include #include #include @@ -99,6 +100,14 @@ public: /** Animation tree given to inline-spawned population NPCs. */ Ogre::String animationTreeName = "male1"; + /** True when the named NPC's spawn THIS update used the phase + * 4.9.4 placement ring (the symbolic position landed inside a + * solid — e.g. a car parked at the same curb anchor — and the + * spawn relocated to the first free ring spot). Games and + * tests read this to distinguish legitimate relocations from + * tier-position bugs; cleared at the start of every update. */ + bool spawnedRelocated(uint64_t registryId) const; + private: struct NpcDetail { flecs::entity spawner; @@ -115,6 +124,7 @@ private: EditorApp *m_editorApp; EditorCameraSystem *m_cameraSystem; std::map m_npcs; + std::set m_relocatedSpawns; static NpcDetailSystem *ms_instance; }; diff --git a/src/features/editScene/systems/NpcMotionSystem.cpp b/src/features/editScene/systems/NpcMotionSystem.cpp index 1edf298..d9c86f0 100644 --- a/src/features/editScene/systems/NpcMotionSystem.cpp +++ b/src/features/editScene/systems/NpcMotionSystem.cpp @@ -185,9 +185,13 @@ bool NpcMotionSystem::walkTravelHours(int areaIdx, const TownLocations::NpcLocation *to = locs.findByName(toLoc); if (!from || !to) return false; - const PathEntry *pe = pathBetween(areaIdx, fromLoc, toLoc, - from->position, to->position, - roadGraph()); + /* 6.8.12: an interior destination is approached THROUGH its + * building entry — the path ends at the entry, not the + * (possibly interior) location cell. */ + const PathEntry *pe = pathBetween( + areaIdx, fromLoc, toLoc, + TownLocations::approachPosition(*from), + TownLocations::approachPosition(*to), roadGraph()); outHours = (double)pe->second / ((double)walkSpeedMps * 3600.0); return true; } @@ -244,7 +248,9 @@ void NpcMotionSystem::update() if (!lc.next) { /* Single-location schedule: never moves. */ if (cur) - rec->position = cur->position; + rec->position = + TownLocations::approachPosition( + *cur); st.state = State::AtLocation; st.stateLocation = active->location; st.toLocation.clear(); @@ -267,10 +273,15 @@ void NpcMotionSystem::update() continue; } - /* Coarse path, shared cache per (area, from, to). */ + /* Coarse path, shared cache per (area, from, to). Both + * endpoints are the approach anchors (6.8.12): interior + * locations path through their building entry, so + * reaching the entry IS arriving. */ const PathEntry *pe = pathBetween(areaIdx, active->location, next->location, - cur->position, to->position, graph); + TownLocations::approachPosition(*cur), + TownLocations::approachPosition(*to), + graph); const std::vector *path = &pe->first; float pathLength = pe->second; @@ -287,15 +298,19 @@ void NpcMotionSystem::update() st.pathLength = pathLength; if (progressM < 0.0) { /* Waiting for the departure. */ - rec->position = cur->position; + rec->position = + TownLocations::approachPosition(*cur); st.state = State::AtLocation; st.stateLocation = active->location; st.toLocation.clear(); st.progress = 0.0f; } else if (progressM >= (double)pathLength) { /* Arrived (rounding guard; normally the next - * entry is already the active one here). */ - rec->position = to->position; + * entry is already the active one here). The + * symbolic position settles AT THE ENTRY + * (6.8.12): reaching the entry means inside. */ + rec->position = + TownLocations::approachPosition(*to); st.state = State::AtLocation; st.stateLocation = next->location; st.toLocation.clear(); diff --git a/src/features/editScene/systems/NpcPopulationSystem.cpp b/src/features/editScene/systems/NpcPopulationSystem.cpp index f1d4c17..89b24c5 100644 --- a/src/features/editScene/systems/NpcPopulationSystem.cpp +++ b/src/features/editScene/systems/NpcPopulationSystem.cpp @@ -4,12 +4,16 @@ #include "CharacterRegistry.hpp" #include "CharacterSlotSystem.hpp" +#include "RenderOriginSystem.hpp" #include "RoadSystem.hpp" #include "TerrainSystem.hpp" #include "TownGenSystem.hpp" #include "../GameMode.hpp" #include "../components/CharacterSlots.hpp" +#include "../components/NpcLocation.hpp" +#include "../components/Poi.hpp" #include "../components/RoadGraph.hpp" +#include "../components/Transform.hpp" #include "../roadlib/RoadNetworkGen.hpp" /* Deterministic per-NPC look from the character-part catalog: a @@ -256,7 +260,18 @@ int NpcPopulationSystem::generateAreaPopulation(int areaIndex, } } - ap.locations = std::move(locations); + /* Phase 6.8.11: the unified POI database is the shared store — + * generated locations feed it, authored NpcLocationComponent + * markers merge into it, and the LocationSet the planner + * consumes is its projection (equivalent by location NAME). */ + ap.poiSet = PoiDatabase::feedLocationSet(locations); + /* Phase 6.8.12: one exterior entry POI per enterable building + * (idempotent), before the projection stamps the entry anchors + * onto interior locations. */ + PoiDatabase::appendEntries(ap.poiSet, locations); + mergeAuthoredPoiMarkers(ap.poiSet, ap.authoredPoiIds); + ap.poiSet.finalize(); + ap.locations = PoiDatabase::projectLocations(ap.poiSet); /* Phase 4.7.4: per-NPC time-of-day schedules over the area's * locations (same town state -> same schedules; a script * forcing a regeneration stays deterministic). */ @@ -330,6 +345,32 @@ void NpcPopulationSystem::update() const RoadGraph &graph = terrain.get().roadGraph; + + /* Authored POI layer (phase 6.8.11): re-merge every 60 frames + * so POIs added at runtime (or by a scene switch) appear + * without a full regeneration. Only the authored ids are + * replaced; the generated baseline and the schedules are + * untouched. */ + for (auto &kv : m_areas) { + AreaPopulation &ap = kv.second; + if (++ap.poiMergeCounter < 60) + continue; + ap.poiMergeCounter = 0; + for (const auto &id : ap.authoredPoiIds) { + for (auto it = ap.poiSet.pois.begin(); + it != ap.poiSet.pois.end();) { + if (it->id == id) + it = ap.poiSet.pois.erase(it); + else + ++it; + } + } + ap.authoredPoiIds.clear(); + mergeAuthoredPoiMarkers(ap.poiSet, ap.authoredPoiIds); + ap.poiSet.finalize(); + ap.locations = PoiDatabase::projectLocations(ap.poiSet); + } + uint64_t key = graph.version ^ ((uint64_t)graph.townAreas.size() << 40); if (key == m_autoKey && terrain.id() == m_autoTerrain) @@ -422,6 +463,122 @@ NpcPopulationSystem::areaLocations(int areaIndex) const return it == m_areas.end() ? nullptr : &it->second.locations; } +void NpcPopulationSystem::mergeAuthoredPoiMarkers( + PoiDatabase::Set &set, std::set &authoredIds) +{ + m_world.query().each( + [&](flecs::entity, const NpcLocationComponent &marker, + const TransformComponent &t) { + if (!marker.enabled) + return; + if (marker.name.empty()) { + Ogre::LogManager::getSingleton().logMessage( + "[poi] skipping an unnamed " + "NpcLocationComponent"); + return; + } + TownLocations::LocationKind kind = + (marker.kind >= 0 && + marker.kind < + (int)TownLocations::LocationKind:: + COUNT) ? + (TownLocations::LocationKind) + marker.kind : + TownLocations::LocationKind::Interest; + PoiDatabase::Record rec; + rec.id = marker.name; + rec.kind = PoiDatabase::kindName(kind); + rec.domain = TownLocations::isIndoorKind(kind) ? + PoiDatabase::Domain::Interior : + PoiDatabase::Domain::Exterior; + rec.tags = { rec.kind }; + if (t.hasWorldPosition) { + rec.position = + RenderOriginSystem::getInstance() + ->worldToRender(t.worldX, + t.worldY, + t.worldZ); + } else if (t.node) { + rec.position = t.node->getPosition(); + } + std::string err; + const std::string id = rec.id; + if (!set.add(std::move(rec), &err)) { + Ogre::LogManager::getSingleton().logMessage( + "[poi] marker '" + marker.name + + "' not merged: " + err); + } else { + authoredIds.insert(id); + } + }); + + /* Authored full POIs (PoiComponent): planner data and the + * activation spec come from the component, position from the + * entity transform (render space, same as generated + * locations). */ + m_world.query().each( + [&](flecs::entity, const PoiComponent &poi, + const TransformComponent &t) { + if (poi.poiId.empty()) { + Ogre::LogManager::getSingleton().logMessage( + "[poi] skipping a PoiComponent with an " + "empty poiId"); + return; + } + PoiDatabase::Record rec; + rec.id = poi.poiId; + rec.kind = poi.kind; + rec.domain = poi.domain(); + rec.address = ""; + rec.building = -1; + rec.tags = poi.tags; + rec.entranceRole = poi.entranceRole; + rec.openHour = poi.openHour; + rec.closeHour = poi.closeHour; + rec.statEffects = poi.statEffects; + rec.activation.kind = + (PoiDatabase::ActivationKind)poi.activationKind; + rec.activation.behaviorTree = poi.behaviorTree; + rec.activation.luaScript = poi.luaScript; + rec.activation.radius = poi.radius; + rec.activation.cooldownSeconds = poi.cooldownSeconds; + rec.activation.once = poi.once; + if (t.hasWorldPosition) { + rec.position = + RenderOriginSystem::getInstance() + ->worldToRender(t.worldX, + t.worldY, + t.worldZ); + } else if (t.node) { + rec.position = t.node->getPosition(); + } + std::string err; + const std::string id = rec.id; + if (!set.add(std::move(rec), &err)) { + Ogre::LogManager::getSingleton().logMessage( + "[poi] POI '" + poi.poiId + + "' not merged: " + err); + } else { + authoredIds.insert(id); + } + }); +} + +const PoiDatabase::Set * +NpcPopulationSystem::areaPoiSet(int areaIndex) const +{ + auto it = m_areas.find(areaIndex); + return it == m_areas.end() ? nullptr : &it->second.poiSet; +} + +std::vector NpcPopulationSystem::areaIds() const +{ + std::vector ids; + for (const auto &kv : m_areas) + ids.push_back(kv.first); + return ids; +} + int NpcPopulationSystem::areaIndexOf(uint64_t registryId) const { for (const auto &kv : m_areas) diff --git a/src/features/editScene/systems/NpcPopulationSystem.hpp b/src/features/editScene/systems/NpcPopulationSystem.hpp index 2e3bc2c..0a66d33 100644 --- a/src/features/editScene/systems/NpcPopulationSystem.hpp +++ b/src/features/editScene/systems/NpcPopulationSystem.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -10,6 +11,7 @@ #include "../roadlib/TownPopulation.hpp" #include "../roadlib/TownLocations.hpp" #include "../roadlib/TownSchedule.hpp" +#include "../roadlib/PoiDatabase.hpp" /** * NPC population system (demo-taxi phase 4.7.2). @@ -80,6 +82,10 @@ public: /** The generated location set of an area, nullptr when the * area has no generated population. */ const TownLocations::LocationSet *areaLocations(int areaIndex) const; + /** The area's unified POI database (phase 6.8.11). */ + const PoiDatabase::Set *areaPoiSet(int areaIndex) const; + /** Generated area indices (for POI/location inspectors). */ + std::vector areaIds() const; /** Index of the town area that generated @p registryId, -1 when * the id is no population NPC (linear scan — the per-area id @@ -109,11 +115,25 @@ private: struct AreaPopulation { std::vector characterIds; std::vector groupIds; + /* The area's unified POI database (phase 6.8.11) and + * the LocationSet projected from it (planner view). */ + PoiDatabase::Set poiSet; TownLocations::LocationSet locations; std::vector schedules; + /* Ids merged from authored components (markers/POIs) so + * the periodic re-merge can replace exactly those — + * runtime-added POIs appear within a second, no full + * regeneration needed. */ + std::set authoredPoiIds; + int poiMergeCounter = 0; }; int clearArea(int areaIndex); + /* Merge authored NpcLocationComponent markers (scene + * landmarks) and PoiComponent POIs into the area's POI + * database; authoredIds collects exactly the merged ids. */ + void mergeAuthoredPoiMarkers(PoiDatabase::Set &set, + std::set &authoredIds); flecs::world &m_world; std::map m_areas; diff --git a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp index 7e7d48a..50f8f10 100644 --- a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp +++ b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp @@ -9,6 +9,7 @@ #include "../EditorApp.hpp" #include "CharacterRegistry.hpp" +#include "CharacterStatStore.hpp" #include "GameClockSystem.hpp" #include "NpcDetailSystem.hpp" #include "NpcMotionSystem.hpp" @@ -205,6 +206,17 @@ void NpcScheduleDebugOverlay::render() ImGui::Text("work: %s", col("npcWork").c_str()); ImGui::Text("interest: %s", col("npcInterest").c_str()); + /* Phase 6.8.9: live stat values for the selected + * NPC (the planner's snapshot view). */ + auto stats = + CharacterStatStore::getInstance().snapshot(selId); + if (!stats.empty()) { + ImGui::Separator(); + ImGui::Text("stats:"); + for (const auto &kv : stats) + ImGui::Text(" %-8s %.1f", + kv.first.c_str(), kv.second); + } /* Phase 4.7.5: symbolic motion state + position. */ if (motion) { NpcMotionSystem::MotionInfo mi; diff --git a/src/features/editScene/systems/NpcTrafficSystem.cpp b/src/features/editScene/systems/NpcTrafficSystem.cpp index b95459f..f72cdf8 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.cpp +++ b/src/features/editScene/systems/NpcTrafficSystem.cpp @@ -133,6 +133,10 @@ bool NpcTrafficSystem::trafficInfoOf(uint64_t driverId, out.parkedAtChain = car.claimedK; out.parkBlockedSeconds = car.parkBlockedSec; out.fallbackActive = car.parkFallbackFired; + out.parkTarget = car.liveDestPos; + if (car.vehicle.is_alive() && car.vehicle.has()) + out.aiArrived = + car.vehicle.get().arrived; return true; } @@ -1357,8 +1361,18 @@ void NpcTrafficSystem::stepHighDetail(TrafficCar &car, float dt, if (dist < car.parkMinDist - 0.1f) { car.parkMinDist = dist; car.parkBlockedSec = 0.0f; + car.parkStuckSec = 0.0f; } else if (dist <= parkEngageM || ai.arrived) { car.parkBlockedSec += dt; + /* 6.8.2a: immobility tracking — a wedged + * car (high-centered on a pad edge, the + * throttle fighting nothing) reads + * ~zero speed while the AI keeps + * commanding throttle. */ + if (speed < 0.1f) + car.parkStuckSec += dt; + else + car.parkStuckSec = 0.0f; } if (ai.arrived && @@ -1386,6 +1400,22 @@ void NpcTrafficSystem::stepHighDetail(TrafficCar &car, float dt, !car.parkFallbackFired) { retargetToFallback(car, graph); } + + /* 6.8.2a live recovery: even after the curb + * fallback the car may be physically unable to + * reach the pose (wedged, high-centered) — a + * stranded live car must never deadlock the + * leg's alight. Once the fallback is in and + * the car has sat immobile for + * parkRecoverSeconds, teleport the chassis + * onto the fallback pose: the symbolic layer + * stays the ground truth and the observed + * physics is best-effort. */ + if (car.parkFallbackFired && + car.parkStuckSec >= parkRecoverSeconds) { + recoverParkedTeleport(car); + return; + } } } } @@ -1442,6 +1472,7 @@ void NpcTrafficSystem::retargetToFallback(TrafficCar &car, car.parkFallbackFired = true; car.parkBlockedSec = 0.0f; car.parkMinDist = FLT_MAX; + car.parkStuckSec = 0.0f; if (graph) { parkedPlacement(*best, graph, car.liveDestPos, @@ -1493,6 +1524,44 @@ void NpcTrafficSystem::retargetToFallback(TrafficCar &car, } } +void NpcTrafficSystem::recoverParkedTeleport(TrafficCar &car) +{ + if (!car.vehicle.is_alive() || + !car.vehicle.has()) + return; + RigidBodyComponent &rb = + car.vehicle.get_mut(); + if (!rb.bodyCreated) + return; + JoltPhysicsWrapper *phys = + m_editorApp && m_editorApp->getEditorPhysicsSystem() + ? m_editorApp->getEditorPhysicsSystem() + ->getPhysicsWrapper() + : nullptr; + if (!phys) + return; + /* The pose is world space (double precision — the Jolt RVec3 + * setPosition overload); the vehicle node syncs off the body on + * the next physics step. */ + phys->setPosition(rb.bodyID, + JPH::RVec3(car.liveDestPos.x, car.liveDestPos.y, + car.liveDestPos.z)); + phys->setRotation(rb.bodyID, car.liveDestRot); + phys->setVelocities(rb.bodyID, Ogre::Vector3::ZERO, + Ogre::Vector3::ZERO); + car.parkStuckSec = 0.0f; + car.parkBlockedSec = 0.0f; + car.parkMinDist = FLT_MAX; + /* A post-teleport coarse stop may still sit outside the alight + * gate — let the creep corrector work once more. */ + car.parkCreepFired = false; + Ogre::LogManager::getSingleton().logMessage( + "NpcTrafficSystem: driver " + + std::to_string((unsigned long long)car.driverId) + + " wedged near its park approach — recovered by teleport " + "onto the fallback pose"); +} + bool NpcTrafficSystem::spawnHighDetail(TrafficCar &car) { PrefabSystem prefabSys(m_world, m_editorApp->getSceneManager()); diff --git a/src/features/editScene/systems/NpcTrafficSystem.hpp b/src/features/editScene/systems/NpcTrafficSystem.hpp index 50afc97..9e07837 100644 --- a/src/features/editScene/systems/NpcTrafficSystem.hpp +++ b/src/features/editScene/systems/NpcTrafficSystem.hpp @@ -129,6 +129,12 @@ public: * the live car takes a curb fallback. */ float parkBlockedSeconds = 0.0f; bool fallbackActive = false; + /** 6.8.2a drop-off read-out: the live pose the alight + * gate scores against (the claimed slot, or the + * fallback curb spot once retargeted) and the AI's + * arrived latch. */ + Ogre::Vector3 parkTarget = Ogre::Vector3::ZERO; + bool aiArrived = false; }; /** True while the NPC is in a drive-leg window (suspended from @@ -229,6 +235,14 @@ public: float parkAlightSpeedMps = 0.6f; float parkEngageM = 9.0f; float parkFallbackSeconds = 5.0f; + /** Immobile-recovery timeout (6.8.2a): after the curb fallback + * fired, a car that still cannot approach its pose because it + * is physically wedged/high-centered (throttle commanded, + * ~zero speed) is TELEPORTED onto the fallback pose — the + * symbolic layer stays the ground truth and the observed + * physics is best-effort; a stranded live car must never + * deadlock the leg (the driver otherwise never alights). */ + float parkRecoverSeconds = 4.0f; /** Spawn/despawn distance (squared, world units) camera<->car; * larger than the pedestrian tier — cars are fast and big. */ float spawnDistanceSq = 150.0f * 150.0f; @@ -351,6 +365,11 @@ private: bool haveLastLivePos = false; float parkMinDist = FLT_MAX; float parkBlockedSec = 0.0f; + /** 6.8.2a: seconds spent ~immobilized while engaged with + * the park approach (throttle commanded, ~zero speed); + * recovered (teleport onto the pose) once the fallback + * has fired and this crosses parkRecoverSeconds. */ + float parkStuckSec = 0.0f; bool parkCreepFired = false; bool parkFallbackFired = false; bool parkFallbackWarned = false; @@ -416,6 +435,11 @@ private: bool ownDriver(const TrafficCar &car, flecs::entity ch) const; flecs::entity createDriverSpawner(uint64_t registryId); bool spawnHighDetail(TrafficCar &car); + /** 6.8.2a: teleport a wedged car onto its fallback pose (the + * last resort after retargetToFallback — the AI cannot drive + * it there). Velocities zeroed; the watchdog state resets so + * the alight gate accepts on the next frames. */ + void recoverParkedTeleport(TrafficCar &car); void despawnHighDetail(TrafficCar &car); void seatDriver(TrafficCar &car, flecs::entity character); size_t routeIndexAt(const LegRoute &leg, float progress) const; diff --git a/src/features/editScene/systems/PoiSystem.cpp b/src/features/editScene/systems/PoiSystem.cpp new file mode 100644 index 0000000..b5a94d0 --- /dev/null +++ b/src/features/editScene/systems/PoiSystem.cpp @@ -0,0 +1,210 @@ +#include "PoiSystem.hpp" + +#include "../EditorApp.hpp" +#include "EventBus.hpp" +#include "ActuatorSystem.hpp" +#include "NpcDetailSystem.hpp" +#include "../components/Actuator.hpp" +#include "../components/Character.hpp" +#include "../components/CharacterIdentity.hpp" +#include "../components/Transform.hpp" + +PoiSystem::PoiSystem(flecs::world &world, EditorApp *editorApp) + : m_world(world) + , m_editorApp(editorApp) +{ +} + +int PoiSystem::getActivationCount(const Ogre::String &poiId) const +{ + auto it = m_activationCounts.find(poiId); + return it == m_activationCounts.end() ? 0 : it->second; +} + +/* + * Every frame, before the proximity pass. Collected during the query + * and applied AFTER the loop: adding an ActuatorComponent inside query + * iteration moves the entity to another table (flecs swap-and-pops the + * vacated row), which dangles the iteration's component references — + * observed as an OnApproach POI executing a DIFFERENT POI's behavior + * tree with its data. Never add components inside these lambdas. + */ +void PoiSystem::ensureActuators() +{ + std::vector> pending; + m_world.query().each( + [&](flecs::entity e, const PoiComponent &poi) { + if (poi.activationKind == 0 || + poi.behaviorTree.empty()) + return; + bool present = false; + if (e.has()) { + const auto &actuator = + e.get(); + for (const auto &name : actuator.actionNames) + if (name == poi.behaviorTree) + present = true; + } + if (!present) + pending.push_back({ e, poi }); + }); + for (const auto &pendingPoi : pending) + ensureActuatorFor(pendingPoi.first, pendingPoi.second); +} + +void PoiSystem::ensureActuatorFor(flecs::entity poiEntity, + const PoiComponent &poi) +{ + if (poi.behaviorTree.empty()) + return; + ActuatorComponent actuator; + if (poiEntity.has()) + actuator = poiEntity.get(); + bool present = false; + for (const auto &name : actuator.actionNames) + if (name == poi.behaviorTree) + present = true; + if (present) + return; + actuator.actionNames.push_back(poi.behaviorTree); + if (actuator.radius < poi.radius) + actuator.radius = poi.radius; + poiEntity.set(actuator); +} + +bool PoiSystem::canFire(const FireKey &key, const PoiComponent &poi) const +{ + if (poi.once && m_firedOnce.count(key)) + return false; + auto it = m_lastFireTime.find(key); + if (it != m_lastFireTime.end() && + m_time - it->second < poi.cooldownSeconds) + return false; + return true; +} + +void PoiSystem::fire(const FireKey &key, flecs::entity poiEntity, + const PoiComponent &poi) +{ + m_activationCounts[poi.poiId]++; + m_lastFireTime[key] = m_time; + if (poi.once) + m_firedOnce.insert(key); + + if (!poi.behaviorTree.empty()) { + /* ActuatorComponent is ensured every frame before the + * proximity pass — never add components inside the + * iteration (table move dangles the loop's references, + * see ensureActuators). */ + ActuatorSystem *actuators = m_editorApp->getActuatorSystem(); + if (actuators) { + flecs::entity actor = m_world.entity(key.actor); + if (actor.is_alive()) + actuators->executeAction(actor, poiEntity, + poi.behaviorTree); + } + return; + } + if (!poi.luaScript.empty()) { + editScene::EventParams params; + params.setString("poi", poi.poiId); + params.setString("script", poi.luaScript); + params.setEntityId("actor", key.actor); + params.setEntityId("entity", key.poi); + EventBus::getInstance().send("poi_activated", params); + } +} + +void PoiSystem::update(float deltaTime) +{ + if (!m_editorApp || + m_editorApp->getGameMode() != EditorApp::GameMode::Game || + m_editorApp->getGamePlayState() != + EditorApp::GamePlayState::Playing) + return; + + m_time += deltaTime; + ensureActuators(); + + /* Actors: the player character plus every high-detail NPC. */ + struct Actor { + flecs::entity entity; + Ogre::Vector3 position; + float speed = 0.0f; + }; + std::vector actors; + flecs::entity player = m_editorApp->getPlayerCharacterEntity(); + if (player.is_alive() && player.has()) { + const auto &t = player.get(); + float speed = 0.0f; + if (player.has()) + speed = player.get() + .linearVelocity.length(); + if (t.node) + actors.push_back({ player, t.node->getPosition(), + speed }); + } + m_world.query() + .each([&](flecs::entity e, const CharacterComponent &cc, + const CharacterIdentityComponent &ci, + const TransformComponent &t) { + if (!t.node) + return; + NpcDetailSystem *detail = NpcDetailSystem::getInstance(); + if (!detail || !detail->isHighDetail(ci.registryId)) + return; + actors.push_back( + { e, t.node->getPosition(), + cc.linearVelocity.length() }); + }); + + /* Read-only pass: fire() only mutates PoiSystem bookkeeping and + * actuator VALUES (executeAction's get_mut), never structure. */ + m_world.query().each( + [&](flecs::entity poiEntity, const PoiComponent &poi, + const TransformComponent &t) { + if (poi.activationKind == 0 || !t.node) + return; + if (poi.behaviorTree.empty() && poi.luaScript.empty()) + return; + const Ogre::Vector3 poiPos = t.node->getPosition(); + const float radiusSq = poi.radius * poi.radius; + for (const Actor &actor : actors) { + Ogre::Vector3 delta = + actor.position - poiPos; + delta.y = 0.0f; + if (delta.squaredLength() > radiusSq) { + FireKey key{ actor.entity.id(), + poiEntity.id() }; + m_settleStart.erase(key); + continue; + } + FireKey key{ actor.entity.id(), + poiEntity.id() }; + if (poi.activationKind == 2) { + /* OnApproach. */ + if (canFire(key, poi)) + fire(key, poiEntity, poi); + } else if (poi.activationKind == 3) { + /* OnArrive: dwell 0.5 s while + * settled inside the radius. */ + if (actor.speed > 0.3f) { + m_settleStart.erase(key); + continue; + } + auto it = m_settleStart.find(key); + if (it == m_settleStart.end()) { + m_settleStart[key] = m_time; + continue; + } + if (m_time - it->second < 0.5) + continue; + if (canFire(key, poi)) { + fire(key, poiEntity, poi); + m_settleStart.erase(key); + } + } + } + }); +} diff --git a/src/features/editScene/systems/PoiSystem.hpp b/src/features/editScene/systems/PoiSystem.hpp new file mode 100644 index 0000000..421c7f1 --- /dev/null +++ b/src/features/editScene/systems/PoiSystem.hpp @@ -0,0 +1,78 @@ +#ifndef EDITSCENE_POISYSTEM_HPP +#define EDITSCENE_POISYSTEM_HPP +#pragma once + +#include +#include +#include +#include +#include "../components/Poi.hpp" + +class EditorApp; + +/** + * Mechanical activation of the unified POI model (phase 6.8.11). + * + * Owns the runtime side of PoiComponent activation: ensures an + * ActuatorComponent for behavior-tree POIs (so the existing E-prompt + * and ActuatorSystem::executeAction path works for players AND + * high-detail NPCs), and fires activations per trigger mode: + * + * Manual - the player E prompt through ActuatorSystem (the + * PoiSystem does nothing per-frame for it beyond the + * ensured ActuatorComponent). + * OnApproach - an actor (player or high-detail NPC) entering the + * POI radius fires immediately. + * OnArrive - an actor settling within the radius for 0.5 s + * (speed < 0.3 m/s) fires. + * + * A behaviorTree (ActionDatabase action name) executes through + * ActuatorSystem::executeAction; a luaScript instead fires the + * `poi_activated` event (params: poi id, script, actor entity id, + * poi entity id) for scene scripts to handle. Cooldowns are per + * (actor, poi); `once` fires once per (actor, poi). Fired flags, + * cooldowns and dwell timers are runtime-only, never serialized. + * + * flecs rule: ActuatorComponents are ensured BEFORE the proximity + * pass (collect-then-apply). Adding a component inside a query's + * each() moves the entity to another table, dangling the iteration's + * component references (the probe fired another POI's behavior tree + * that way). fire() only writes component VALUES. + */ +class PoiSystem { +public: + PoiSystem(flecs::world &world, EditorApp *editorApp); + + void update(float deltaTime); + + /** Activations fired for a POI id (tests/debug overlay). */ + int getActivationCount(const Ogre::String &poiId) const; + +private: + struct FireKey { + uint64_t actor = 0; + uint64_t poi = 0; + bool operator<(const FireKey &other) const + { + return actor < other.actor || + (actor == other.actor && poi < other.poi); + } + }; + + void ensureActuators(); + void ensureActuatorFor(flecs::entity poiEntity, + const PoiComponent &poi); + bool canFire(const FireKey &key, const PoiComponent &poi) const; + void fire(const FireKey &key, flecs::entity poiEntity, + const PoiComponent &poi); + + flecs::world &m_world; + EditorApp *m_editorApp; + double m_time = 0.0f; + std::map m_lastFireTime; + std::set m_firedOnce; + std::map m_settleStart; + std::map m_activationCounts; +}; + +#endif // EDITSCENE_POISYSTEM_HPP diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index ab1bbd1..9740066 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -51,6 +51,7 @@ #include "../components/Pushable.hpp" #include "../components/TargetZone.hpp" #include "../components/NpcLocation.hpp" +#include "../components/Poi.hpp" #include "../components/GoapPlanner.hpp" #include "../components/Item.hpp" #include "../components/Inventory.hpp" @@ -426,6 +427,9 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) if (entity.has()) { json["npcLocation"] = serializeNpcLocation(entity); } + if (entity.has()) { + json["poi"] = serializePoi(entity); + } if (entity.has()) { json["eventHandler"] = serializeEventHandler(entity); } @@ -684,6 +688,9 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, if (json.contains("npcLocation")) { deserializeNpcLocation(entity, json["npcLocation"]); } + if (json.contains("poi")) { + deserializePoi(entity, json["poi"]); + } if (json.contains("eventHandler")) { deserializeEventHandler(entity, json["eventHandler"]); } @@ -1018,6 +1025,9 @@ void SceneSerializer::deserializeEntityComponents( if (json.contains("npcLocation")) { deserializeNpcLocation(entity, json["npcLocation"]); } + if (json.contains("poi")) { + deserializePoi(entity, json["poi"]); + } if (json.contains("sceneScript")) { deserializeSceneScript(entity, json["sceneScript"]); } @@ -4620,6 +4630,51 @@ nlohmann::json SceneSerializer::serializeNpcLocation(flecs::entity entity) return json; } +nlohmann::json SceneSerializer::serializePoi(flecs::entity entity) +{ + const PoiComponent &poi = entity.get(); + nlohmann::json json; + json["poiId"] = poi.poiId; + json["kind"] = poi.kind; + json["tags"] = poi.tags; + json["entranceRole"] = poi.entranceRole; + json["openHour"] = poi.openHour; + json["closeHour"] = poi.closeHour; + json["statEffects"] = poi.statEffects; + json["activationKind"] = poi.activationKind; + json["behaviorTree"] = poi.behaviorTree; + json["luaScript"] = poi.luaScript; + json["radius"] = poi.radius; + json["cooldownSeconds"] = poi.cooldownSeconds; + json["once"] = poi.once; + return json; +} + +void SceneSerializer::deserializePoi(flecs::entity entity, + const nlohmann::json &json) +{ + PoiComponent poi; + poi.poiId = json.value("poiId", ""); + poi.kind = json.value("kind", "interest"); + poi.tags = json.value("tags", std::vector{}); + poi.entranceRole = json.value("entranceRole", ""); + poi.openHour = json.value("openHour", -1.0f); + poi.closeHour = json.value("closeHour", -1.0f); + if (json.contains("statEffects") && + json["statEffects"].is_object()) { + for (auto it = json["statEffects"].begin(); + it != json["statEffects"].end(); ++it) + poi.statEffects[it.key()] = it.value().get(); + } + poi.activationKind = json.value("activationKind", 0); + poi.behaviorTree = json.value("behaviorTree", ""); + poi.luaScript = json.value("luaScript", ""); + poi.radius = json.value("radius", 1.5f); + poi.cooldownSeconds = json.value("cooldownSeconds", 0.0f); + poi.once = json.value("once", false); + entity.set(poi); +} + void SceneSerializer::deserializeNpcLocation(flecs::entity entity, const nlohmann::json &json) { diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 1a90b0e..709aa47 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -282,6 +282,7 @@ private: nlohmann::json serializePushable(flecs::entity entity); nlohmann::json serializeTargetZone(flecs::entity entity); nlohmann::json serializeNpcLocation(flecs::entity entity); + nlohmann::json serializePoi(flecs::entity entity); nlohmann::json serializeEventHandler(flecs::entity entity); nlohmann::json serializeSceneScript(flecs::entity entity); nlohmann::json serializeStandaloneDoor(flecs::entity entity); @@ -303,6 +304,7 @@ private: const nlohmann::json &json); void deserializeNpcLocation(flecs::entity entity, const nlohmann::json &json); + void deserializePoi(flecs::entity entity, const nlohmann::json &json); void deserializeEventHandler(flecs::entity entity, const nlohmann::json &json); void deserializeSceneScript(flecs::entity entity, diff --git a/src/features/editScene/systems/ScheduleScoring.cpp b/src/features/editScene/systems/ScheduleScoring.cpp new file mode 100644 index 0000000..bfa62a1 --- /dev/null +++ b/src/features/editScene/systems/ScheduleScoring.cpp @@ -0,0 +1,55 @@ +#include "ScheduleScoring.hpp" + +namespace ScheduleScoring +{ + +namespace +{ + +Hook &hookSlot() +{ + static Hook hook; + return hook; +} + +uint32_t fnv1a(const std::string &text) +{ + uint32_t hash = 2166136261u; + for (unsigned char c : text) { + hash ^= c; + hash *= 16777619u; + } + return hash; +} + +} // namespace + +double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, + const Weights &weights) +{ + (void)snapshot; /* the default ignores stats by design */ + double jitter = (double)fnv1a(candidate.name) / (double)UINT32_MAX; + return -(weights.usage * (double)candidate.usage + + weights.distance * candidate.distanceMeters) + + weights.jitter * jitter; +} + +void setHook(Hook hook) +{ + hookSlot() = std::move(hook); +} + +void clearHook() +{ + hookSlot() = nullptr; +} + +double score(const Candidate &candidate, const StatsSnapshot &snapshot, + const Weights &weights) +{ + if (hookSlot()) + return hookSlot()(candidate, snapshot); + return defaultScore(candidate, snapshot, weights); +} + +} // namespace ScheduleScoring diff --git a/src/features/editScene/systems/ScheduleScoring.hpp b/src/features/editScene/systems/ScheduleScoring.hpp new file mode 100644 index 0000000..ea9726c --- /dev/null +++ b/src/features/editScene/systems/ScheduleScoring.hpp @@ -0,0 +1,69 @@ +#ifndef EDITSCENE_SCHEDULESCORING_HPP +#define EDITSCENE_SCHEDULESCORING_HPP +#pragma once + +#include +#include +#include +#include +#include + +/** + * Planner location scoring (demo-taxi phase 6.8.9 step 3). + * + * Given a candidate location and a week-start stats snapshot, answer + * "how good is this location for this NPC?" as one comparable number + * (higher = better). The weekly schedule generator (6.8.3) ranks + * candidates with it; tests hardcode stats and expect exact scores. + * + * Two interchangeable implementations behind score(): + * - defaultScore(): pure C++ — lower current usage and shorter + * distance from home win, plus a deterministic name-hash jitter + * for tie-breaking. The stats snapshot is deliberately unused. + * - an optional hook (C++ std::function or a Lua function via + * ecs.schedule.set_score_hook): read-only, receives only the + * candidate and the snapshot, so stat effects on POIs can steer + * the choice (poor NPCs prefer high-money POIs, stressed NPCs + * stress-relieving ones). A hook MUST NOT mutate game state. + */ +namespace ScheduleScoring +{ + +using StatsSnapshot = std::unordered_map; + +struct Candidate { + /** Stable location name (POI id / LocationSet name). */ + std::string name; + /** Planner tags (kind, job tags, entrance roles). */ + std::vector tags; + /** Distance from the NPC's home in meters (road-path metric). */ + double distanceMeters = 0.0; + /** How many NPCs already picked this location in this build. */ + int usage = 0; + /** Stat effects offered per in-game hour (e.g. money=+10). */ + StatsSnapshot effects; +}; + +struct Weights { + double usage = 1.0; + double distance = 0.01; + double jitter = 0.001; +}; + +/** The pure default: -(usage*w + distance*w) + hashJitter*w. */ +double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, + const Weights &weights = Weights{}); + +using Hook = std::function; + +/** Install/remove the global override hook (empty = default). */ +void setHook(Hook hook); +void clearHook(); + +/** Score with the hook when installed, otherwise the default. */ +double score(const Candidate &candidate, const StatsSnapshot &snapshot, + const Weights &weights = Weights{}); + +} // namespace ScheduleScoring + +#endif // EDITSCENE_SCHEDULESCORING_HPP diff --git a/src/features/editScene/tests/character_stats_test.cpp b/src/features/editScene/tests/character_stats_test.cpp index 6d7bfa8..36bc776 100644 --- a/src/features/editScene/tests/character_stats_test.cpp +++ b/src/features/editScene/tests/character_stats_test.cpp @@ -298,6 +298,57 @@ static int testLuaApi() return 0; } +static int testLuaRulesModule() +{ + TEST("Lua character_stats_rules module (hardcoded activities)"); + + lua_State *L = luaL_newstate(); + luaL_openlibs(L); + lua_newtable(L); + lua_setglobal(L, "ecs"); + editScene::registerLuaCharacterStatsApi(L); + + /* The module returns its M table; keep it as a global. */ + if (luaL_loadfile(L, CHARACTER_STATS_RULES_LUA) != LUA_OK || + lua_pcall(L, 0, 1, 0) != LUA_OK) { + std::string err = lua_tostring(L, -1); + lua_close(L); + FAIL("cannot load the rules module: " + err); + } + lua_setglobal(L, "rules"); + + const char *script = R"( + rules.define() + rules.apply_activity(21, "work", 2.0) + assert(ecs.stats.get(21, "money") == 20.0) + assert(ecs.stats.get(21, "stress") == 10.0) + rules.apply_activity(21, "leisure", 1.0) + assert(ecs.stats.get(21, "money") == 15.0) + assert(ecs.stats.get(21, "stress") == 0.0) + rules.apply_activity(21, "home", 3.0) + assert(ecs.stats.get(21, "stress") == 0.0) + rules.apply_activity(21, "work", 100.0) + assert(ecs.stats.get(21, "money") == 1015.0) + assert(ecs.stats.get(21, "stress") == 100.0) + rules.apply_activity(21, "hospital", 1.0) + assert(ecs.stats.get(21, "health") == 100.0) + assert(ecs.stats.get(21, "stress") == 95.0) + rules.apply_activity(21, "unknown-activity", 1.0) + assert(ecs.stats.get(21, "money") == 1015.0) + assert(rules.emergency.hospitalBelowHealth == 10.0) + assert(rules.emergency.dischargeHealth == 100.0) + )"; + if (luaL_dostring(L, script) != LUA_OK) { + std::string err = lua_tostring(L, -1); + lua_close(L); + FAIL("rules module script failed: " + err); + } + lua_close(L); + + PASS(); + return 0; +} + int main() { printf("Character stats tests\n"); @@ -314,6 +365,7 @@ int main() failures += testFileRoundTrip(); failures += testClearCharacter(); failures += testLuaApi(); + failures += testLuaRulesModule(); printf("%d/%d tests passed\n", passCount, testCount); return failures ? 1 : 0; } diff --git a/src/features/editScene/tests/component_lua_test.cpp b/src/features/editScene/tests/component_lua_test.cpp index 6b1fc69..e425215 100644 --- a/src/features/editScene/tests/component_lua_test.cpp +++ b/src/features/editScene/tests/component_lua_test.cpp @@ -1263,6 +1263,72 @@ static int testStandaloneDoorComponent(lua_State *L) return 0; } +// --------------------------------------------------------------------------- +// Test 32d: Poi component (phase 6.8.11 unified POI) +// --------------------------------------------------------------------------- + +static int testPoiComponent(lua_State *L) +{ + TEST("Poi component (6.8.11)"); + + bool ok = runLua( + L, "local id = ecs.create_entity();" + "ecs.set_component(id, 'Poi', {" + " poiId = 'poi:lua-test'," + " kind = 'interest'," + " tags = { 'probe', 'test' }," + " entranceRole = 'front'," + " openHour = 8.0," + " closeHour = 18.0," + " statEffects = { money = 5.0, stress = -2.0 }," + " activationKind = 2," + " behaviorTree = 'poi_probe_action'," + " radius = 2.5," + " cooldownSeconds = 10.0," + " once = true" + "});" + "local c = ecs.get_component(id, 'Poi');" + "assert(c ~= nil, 'Poi should exist');" + "assert(c.poiId == 'poi:lua-test', 'wrong poiId');" + "assert(c.kind == 'interest', 'wrong kind');" + "assert(#c.tags == 2, 'wrong tag count');" + "assert(c.tags[1] == 'probe', 'wrong tag 1');" + "assert(c.tags[2] == 'test', 'wrong tag 2');" + "assert(c.entranceRole == 'front', " + "'wrong entranceRole');" + "assert(c.openHour == 8.0, 'wrong openHour');" + "assert(c.closeHour == 18.0, 'wrong closeHour');" + "assert(c.statEffects['money'] == 5.0, " + "'wrong money effect');" + "assert(c.statEffects['stress'] == -2.0, " + "'wrong stress effect');" + "assert(c.activationKind == 2, " + "'wrong activationKind');" + "assert(c.behaviorTree == 'poi_probe_action', " + "'wrong behaviorTree');" + "assert(math.abs(c.radius - 2.5) < 0.001, " + "'wrong radius');" + "assert(c.cooldownSeconds == 10.0, " + "'wrong cooldownSeconds');" + "assert(c.once == true, 'wrong once');" + "ecs.set_field(id, 'Poi', 'radius', 4.0);" + "local c2 = ecs.get_component(id, 'Poi');" + "assert(math.abs(c2.radius - 4.0) < 0.001, " + "'radius not updated');" + "assert(c2.activationKind == 2, " + "'partial update lost activationKind');" + "assert(c2.statEffects['money'] == 5.0, " + "'partial update lost effects');" + "ecs.remove_component(id, 'Poi');" + "assert(ecs.has_component(id, 'Poi') == false, " + "'Poi should be removed')"); + if (!ok) + FAIL("Poi component assertion failed"); + + PASS(); + return 0; +} + // --------------------------------------------------------------------------- // Test 33: Room component // --------------------------------------------------------------------------- @@ -2138,6 +2204,7 @@ int main() failures += testCellGridComponent(L); failures += testCellGridDoorConfig(L); failures += testStandaloneDoorComponent(L); + failures += testPoiComponent(L); failures += testRoomComponent(L); failures += testLotComponent(L); failures += testDistrictComponent(L); diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index 4c266d3..32853dc 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -41,6 +41,7 @@ #include "roadlib/TownParking.hpp" #include "roadlib/TownPopulation.hpp" #include "roadlib/TownLocations.hpp" +#include "roadlib/PoiDatabase.hpp" #include "roadlib/TownSchedule.hpp" #include @@ -4131,6 +4132,141 @@ static bool runTownGenProbe() ok = false; } + /* --- Phase 6.8.1: DSL macro blocks --- */ + { + auto plansEqual = [](const BuildingPlan &a, + const BuildingPlan &b) { + return a.cellsX == b.cellsX && + a.cellsZ == b.cellsZ && + a.floors == b.floors && + a.cellSize == b.cellSize && + a.cellHeight == b.cellHeight && + a.cells == b.cells && + a.roomIds == b.roomIds && + a.roomNames == b.roomNames && + a.roomTypes == b.roomTypes && + a.roofs.size() == b.roofs.size(); + }; + const char *macroSrc = + "(defmacro rect-room (name x0 z0 x1 z1)" + " (room name (rect x0 z0 x1 z1)))" + "(defmacro suite (name x0 z0)" + " (rect-room name x0 z0 (+ x0 0.5) (+ z0 0.5)))" + "(layout macro-house (shell 12 12 :empty)" + " (rect-room living 0 0 0.5 0.5)" + " (suite kitchen 0.5 0.5))"; + const char *handSrc = + "(shell 12 12 :empty)" + "(room living (rect 0 0 0.5 0.5))" + "(room kitchen (rect 0.5 0.5 1 1))"; + + /* Library macros: expansion must be identical to + * the hand-written form (nested macro calls and + * arithmetic on substituted parameters included). */ + LayoutLibrary libM; + if (!libM.loadSource(macroSrc, &derr)) { + printf(" *** dsl macro load: '%s' ***\n", + derr.c_str()); + ok = false; + } + BuildingPlan pM, pH; + { + std::mt19937 rngM(7), rngH(7); + LayoutEval evM(lb, &rngM, &libM); + LayoutEval evH(lb, &rngH, nullptr); + if (!evM.evaluate("macro-house", pM, &derr) || + !evH.evaluate(handSrc, pH, &derr) || + !plansEqual(pM, pH)) { + printf(" *** dsl macro expand: err " + "'%s' ***\n", + derr.c_str()); + ok = false; + } + } + + /* Inline defmacro forms work without a library. */ + { + std::string inlineSrc = macroSrc; + inlineSrc = inlineSrc.substr( + 0, inlineSrc.find("(layout")); + inlineSrc += handSrc; + BuildingPlan pI; + std::mt19937 rngI(7); + LayoutEval evI(lb, &rngI, nullptr); + if (!evI.evaluate(inlineSrc, pI, &derr) || + !plansEqual(pI, pH)) { + printf(" *** dsl macro inline: err " + "'%s' ***\n", + derr.c_str()); + ok = false; + } + } + + /* Arity errors name the macro and the counts. */ + BuildingPlan pArity; + LayoutEval evArity(lb, &rngDsl, &libM); + if (evArity.evaluate( + "(shell 12 12 :empty)(suite kitchen 4)", + pArity, &derr) || + derr.find("expects") == std::string::npos) { + printf(" *** dsl macro arity: '%s' ***\n", + derr.c_str()); + ok = false; + } + + /* Recursive macros hit the expansion cap with a + * call chain instead of hanging. */ + LayoutEval evRec(lb, &rngDsl, nullptr); + BuildingPlan pRec; + if (evRec.evaluate("(defmacro a () (a))" + "(shell 10 10)(a)", + pRec, &derr) || + derr.find("expansion limit") == std::string::npos) { + printf(" *** dsl macro recursion: '%s' ***\n", + derr.c_str()); + ok = false; + } + + /* Duplicate definitions and reserved names are + * rejected at definition time. */ + LayoutLibrary libDup; + if (libDup.loadSource( + "(defmacro a () (shell 1 1))" + "(defmacro a () (shell 2 2))", + &derr) || + derr.find("duplicate macro") == std::string::npos) { + printf(" *** dsl macro duplicate: '%s' ***\n", + derr.c_str()); + ok = false; + } + if (libDup.defineMacro("room", {}, + { SExpr{} }, &derr) || + derr.find("collides") == std::string::npos) { + printf(" *** dsl macro reserved: '%s' ***\n", + derr.c_str()); + ok = false; + } + + /* Unused macros in the library change nothing: + * the built-in default evaluates identically. */ + { + BuildingPlan pWith, pWithout; + std::mt19937 rngW(11), rngWo(11); + LayoutEval evWith(lb, &rngW, &libM); + LayoutEval evWithout(lb, &rngWo, nullptr); + if (!evWith.evaluate("village-house", pWith, + &derr) || + !evWithout.evaluate("village-house", + pWithout, &derr) || + !plansEqual(pWith, pWithout)) { + printf(" *** dsl macro unused: err " + "'%s' ***\n", + derr.c_str()); + ok = false; + } + } + } + /* --- Phase 4.6: rooms-first (:empty) layouts --- */ /* L footprint from two rooms (no cut): 12x8 m -> 6x4 cells; @@ -6168,6 +6304,442 @@ static bool runTownPopulationProbe() return ok; } +static PoiDatabase::Record makePoi(const char *id, const char *kind, + PoiDatabase::Domain domain, + const char *address = "") +{ + PoiDatabase::Record rec; + rec.id = id; + rec.kind = kind; + rec.domain = domain; + rec.address = address; + return rec; +} + +static bool runPoiDatabaseProbe() +{ + printf("poi database probe\n"); + bool ok = true; + + /* Build order must not matter: finalize sorts by id. */ + PoiDatabase::Set set; + PoiDatabase::Record work = makePoi("work:shop-1", "work", + PoiDatabase::Domain::Exterior, + "shop-1"); + work.tags = { "shop", "salesman" }; + work.statEffects = { { "money", 10.0 }, { "stress", 5.0 } }; + work.openHour = 9.0f; + work.closeHour = 18.0f; + PoiDatabase::Record home = makePoi("home:house-1", "home", + PoiDatabase::Domain::Interior, + "house-1"); + PoiDatabase::Record curb = makePoi("curb:shop-1", "curb", + PoiDatabase::Domain::Exterior, + "shop-1"); + PoiDatabase::Record night = makePoi("leisure:bar-1", "leisure", + PoiDatabase::Domain::Exterior, + "bar-1"); + night.tags = { "bar" }; + night.openHour = 22.0f; + night.closeHour = 6.0f; + PoiDatabase::Record counter = makePoi("poi:grid-1:counter", + "interaction", + PoiDatabase::Domain::Interior, + "shop-1"); + counter.activation.kind = PoiDatabase::ActivationKind::Manual; + counter.activation.behaviorTree = "work_counter"; + + std::string err; + if (!set.add(work, &err) || !set.add(home, &err) || + !set.add(curb, &err) || !set.add(night, &err) || + !set.add(counter, &err)) { + printf(" *** add: '%s' ***\n", err.c_str()); + ok = false; + } + set.finalize(); + if (set.pois.front().id != "curb:shop-1" || + set.pois.back().id != "work:shop-1") { + printf(" *** finalize did not sort by id ***\n"); + ok = false; + } + if (set.findById("work:shop-1") == nullptr || + set.findById("nope") != nullptr) { + printf(" *** findById mismatch ***\n"); + ok = false; + } + if (set.add(work, &err) || + err.find("duplicate") == std::string::npos) { + printf(" *** duplicate id accepted: '%s' ***\n", + err.c_str()); + ok = false; + } + PoiDatabase::Record empty = makePoi("", "work", + PoiDatabase::Domain::Exterior); + if (set.add(empty, &err)) { + printf(" *** empty id accepted ***\n"); + ok = false; + } + + /* Domain / kind / address filters. */ + PoiDatabase::Query q; + q.domain = (int)PoiDatabase::Domain::Exterior; + if (set.query(q).size() != 3) { + printf(" *** exterior count: %zu, want 3 ***\n", + set.query(q).size()); + ok = false; + } + q.domain = (int)PoiDatabase::Domain::Interior; + if (set.query(q).size() != 2) { + printf(" *** interior count: %zu, want 2 ***\n", + set.query(q).size()); + ok = false; + } + q = PoiDatabase::Query(); + q.kind = "work"; + auto works = set.query(q); + if (works.size() != 1 || + set.pois[works[0]].id != "work:shop-1") { + printf(" *** kind filter mismatch ***\n"); + ok = false; + } + q.address = "shop-1"; + q.kind.clear(); + if (set.query(q).size() != 3) { + printf(" *** address filter mismatch ***\n"); + ok = false; + } + + /* tagsAll: shop+salesman selects the workplace only. */ + q = PoiDatabase::Query(); + q.tagsAll = { "shop", "salesman" }; + if (set.query(q).size() != 1 || + set.pois[set.query(q)[0]].id != "work:shop-1") { + printf(" *** tagsAll mismatch ***\n"); + ok = false; + } + q.tagsAll = { "shop", "office" }; + if (!set.query(q).empty()) { + printf(" *** tagsAll must not match a missing tag ***\n"); + ok = false; + } + q.tagsAll = { "no-such-tag" }; + if (!set.query(q).empty()) { + printf(" *** unknown tag must yield no results ***\n"); + ok = false; + } + + /* Opening hours, including the overnight wrap. */ + q = PoiDatabase::Query(); + q.kind = "work"; + q.openAtHour = true; + q.hour = 12.0f; + if (set.query(q).size() != 1) { + printf(" *** shop must be open at noon ***\n"); + ok = false; + } + q.hour = 20.0f; + if (!set.query(q).empty()) { + printf(" *** shop must be closed at 20:00 ***\n"); + ok = false; + } + q.kind = "leisure"; + q.hour = 23.0f; + if (set.query(q).size() != 1) { + printf(" *** bar must be open at 23:00 ***\n"); + ok = false; + } + q.hour = 5.0f; + if (set.query(q).size() != 1) { + printf(" *** bar must be open at 05:00 (wrap) ***\n"); + ok = false; + } + q.hour = 12.0f; + if (!set.query(q).empty()) { + printf(" *** bar must be closed at noon ***\n"); + ok = false; + } + q.kind = "home"; + q.hour = 3.0f; + if (set.query(q).size() != 1) { + printf(" *** always-open POI must pass any hour ***\n"); + ok = false; + } + + /* Stat effects. */ + if (!PoiDatabase::hasEffect(work, "money") || + PoiDatabase::hasEffect(work, "health")) { + printf(" *** hasEffect mismatch ***\n"); + ok = false; + } + + /* LocationSet projection: only location kinds, with names, + * positions, addresses and headings preserved. */ + PoiDatabase::Record park = makePoi("parking:shop-1-s0", "parking", + PoiDatabase::Domain::Exterior, + "shop-1"); + park.position = Ogre::Vector3(4.0f, 0.0f, 7.0f); + park.hasHeading = true; + park.headingYaw = 1.25f; + set.add(park, &err); + set.finalize(); + TownLocations::LocationSet locs = + PoiDatabase::projectLocations(set); + /* home, work, leisure, curb, parking = 5; interaction and any + * future entrance records are excluded. */ + if (locs.locations.size() != 5) { + printf(" *** projection size: %zu, want 5 ***\n", + locs.locations.size()); + ok = false; + } + const TownLocations::NpcLocation *pw = + locs.findByName("work:shop-1"); + if (!pw || pw->kind != TownLocations::LocationKind::Work || + pw->address != "shop-1") { + printf(" *** projection work mapping mismatch ***\n"); + ok = false; + } + const TownLocations::NpcLocation *pp = + locs.findByName("parking:shop-1-s0"); + if (!pp || pp->kind != TownLocations::LocationKind::Parking || + !pp->hasHeading || pp->headingYaw != 1.25f || + pp->position.x != 4.0f || pp->position.z != 7.0f) { + printf(" *** projection parking fields mismatch ***\n"); + ok = false; + } + if (locs.findByName("poi:grid-1:counter") != nullptr) { + printf(" *** interaction POI leaked into the " + "LocationSet ***\n"); + ok = false; + } + + /* feedLocationSet + projection must be equivalent to the + * original LocationSet BY NAME (order is id-sorted, all + * consumers bind by name). */ + { + TownLocations::LocationSet src; + auto add = [&](const char *name, + TownLocations::LocationKind kind, + const char *address, int building, + Ogre::Vector3 pos) { + TownLocations::NpcLocation loc; + loc.id = (int)src.locations.size(); + loc.kind = kind; + loc.name = name; + loc.address = address; + loc.building = building; + loc.position = pos; + src.locations.push_back(loc); + }; + /* Every generated building has exactly one Home at its + * doorstep (the entry-generation input). */ + add("home:h1", TownLocations::LocationKind::Home, "h1", 0, + Ogre::Vector3(1.0f, 0.0f, 2.0f)); + add("home:s1", TownLocations::LocationKind::Home, "s1", 1, + Ogre::Vector3(2.5f, 0.0f, 3.5f)); + add("home:b1", TownLocations::LocationKind::Home, "b1", 2, + Ogre::Vector3(5.5f, 0.0f, 6.5f)); + add("home:s2", TownLocations::LocationKind::Home, "s2", 3, + Ogre::Vector3(11.0f, 0.0f, 12.0f)); + add("work:s1", TownLocations::LocationKind::Work, "s1", 1, + Ogre::Vector3(3.0f, 0.0f, 4.0f)); + add("leisure:b1", TownLocations::LocationKind::Leisure, + "b1", 2, Ogre::Vector3(5.0f, 0.0f, 6.0f)); + add("interest:s2", TownLocations::LocationKind::Interest, + "s2", 3, Ogre::Vector3::ZERO); + add("curb:s1", TownLocations::LocationKind::Curb, "s1", 1, + Ogre::Vector3(7.0f, 0.0f, 8.0f)); + add("parking:s1", TownLocations::LocationKind::Parking, + "s1", 1, Ogre::Vector3(9.0f, 0.0f, 10.0f)); + src.locations.back().hasHeading = true; + src.locations.back().headingYaw = 2.5f; + /* 6.8.2: tagged locations keep their tags and entrance + * role through the round trip. */ + for (auto &loc : src.locations) { + if (loc.name == "work:s1") { + loc.tags = { "shop", "salesman" }; + loc.entranceRole = "front"; + } else if (loc.name == "leisure:b1") { + loc.tags = { "bar" }; + } + } + + PoiDatabase::Set fed = PoiDatabase::feedLocationSet(src); + TownLocations::LocationSet round = + PoiDatabase::projectLocations(fed); + if (round.locations.size() != src.locations.size()) { + printf(" *** feed round trip size: %zu != %zu " + "***\n", + round.locations.size(), src.locations.size()); + ok = false; + } + for (const auto &orig : src.locations) { + const TownLocations::NpcLocation *back = + round.findByName(orig.name); + /* feed stamps {kindName} on untagged locations + * (the legacy tag); tagged sources keep theirs + * exactly. */ + std::vector expectTags = + orig.tags.empty() ? + std::vector{ + PoiDatabase::kindName( + orig.kind) } : + orig.tags; + if (!back || back->kind != orig.kind || + back->address != orig.address || + back->building != orig.building || + back->position != orig.position || + back->hasHeading != orig.hasHeading || + back->headingYaw != orig.headingYaw || + back->tags != expectTags || + back->entranceRole != orig.entranceRole) { + printf(" *** feed round trip mismatch at " + "'%s' ***\n", + orig.name.c_str()); + ok = false; + } + } + /* Domains follow isIndoorKind. */ + const PoiDatabase::Record *homeRec = + fed.findById("home:h1"); + const PoiDatabase::Record *curbRec = + fed.findById("curb:s1"); + if (!homeRec || + homeRec->domain != PoiDatabase::Domain::Interior || + !curbRec || + curbRec->domain != PoiDatabase::Domain::Exterior) { + printf(" *** feed domain mapping mismatch ***\n"); + ok = false; + } + + /* ----- 6.8.12 building entry contract ----- + * One exterior entry POI per enterable building, at its + * doorstep; interior destinations resolve their + * approach through it. */ + int appended = PoiDatabase::appendEntries(fed, src); + /* Buildings h1(0), s1(1), b1(2), s2(3). */ + if (appended != 4) { + printf(" *** appendEntries: %d, want 4 ***\n", + appended); + ok = false; + } + /* Idempotent: a re-run appends nothing. */ + if (PoiDatabase::appendEntries(fed, src) != 0) { + printf(" *** appendEntries is not idempotent " + "***\n"); + ok = false; + } + fed.finalize(); + for (const char *addr : { "h1", "s1", "b1", "s2" }) { + std::string id = PoiDatabase::entryId(addr); + const PoiDatabase::Record *entry = + fed.findById(id); + if (!entry || entry->kind != "entrance" || + entry->domain != + PoiDatabase::Domain::Exterior || + entry->entranceRole != "front" || + entry->tags.size() != 1 || + entry->tags[0] != "entrance" || + entry->address != addr) { + printf(" *** entry record '%s' has " + "wrong fields ***\n", + id.c_str()); + ok = false; + } + } + /* The entry sits at the building's doorstep — the same + * anchor the Home location uses. */ + const PoiDatabase::Record *entryH1 = + fed.findById("entrance:h1"); + if (!entryH1 || entryH1->building != 0 || + entryH1->position != + Ogre::Vector3(1.0f, 0.0f, 2.0f)) { + printf(" *** entry:h1 not at the doorstep ***\n"); + ok = false; + } + /* Lookup by index and by address. */ + if (PoiDatabase::entryOfBuilding(fed, 0, "") != entryH1 || + PoiDatabase::entryOfBuilding(fed, -1, "s1") == + nullptr) { + printf(" *** entryOfBuilding mismatch ***\n"); + ok = false; + } + /* Approach resolution: interior through the entry, + * exterior at itself. work:s1 is building 1, so its + * approach is entrance:s1 — not h1's doorstep. */ + const PoiDatabase::Record *workS1 = fed.findById("work:s1"); + const PoiDatabase::Record *entryS1 = + fed.findById("entrance:s1"); + if (!workS1 || !entryS1 || + PoiDatabase::approachPosition(fed, *workS1) != + entryS1->position) { + printf(" *** interior approach must use the " + "building entry ***\n"); + ok = false; + } + if (PoiDatabase::approachPosition(fed, *curbRec) != + curbRec->position) { + printf(" *** exterior approach must be the POI " + "itself ***\n"); + ok = false; + } + + /* The projection stamps the domain and the entry anchor: + * interior locations carry hasEntry/entryPosition, + * exterior ones do not, and entrance records stay out + * of the LocationSet. */ + TownLocations::LocationSet stamped = + PoiDatabase::projectLocations(fed); + if (stamped.locations.size() != src.locations.size()) { + printf(" *** entries leaked into the " + "LocationSet ***\n"); + ok = false; + } + const TownLocations::NpcLocation *sh = + stamped.findByName("home:h1"); + const TownLocations::NpcLocation *sw = + stamped.findByName("work:s1"); + const TownLocations::NpcLocation *sc = + stamped.findByName("curb:s1"); + if (!sh || !sh->indoor || !sh->hasEntry || + sh->entryPosition != entryH1->position || + !sw || !sw->indoor || !sw->hasEntry || + sw->entryPosition != entryS1->position || + !sc || sc->indoor || sc->hasEntry) { + printf(" *** projection entry/domain stamps " + "mismatch ***\n"); + ok = false; + } + /* approachPosition() of a location agrees with the + * stamp (the motion layer's endpoint helper). */ + if (TownLocations::approachPosition(*sh) != + entryH1->position || + TownLocations::approachPosition(*sc) != sc->position) { + printf(" *** approachPosition helper mismatch " + "***\n"); + ok = false; + } + + /* A standalone interior POI with no building entry + * falls back to its own position (checked after the + * projection asserts, the orphan projects too). */ + PoiDatabase::Record orphan = makePoi("work:orphan", "work", + PoiDatabase::Domain:: + Interior, + "orphan"); + orphan.position = Ogre::Vector3(9.0f, 0.0f, 9.0f); + fed.add(orphan, &err); + fed.finalize(); + if (PoiDatabase::approachPosition(fed, orphan) != + orphan.position) { + printf(" *** entryless interior must fall back " + "to its own position ***\n"); + ok = false; + } + } + + return ok; +} + static bool runTownLocationsProbe() { printf("town locations probe\n"); @@ -6214,7 +6786,8 @@ static bool runTownLocationsProbe() const TownLocations::NpcLocation &y) { return x.id == y.id && x.kind == y.kind && x.name == y.name && x.address == y.address && - x.position == y.position && x.building == y.building; + x.position == y.position && x.building == y.building && + x.tags == y.tags && x.entranceRole == y.entranceRole; }; /* Determinism: identical seed -> identical set. */ @@ -6234,7 +6807,10 @@ static bool runTownLocationsProbe() } /* Counts: one Home per building, one Curb per pickup POI, one - * Parking per parking POI. */ + * Parking per parking POI. CityCenter's stock district default + * (city-tower, tags tower/center/commercial) is commercial, so + * every building also yields the 6.8.2 Work+Leisure pair and + * joins no Interest roll. */ size_t pickups = 0, parkings = 0; for (const TownGen::TownPoi &poi : town.pois) if (poi.kind == TownGen::TownPoi::Parking) @@ -6247,6 +6823,12 @@ static bool runTownLocationsProbe() a.indicesOfKind(TownLocations::LocationKind::Curb); std::vector parks = a.indicesOfKind(TownLocations::LocationKind::Parking); + std::vector works = + a.indicesOfKind(TownLocations::LocationKind::Work); + std::vector leisures = + a.indicesOfKind(TownLocations::LocationKind::Leisure); + std::vector interests = + a.indicesOfKind(TownLocations::LocationKind::Interest); if (homes.size() != town.buildings.size()) { printf(" *** %zu homes for %zu buildings ***\n", homes.size(), town.buildings.size()); @@ -6258,21 +6840,90 @@ static bool runTownLocationsProbe() parks.size(), parkings); ok = false; } - - /* Interest ratio: 0 -> none, 1 -> one per building. */ - if (!TownLocations::buildLocations(town, seed, 0.0f) - .indicesOfKind(TownLocations::LocationKind::Interest) - .empty()) { - printf(" *** interest ratio 0 still yields interest " - "locations ***\n"); + if (works.size() != town.buildings.size() || + leisures.size() != town.buildings.size() || + !interests.empty()) { + printf(" *** commercial zoning: %zu work, %zu leisure, " + "%zu interest for %zu city towers ***\n", + works.size(), leisures.size(), interests.size(), + town.buildings.size()); ok = false; } - if (TownLocations::buildLocations(town, seed, 1.0f) - .indicesOfKind(TownLocations::LocationKind::Interest) - .size() != town.buildings.size()) { - printf(" *** interest ratio 1 does not cover every " - "building ***\n"); - ok = false; + /* Work/Leisure pairs: stable names, work tags = building tags + * + job tag, leisure tags = building tags, front entrance + * role. */ + for (size_t bi = 0; bi < town.buildings.size(); ++bi) { + const TownGen::TownBuilding &b = town.buildings[bi]; + const TownLocations::NpcLocation *w = + a.findByName("work:" + b.address); + const TownLocations::NpcLocation *l = + a.findByName("leisure:" + b.address); + bool workOk = w && w->building == (int)bi && + w->entranceRole == "front" && + w->tags.size() == b.tags.size() + 1; + bool leisureOk = l && l->building == (int)bi && + l->entranceRole == "front" && + l->tags == b.tags; + if (workOk) { + for (size_t t = 0; t < b.tags.size(); ++t) + if ((*w).tags[t] != b.tags[t]) + workOk = false; + if ((*w).tags.back() != + TownLocations::jobTagForBuilding( + b.tags)) + workOk = false; + } + if (!workOk || !leisureOk) { + printf(" *** building %zu work/leisure pair " + "mismatch ***\n", bi); + ok = false; + break; + } + } + + /* Interest ratio on a RESIDENTIAL town (village houses carry no + * commercial tag, so the legacy Interest roll applies): 0 -> + * none, 1 -> one per building. */ + { + cc.district = TownGen::District::Village; + TownGen::TownResult vtown = + TownGen::generateTown(graph, cc, flat); + if (vtown.buildings.size() != 12) { + printf(" *** setup: %zu village buildings, " + "want 12 ***\n", vtown.buildings.size()); + ok = false; + } else { + if (!TownLocations::buildLocations(vtown, seed, 0.0f) + .indicesOfKind( + TownLocations::LocationKind:: + Interest) + .empty()) { + printf(" *** interest ratio 0 still " + "yields interest locations ***\n"); + ok = false; + } + if (TownLocations::buildLocations(vtown, seed, 1.0f) + .indicesOfKind( + TownLocations::LocationKind:: + Interest) + .size() != vtown.buildings.size()) { + printf(" *** interest ratio 1 does not " + "cover every building ***\n"); + ok = false; + } + /* Residential buildings stay unzoned: no + * Work/Leisure pairs at any ratio. */ + if (!TownLocations::buildLocations(vtown, seed) + .indicesOfKind( + TownLocations::LocationKind:: + Work) + .empty()) { + printf(" *** village houses must not " + "generate work locations ***\n"); + ok = false; + } + } + cc.district = TownGen::District::CityCenter; } /* Lookup helpers: ids are the vector index, homeOfBuilding and @@ -6358,10 +7009,276 @@ static bool runTownLocationsProbe() } } - printf(" %s (%zu locations: %zu homes, %zu curbs, %zu parkings, " - "%zu interest)\n", ok ? "passed" : "FAILURES above", - a.locations.size(), homes.size(), curbs.size(), parks.size(), - a.indicesOfKind(TownLocations::LocationKind::Interest).size()); + printf(" %s (%zu locations: %zu homes, %zu work, %zu leisure, " + "%zu curbs, %zu parkings, %zu interest)\n", + ok ? "passed" : "FAILURES above", a.locations.size(), + homes.size(), works.size(), leisures.size(), curbs.size(), + parks.size(), interests.size()); + return ok; +} + +/** + * Location tag model + index probe (phase 6.8.2): the dual-audience + * Work/Leisure generation over a hand-built TownResult (no RNG — + * the buildings carry their layout tags directly), the tag -> + * indices / building -> indices index, the binding changes and the + * tagless-town compatibility. + */ +static bool runTownTagIndexProbe() +{ + printf("town location tag/index probe\n"); + bool ok = true; + + TownGen::TownResult town; + town.buildings.resize(3); + /* A plain house: no tags -> legacy Interest behavior. */ + town.buildings[0].address = "house-1"; + town.buildings[0].doorstep = Ogre::Vector3(1, 0, 3); + /* A shop: commercial tags. */ + town.buildings[1].address = "shop-1"; + town.buildings[1].tags = { "commercial", "shop", "town" }; + town.buildings[1].doorstep = Ogre::Vector3(11, 0, 13); + /* An office: the other job-tag mapping branch. */ + town.buildings[2].address = "office-1"; + town.buildings[2].tags = { "office" }; + town.buildings[2].doorstep = Ogre::Vector3(21, 0, 23); + /* A pickup POI for the shop (the anchor the Work/Leisure + * locations sit at). */ + town.pois.push_back({ "shop-1 pickup", + Ogre::Vector3(10, 0, 9), 1, + TownGen::TownPoi::Pickup }); + + TownLocations::LocationSet set = + TownLocations::buildLocations(town, 42, 1.0f); + + /* Count check: 3 homes + shop work/leisure + office work/leisure + * (the dual-audience rule is uniform over commercial tags) + the + * shop's curb + the house's interest (ratio 1; the zoned + * buildings join no interest roll) = 9. */ + if (set.locations.size() != 9) { + printf(" *** location count %zu, want 9 ***\n", + set.locations.size()); + ok = false; + } + + /* The shop: a Work location for employees (building tags + + * job tag, front role) AND a Leisure location for customers, + * both anchored at the pickup POI, and NO Interest. */ + { + const TownLocations::NpcLocation *w = + set.findByName("work:shop-1"); + const TownLocations::NpcLocation *l = + set.findByName("leisure:shop-1"); + if (!w || !l) { + printf(" *** shop must yield work+leisure " + "***\n"); + ok = false; + } else { + if (w->kind != TownLocations::LocationKind::Work || + w->position != Ogre::Vector3(10, 0, 9) || + w->entranceRole != "front" || + w->tags != + std::vector{ + "commercial", "shop", "town", + "salesman" }) { + printf(" *** work:shop-1 fields " + "mismatch ***\n"); + ok = false; + } + if (l->kind != + TownLocations::LocationKind::Leisure || + l->position != Ogre::Vector3(10, 0, 9) || + l->tags != town.buildings[1].tags) { + printf(" *** leisure:shop-1 fields " + "mismatch ***\n"); + ok = false; + } + } + if (set.findByName("interest:shop-1") != nullptr) { + printf(" *** zoned shop joined the interest " + "roll ***\n"); + ok = false; + } + } + + /* The office: same dual-audience pair, job tag clerk. */ + { + const TownLocations::NpcLocation *w = + set.findByName("work:office-1"); + const TownLocations::NpcLocation *l = + set.findByName("leisure:office-1"); + if (!w || !l || + std::string(w->tags.back()) != + TownLocations::jobTagForBuilding( + { "office" }) || + std::string(w->tags.back()) != "clerk") { + printf(" *** office work tag mismatch ***\n"); + ok = false; + } + } + + /* The house: legacy Interest roll honored (ratio 1), no + * Work/Leisure pair. */ + if (set.findByName("interest:house-1") == nullptr || + set.findByName("work:house-1") != nullptr || + set.findByName("leisure:house-1") != nullptr) { + printf(" *** tagless house must keep the interest " + "fallback ***\n"); + ok = false; + } + + /* Tag index: deterministic, indexed (same result by value and + * by a second query after a reindex), covering both the + * commercial pair and the job tag. */ + { + std::vector shopTag = + set.indicesWithTag("shop"); + std::vector expect; + for (size_t i = 0; i < set.locations.size(); ++i) { + const TownLocations::NpcLocation &loc = + set.locations[i]; + if (loc.address == "shop-1" && + (loc.kind == + TownLocations::LocationKind::Work || + loc.kind == + TownLocations::LocationKind::Leisure)) + expect.push_back(loc.id); + } + if (shopTag != expect) { + printf(" *** tag index 'shop': %zu hits, want " + "%zu ***\n", shopTag.size(), expect.size()); + ok = false; + } + std::vector salesman = + set.indicesWithTag("salesman"); + if (salesman.size() != 1 || + set.locations[salesman[0]].name != "work:shop-1") { + printf(" *** tag index 'salesman' mismatch " + "***\n"); + ok = false; + } + if (!set.indicesWithTag("no-such-tag").empty()) { + printf(" *** unknown tag must yield nothing " + "***\n"); + ok = false; + } + /* Building buckets: the shop's home/work/leisure/curb + * live in one bucket, in location order. */ + std::vector shopB = + set.indicesOfBuilding(1); + for (size_t i = 1; i < shopB.size(); ++i) + if (shopB[i - 1] >= shopB[i]) { + printf(" *** building bucket not in " + "location order ***\n"); + ok = false; + break; + } + if (shopB.size() != 4) { + printf(" *** shop building bucket %zu, want 4 " + "(home/work/leisure/curb) ***\n", + shopB.size()); + ok = false; + } + } + + /* Bindings: an NPC of the shop town works AT a Work location; + * leisure locations join the visit pool. */ + { + TownLocations::NpcBindings bind = + TownLocations::computeBindings(set, "npc-a", + "house-1"); + if (bind.work.rfind("work:", 0) != 0) { + printf(" *** work binding picked '%s', want a " + "work: location ***\n", bind.work.c_str()); + ok = false; + } + /* Deterministic: the same key re-picks the same + * binding. */ + TownLocations::NpcBindings again = + TownLocations::computeBindings(set, "npc-a", + "house-1"); + if (again.work != bind.work || + again.interest != bind.interest) { + printf(" *** bindings not stable for the same " + "key ***\n"); + ok = false; + } + } + + /* Tagless-town compatibility: bindings keep the Interest + * fallback (the 4.7.4 behavior). */ + { + TownGen::TownResult legacy; + legacy.buildings.resize(2); + legacy.buildings[0].address = "h1"; + legacy.buildings[0].doorstep = Ogre::Vector3(1, 0, 2); + legacy.buildings[1].address = "h2"; + legacy.buildings[1].doorstep = Ogre::Vector3(5, 0, 6); + TownLocations::LocationSet ls = + TownLocations::buildLocations(legacy, 42, 1.0f); + TownLocations::NpcBindings bind = + TownLocations::computeBindings(ls, "npc-a", "h1"); + if (bind.work.rfind("interest:", 0) != 0) { + printf(" *** tagless town work binding '%s', " + "want interest fallback ***\n", + bind.work.c_str()); + ok = false; + } + } + + /* Determinism: the same town builds bit-identically, tags + * included. */ + { + TownLocations::LocationSet b = + TownLocations::buildLocations(town, 42, 1.0f); + if (b.locations.size() != set.locations.size()) { + printf(" *** determinism: size mismatch ***\n"); + ok = false; + } else { + for (size_t i = 0; i < set.locations.size(); ++i) { + if (set.locations[i].name != + b.locations[i].name || + set.locations[i].tags != + b.locations[i].tags || + set.locations[i].entranceRole != + b.locations[i].entranceRole) { + printf(" *** determinism: " + "location %zu differs " + "***\n", i); + ok = false; + break; + } + } + } + } + + /* Feed/projection round trip preserves tags + entrance role. */ + { + PoiDatabase::Set fed = PoiDatabase::feedLocationSet(set); + TownLocations::LocationSet round = + PoiDatabase::projectLocations(fed); + const TownLocations::NpcLocation *w = + round.findByName("work:shop-1"); + if (!w || w->tags.size() != 4 || + w->tags.back() != "salesman" || + w->entranceRole != "front") { + printf(" *** feed/projection lost the work " + "tags ***\n"); + ok = false; + } + /* The unified query model sees the same tags: one POI + * carries shop+salesman. */ + PoiDatabase::Query q; + q.tagsAll = { "shop", "salesman" }; + if (fed.query(q).size() != 1) { + printf(" *** fed POI db tag query mismatch " + "***\n"); + ok = false; + } + } + + printf(" %s (%zu locations)\n", + ok ? "passed" : "FAILURES above", set.locations.size()); return ok; } @@ -8922,7 +9839,9 @@ int main(int argc, char **argv) ok &= runRoadPathProbe(); ok &= runTownPopulationProbe(); ok &= runTownLocationsProbe(); + ok &= runTownTagIndexProbe(); ok &= runTownParkingProbe(); + ok &= runPoiDatabaseProbe(); ok &= runTownScheduleProbe(); ok &= runLaneTransitionTaper(); ok &= runApronSkirt(); diff --git a/src/features/editScene/tests/schedule_scoring_test.cpp b/src/features/editScene/tests/schedule_scoring_test.cpp new file mode 100644 index 0000000..9e51ad3 --- /dev/null +++ b/src/features/editScene/tests/schedule_scoring_test.cpp @@ -0,0 +1,199 @@ +/** + * @file schedule_scoring_test.cpp + * @brief Headless tests for the phase 6.8.9 planner scoring hook. + * + * Links only ScheduleScoring + its Lua binding (no OGRE, no flecs). + * The default scoring is pure; the Lua override is exercised with + * hardcoded stats snapshots for exact, predictable results. + */ + +#include +#include + +#include "../systems/ScheduleScoring.hpp" +#include "../lua/LuaScheduleScoringApi.hpp" + +extern "C" { +#include +#include +#include +} + +static int testCount = 0; +static int passCount = 0; + +#define TEST(name) \ + do { \ + testCount++; \ + printf(" TEST %d: %s ... ", testCount, name); \ + } while (0) + +#define PASS() \ + do { \ + passCount++; \ + printf("PASS\n"); \ + } while (0) + +#define FAIL(msg) \ + do { \ + printf("FAIL: %s\n", std::string(msg).c_str()); \ + return 1; \ + } while (0) + +static ScheduleScoring::Candidate +makeCandidate(const char *name, int usage, double distance, + ScheduleScoring::StatsSnapshot effects = {}) +{ + ScheduleScoring::Candidate c; + c.name = name; + c.usage = usage; + c.distanceMeters = distance; + c.effects = std::move(effects); + return c; +} + +static int testDefaultScore() +{ + TEST("default: usage + distance + deterministic jitter"); + + ScheduleScoring::StatsSnapshot empty; + auto quiet = makeCandidate("shop-a", 0, 100.0); + auto busy = makeCandidate("shop-a", 5, 100.0); + auto far = makeCandidate("shop-a", 0, 1000.0); + + if (ScheduleScoring::defaultScore(quiet, empty) <= + ScheduleScoring::defaultScore(busy, empty)) + FAIL("more usage must score lower"); + if (ScheduleScoring::defaultScore(quiet, empty) <= + ScheduleScoring::defaultScore(far, empty)) + FAIL("more distance must score lower"); + + /* Deterministic: the same candidate always scores the same. */ + if (ScheduleScoring::defaultScore(quiet, empty) != + ScheduleScoring::defaultScore(quiet, empty)) + FAIL("default scoring must be deterministic"); + + /* The snapshot is deliberately ignored by the default. */ + ScheduleScoring::StatsSnapshot rich = { { "money", 1000.0 } }; + if (ScheduleScoring::defaultScore(quiet, empty) != + ScheduleScoring::defaultScore(quiet, rich)) + FAIL("the default must ignore the stats snapshot"); + + PASS(); + return 0; +} + +static int testCppHook() +{ + TEST("C++ hook override and clearHook"); + + auto c = makeCandidate("shop-b", 0, 0.0); + ScheduleScoring::StatsSnapshot empty; + ScheduleScoring::setHook( + [](const ScheduleScoring::Candidate &, + const ScheduleScoring::StatsSnapshot &) { return 42.0; }); + if (ScheduleScoring::score(c, empty) != 42.0) + FAIL("the installed hook must be used"); + ScheduleScoring::clearHook(); + if (ScheduleScoring::score(c, empty) != + ScheduleScoring::defaultScore(c, empty)) + FAIL("clearHook must restore the default"); + + PASS(); + return 0; +} + +static int testLuaHook() +{ + TEST("Lua hook: stats-driven location choice (hardcoded)"); + + lua_State *L = luaL_newstate(); + luaL_openlibs(L); + lua_newtable(L); + lua_setglobal(L, "ecs"); + editScene::registerLuaScheduleScoringApi(L); + + const char *script = R"( + ecs.schedule.set_score_hook(function(c, s) + local score = 0.0 + if (s.money or 0) < 50 then + score = score + (c.effects.money or 0) * 2 + end + if (s.stress or 0) > 60 then + score = score - (c.effects.stress or 0) * 3 + end + return score + end) + jobA = { name = "job:a", tags = { "job" }, distance = 0, + usage = 0, effects = { money = 10, stress = 5 } } + jobB = { name = "job:b", tags = { "job" }, distance = 0, + usage = 0, effects = { money = 1, stress = -10 } } + poor = { money = 10, stress = 0 } + richStressed = { money = 1000, stress = 90 } + neutral = { money = 1000, stress = 0 } + )"; + if (luaL_dostring(L, script) != LUA_OK) + { + std::string err = lua_tostring(L, -1); + lua_close(L); + FAIL("hook setup failed: " + err); + } + + auto scoreOf = [&](const char *candidate, + const char *snapshot) -> double { + lua_getglobal(L, "ecs"); + lua_getfield(L, -1, "schedule"); + lua_getfield(L, -1, "score"); + lua_getglobal(L, candidate); + lua_getglobal(L, snapshot); + if (lua_pcall(L, 2, 1, 0) != LUA_OK) { + std::string err = lua_tostring(L, -1); + lua_close(L); + FAIL("score call failed: " + err); + } + double value = lua_tonumber(L, -1); + lua_pop(L, 3); + return value; + }; + + /* Poor: money effects dominate (A=20 > B=2). */ + if (scoreOf("jobA", "poor") != 20.0 || scoreOf("jobB", "poor") != 2.0) + FAIL("poor NPC must prefer the high-money location"); + /* Rich and stressed: stress effects dominate (A=-15 < B=30). */ + if (scoreOf("jobA", "richStressed") != -15.0 || + scoreOf("jobB", "richStressed") != 30.0) + FAIL("stressed NPC must prefer the calming location"); + /* Neutral stats: no effects considered. */ + if (scoreOf("jobA", "neutral") != 0.0 || + scoreOf("jobB", "neutral") != 0.0) + FAIL("neutral stats must score zero"); + + /* clear_score_hook restores the pure default. */ + if (luaL_dostring(L, "ecs.schedule.clear_score_hook()") != LUA_OK) { + lua_close(L); + FAIL("clear_score_hook failed"); + } + auto a = makeCandidate("job:a", 0, 0.0); + auto b = makeCandidate("job:b", 0, 0.0); + ScheduleScoring::StatsSnapshot empty; + if (ScheduleScoring::score(a, empty) != + ScheduleScoring::defaultScore(a, empty) || + ScheduleScoring::score(b, empty) != + ScheduleScoring::defaultScore(b, empty)) + FAIL("clear_score_hook must restore the default"); + + lua_close(L); + PASS(); + return 0; +} + +int main() +{ + printf("Schedule scoring tests\n"); + int failures = 0; + failures += testDefaultScore(); + failures += testCppHook(); + failures += testLuaHook(); + printf("%d/%d tests passed\n", passCount, testCount); + return failures ? 1 : 0; +} diff --git a/src/features/editScene/ui/PoiDatabasePanel.cpp b/src/features/editScene/ui/PoiDatabasePanel.cpp new file mode 100644 index 0000000..13cd9b2 --- /dev/null +++ b/src/features/editScene/ui/PoiDatabasePanel.cpp @@ -0,0 +1,168 @@ +#include "PoiDatabasePanel.hpp" + +#include +#include +#include + +#include "../systems/NpcPopulationSystem.hpp" + +namespace { + +const char *domainName(PoiDatabase::Domain domain) +{ + return domain == PoiDatabase::Domain::Interior ? "interior" : + "exterior"; +} + +std::string effectsText(const PoiDatabase::Record &rec) +{ + std::string out; + for (const auto &kv : rec.statEffects) { + if (!out.empty()) + out += ", "; + out += kv.first + "=" + std::to_string((int)kv.second); + } + return out; +} + +std::string activationText(const PoiDatabase::Record &rec) +{ + switch (rec.activation.kind) { + case PoiDatabase::ActivationKind::Manual: + return "E: " + rec.activation.behaviorTree; + case PoiDatabase::ActivationKind::OnApproach: + return "approach: " + rec.activation.behaviorTree; + case PoiDatabase::ActivationKind::OnArrive: + return "arrive: " + rec.activation.behaviorTree; + default: + return "-"; + } +} + +} // namespace + +void PoiDatabasePanel::render(bool *open) +{ + if (!ImGui::Begin("POI Database", open)) { + ImGui::End(); + return; + } + + ImGui::InputText("Filter (id/kind/tag)", m_filter, + sizeof(m_filter)); + const std::string filter = m_filter; + auto matches = [&](const PoiDatabase::Record &rec) { + if (filter.empty()) + return true; + if (rec.id.find(filter) != std::string::npos || + rec.kind.find(filter) != std::string::npos) + return true; + for (const auto &tag : rec.tags) + if (tag.find(filter) != std::string::npos) + return true; + return false; + }; + + /* 6.8.12: interior POIs are listed under their building's + * entry. */ + static bool groupByBuilding = true; + ImGui::Checkbox("Group interior POIs under their entry", + &groupByBuilding); + + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + if (!pop) { + ImGui::Text("No population system."); + ImGui::End(); + return; + } + for (int areaIndex : pop->areaIds()) { + const PoiDatabase::Set *set = pop->areaPoiSet(areaIndex); + if (!set) + continue; + char header[64]; + snprintf(header, sizeof(header), "Area %d (%zu POIs)", + areaIndex, set->pois.size()); + if (!ImGui::TreeNodeEx(header, ImGuiTreeNodeFlags_DefaultOpen)) + continue; + auto renderRecord = [&](const PoiDatabase::Record &rec) { + ImGui::PushID(rec.id.c_str()); + ImGui::Text("%s", rec.id.c_str()); + ImGui::Indent(); + ImGui::TextDisabled( + "%s | %s | %s", domainName(rec.domain), + rec.kind.c_str(), + rec.address.empty() ? "-" : + rec.address.c_str()); + if (!rec.tags.empty()) { + std::string tags; + for (const auto &tag : rec.tags) + tags += tag + " "; + ImGui::TextDisabled("tags: %s", tags.c_str()); + } + if (!rec.statEffects.empty()) + ImGui::TextDisabled("effects: %s", + effectsText(rec).c_str()); + if (rec.activation.kind != + PoiDatabase::ActivationKind::None) + ImGui::TextDisabled("activation: %s", + activationText(rec).c_str()); + ImGui::Unindent(); + ImGui::PopID(); + }; + if (!groupByBuilding) { + for (const auto &rec : set->pois) { + if (!matches(rec)) + continue; + renderRecord(rec); + } + ImGui::TreePop(); + continue; + } + /* Grouped view: each entrance record becomes a tree + * node holding itself plus every interior POI of the + * same building; exterior records stay flat. Interior + * records whose entry is filtered out fall back flat. */ + std::unordered_map entryOfAddress; + for (size_t i = 0; i < set->pois.size(); ++i) + if (set->pois[i].kind == "entrance" && + !set->pois[i].address.empty() && + matches(set->pois[i])) + entryOfAddress[set->pois[i].address] = i; + auto nestsUnderEntry = [&](const PoiDatabase::Record &rec) { + return rec.domain == + PoiDatabase::Domain::Interior && + !rec.address.empty() && + entryOfAddress.count(rec.address) != 0 && + set->pois[entryOfAddress[rec.address]].id != + rec.id; + }; + for (size_t i = 0; i < set->pois.size(); ++i) { + const PoiDatabase::Record &rec = set->pois[i]; + if (nestsUnderEntry(rec)) + continue; + if (rec.kind != "entrance") { + if (matches(rec)) + renderRecord(rec); + continue; + } + if (!ImGui::TreeNodeEx(rec.id.c_str(), + ImGuiTreeNodeFlags_DefaultOpen)) + continue; + renderRecord(rec); + for (const PoiDatabase::Record &inner : + set->pois) { + if (&inner == &rec || + !nestsUnderEntry(inner) || + inner.address != rec.address || + !matches(inner)) + continue; + ImGui::Indent(); + renderRecord(inner); + ImGui::Unindent(); + } + ImGui::TreePop(); + } + ImGui::TreePop(); + } + ImGui::End(); +} diff --git a/src/features/editScene/ui/PoiDatabasePanel.hpp b/src/features/editScene/ui/PoiDatabasePanel.hpp new file mode 100644 index 0000000..758cfea --- /dev/null +++ b/src/features/editScene/ui/PoiDatabasePanel.hpp @@ -0,0 +1,23 @@ +#ifndef EDITSCENE_POIDATABASEPANEL_HPP +#define EDITSCENE_POIDATABASEPANEL_HPP +#pragma once + +#include + +/** + * Editor POI inspector (phase 6.8.11): a Tools -> POI Database + * window over the unified POI database (NpcPopulationSystem's + * per-area PoiDatabase::Set). Lists every POI with its domain, + * kind, address, tags, stat effects and activation spec, filterable + * by id/kind/tag substring, so DSL-authored and component-authored + * POIs look identical. + */ +class PoiDatabasePanel { +public: + void render(bool *open = nullptr); + +private: + char m_filter[128] = { 0 }; +}; + +#endif // EDITSCENE_POIDATABASEPANEL_HPP diff --git a/src/features/editScene/ui/PoiEditor.cpp b/src/features/editScene/ui/PoiEditor.cpp new file mode 100644 index 0000000..424e518 --- /dev/null +++ b/src/features/editScene/ui/PoiEditor.cpp @@ -0,0 +1,151 @@ +#include "PoiEditor.hpp" +#include +#include +#include +#include + +namespace { + +bool textField(const char *label, Ogre::String &value) +{ + char buf[256] = { 0 }; + strncpy(buf, value.c_str(), sizeof(buf) - 1); + if (ImGui::InputText(label, buf, sizeof(buf))) { + value = buf; + return true; + } + return false; +} + +bool tagsField(std::vector &tags) +{ + std::string joined; + for (size_t i = 0; i < tags.size(); ++i) { + if (i) + joined += ", "; + joined += tags[i]; + } + char buf[512] = { 0 }; + strncpy(buf, joined.c_str(), sizeof(buf) - 1); + if (!ImGui::InputText("Tags (comma-sep.)", buf, sizeof(buf))) + return false; + tags.clear(); + std::string s = buf; + size_t pos = 0; + while (pos <= s.size()) { + size_t comma = s.find(',', pos); + std::string tag = s.substr(pos, comma == std::string::npos ? + std::string::npos : + comma - pos); + size_t b = tag.find_first_not_of(" \t"); + size_t e = tag.find_last_not_of(" \t"); + if (b != std::string::npos && e != std::string::npos && + e >= b) + tags.push_back(tag.substr(b, e - b + 1)); + if (comma == std::string::npos) + break; + pos = comma + 1; + } + return true; +} + +} // namespace + +bool PoiEditor::renderComponent(flecs::entity entity, PoiComponent &poi) +{ + (void)entity; + bool modified = false; + + if (textField("POI Id", poi.poiId)) + modified = true; + if (textField("Kind", poi.kind)) + modified = true; + if (tagsField(poi.tags)) + modified = true; + + const char *roles[] = { "", "front", "back", "side" }; + if (ImGui::BeginCombo("Entrance role", + poi.entranceRole.empty() ? + "(none)" : + poi.entranceRole.c_str())) { + for (const char *role : roles) { + bool sel = poi.entranceRole == role; + if (ImGui::Selectable(role[0] ? role : "(none)", + sel)) { + poi.entranceRole = role; + modified = true; + } + } + ImGui::EndCombo(); + } + + if (ImGui::InputFloat("Open hour", &poi.openHour, 0.5f, 1.0f)) + modified = true; + if (ImGui::InputFloat("Close hour", &poi.closeHour, 0.5f, 1.0f)) + modified = true; + ImGui::TextDisabled("openHour < 0 = always open"); + + /* Stat effects: one editable row per entry + add/remove. */ + if (ImGui::TreeNode("Stat effects (per in-game hour)")) { + std::string eraseKey; + for (auto &kv : poi.statEffects) { + ImGui::PushID(kv.first.c_str()); + ImGui::Text("%s", kv.first.c_str()); + ImGui::SameLine(); + if (ImGui::InputDouble("per hour", &kv.second, 0.5, + 1.0)) + modified = true; + if (ImGui::SmallButton("remove")) + eraseKey = kv.first; + ImGui::PopID(); + } + if (!eraseKey.empty()) { + poi.statEffects.erase(eraseKey); + modified = true; + } + static char newStat[64] = { 0 }; + ImGui::InputText("new stat", newStat, sizeof(newStat)); + if (ImGui::SmallButton("add effect") && newStat[0]) { + poi.statEffects[newStat] = 0.0; + newStat[0] = '\0'; + modified = true; + } + ImGui::TreePop(); + } + + const char *kinds[] = { "None", "Manual (E)", "On approach", + "On arrive" }; + if (ImGui::BeginCombo("Activation", kinds[poi.activationKind])) { + for (int k = 0; k < 4; ++k) { + bool sel = poi.activationKind == k; + if (ImGui::Selectable(kinds[k], sel)) { + poi.activationKind = k; + modified = true; + } + } + ImGui::EndCombo(); + } + if (poi.activationKind != 0) { + if (textField("Behavior tree (action)", poi.behaviorTree)) + modified = true; + if (textField("Lua script (event)", poi.luaScript)) + modified = true; + if (ImGui::InputFloat("Radius", &poi.radius, 0.1f, 0.5f)) + modified = true; + if (ImGui::InputFloat("Cooldown (s)", &poi.cooldownSeconds, + 0.5f, 5.0f)) + modified = true; + if (ImGui::Checkbox("Fire once", &poi.once)) + modified = true; + ImGui::TextDisabled( + "BT runs through ActuatorSystem; a Lua script " + "fires the 'poi_activated' event instead."); + } + + ImGui::TextWrapped( + "Unified POI (phase 6.8.11): planner data (id, kind, " + "tags, hours, stat effects) + activation (behavior tree " + "or Lua event). The entity transform is the POI " + "position; poiId must be unique and stable."); + return modified; +} diff --git a/src/features/editScene/ui/PoiEditor.hpp b/src/features/editScene/ui/PoiEditor.hpp new file mode 100644 index 0000000..8ce44aa --- /dev/null +++ b/src/features/editScene/ui/PoiEditor.hpp @@ -0,0 +1,17 @@ +#ifndef EDITSCENE_POIEDITOR_HPP +#define EDITSCENE_POIEDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/Poi.hpp" + +class PoiEditor : public ComponentEditor { +public: + const char *getName() const override { return "Poi"; } + +protected: + bool renderComponent(flecs::entity entity, + PoiComponent &component) override; +}; + +#endif // EDITSCENE_POIEDITOR_HPP