phase 6.8 checkpoint

This commit is contained in:
2026-10-29 01:31:02 +03:00
parent d5b9ae235e
commit 54d9d4a28e
47 changed files with 4850 additions and 91 deletions
+268 -15
View File
@@ -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:<address>`, tags + the job tag from
`jobTagForBuilding`: shop→salesman, office→clerk, else worker) and a
Leisure location (`leisure:<address>`, the building's tags) — same
building, different kind, tags and audience (a shop is a workplace for
employees and a leisure venue for customers) — both anchored at the
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:<address>`, kind `entrance`, domain Exterior, tag
`entrance`, role `front`, position = the building's Home location
(the doorstep — "one Home per building" is the enterability proxy).
Resolution helpers (all pure): `entryId(address)`,
`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/<demo>/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
+35
View File
@@ -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
+20
View File
@@ -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<ZoneSystem>(
m_world, m_physicsSystem->getPhysicsWrapper());
/* Unified POI activation (phase 6.8.11). */
m_poiSystem = std::make_unique<PoiSystem>(m_world, this);
/* F12 game HUD overlay (ImGui, Lua-driven). */
m_gameHudSystem = std::make_unique<GameHudSystem>(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. */
+6
View File
@@ -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<AiDriverSystem> m_aiDriverSystem;
std::unique_ptr<ZoneSystem> m_zoneSystem;
std::unique_ptr<PoiSystem> m_poiSystem;
std::unique_ptr<GameHudSystem> m_gameHudSystem;
/* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */
std::unique_ptr<NpcScheduleDebugOverlay> m_npcScheduleDebugOverlay;
+66
View File
@@ -0,0 +1,66 @@
#ifndef EDITSCENE_POI_HPP
#define EDITSCENE_POI_HPP
#pragma once
#include <Ogre.h>
#include <string>
#include <unordered_map>
#include <vector>
#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<Ogre::String> 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<std::string, double> 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
@@ -0,0 +1,17 @@
#include "Poi.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PoiEditor.hpp"
REGISTER_COMPONENT_GROUP("Poi", "Game", PoiComponent, PoiEditor)
{
registry.registerComponent<PoiComponent>(
"Poi", "Game", std::make_unique<PoiEditor>(),
[](flecs::entity e) {
if (!e.has<PoiComponent>())
e.set<PoiComponent>({});
},
[](flecs::entity e) {
if (e.has<PoiComponent>())
e.remove<PoiComponent>();
});
}
+178 -13
View File
@@ -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:<address>`, building tags + job tag from
`jobTagForBuilding`: shop→salesman, office→clerk, else worker)
and a Leisure location (`leisure:<address>`, 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:<address>`, kind `entrance`, domain
Exterior, tag `entrance`, entrance role `front` (generated buildings
are single-door; multi-door role matching waits for 6.8.8/6.8.13
content), position = the Home location (the doorstep). The POI set
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.
@@ -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<RuntimeMarkerComponent>();
TransformComponent t;
t.node = app->getSceneManager()
->getRootSceneNode()
->createChildSceneNode();
t.position = pos;
t.applyToNode();
e.set<TransformComponent>(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<PoiComponent>(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<TransformComponent>();
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<TransformComponent>())
return true;
const auto &t = player.get<TransformComponent>();
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<TransformComponent>();
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<TransformComponent>();
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<TransformComponent>();
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<TransformComponent>();
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: "
@@ -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
@@ -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 <demo>/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
+242 -2
View File
@@ -31,12 +31,89 @@ void LayoutLibrary::registerLayout(const std::string &name,
e.score = score;
}
bool LayoutLibrary::defineMacro(const std::string &name,
std::vector<std::string> params,
std::vector<SExpr> 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<SExpr> 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<std::string> 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<SExpr> 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::LayoutExample> &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("<top>") : chain;
}
bool LayoutEval::collectMacroDef(
const SExpr &form,
std::map<std::string, LayoutLibrary::MacroDef> &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<std::string, const SExpr *> &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<SExpr> &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<std::string, const SExpr *> 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<SExpr> &in,
std::vector<SExpr> &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<std::string> *warnings)
@@ -2111,6 +2323,8 @@ bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
const std::vector<SExpr> *body = nullptr;
std::vector<SExpr> parsed;
std::vector<SExpr> 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<SExpr> 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<SExpr> *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)) {
@@ -105,6 +105,21 @@ public:
/** Register (or replace) a named layout with metadata. */
void registerLayout(const std::string &name, std::vector<SExpr> body,
std::vector<std::string> 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<std::string> params;
std::vector<SExpr> 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<std::string> params,
std::vector<SExpr> 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<std::string, Entry> m_layouts;
std::map<std::string, MacroDef> 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<SExpr> &in,
std::vector<SExpr> &out);
bool expandForm(const SExpr &form, std::vector<SExpr> &out,
int depth);
SExpr substitute(const SExpr &form,
const std::map<std::string, const SExpr *> &args);
const LayoutLibrary::MacroDef *findMacro(
const std::string &name) const;
bool collectMacroDef(const SExpr &form,
std::map<std::string, LayoutLibrary::MacroDef>
&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<std::map<std::string, Value> > m_scopes;
/* rooms per floor: floor -> (name -> rect) */
std::map<int, std::map<std::string, RoomRect> > m_rooms;
/* Macro definitions local to one inline-source evaluation. */
std::map<std::string, LayoutLibrary::MacroDef> m_localMacros;
std::vector<std::string> m_macroChain;
int m_macroExpansions = 0;
/* roomIds index for a room name of the current evaluation:
* name -> index into BuildingPlan::roomNames. */
std::map<std::string, int> m_roomNameIds;
@@ -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()
@@ -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)
@@ -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",
@@ -0,0 +1,172 @@
#include "LuaScheduleScoringApi.hpp"
#include "../systems/ScheduleScoring.hpp"
#include <iostream>
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
@@ -0,0 +1,31 @@
#ifndef EDITSCENE_LUASCHEDULESCORINGAPI_HPP
#define EDITSCENE_LUASCHEDULESCORINGAPI_HPP
#pragma once
#include <lua.hpp>
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
@@ -0,0 +1,280 @@
#include "PoiDatabase.hpp"
#include <algorithm>
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<int> Set::query(const Query &q) const
{
(void)m_finalized; /* queries also work before finalize, just
* unsorted and unindexed */
std::vector<int> out;
/* Start from the smallest required-tag bucket when an index
* exists; otherwise scan everything. */
std::vector<int> 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<std::string>{ 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
@@ -0,0 +1,184 @@
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#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:<address>", "work:<address>", "poi:<gridUid>:<room>:<n>"). */
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<std::string> 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<std::string, double> 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<std::string> 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<Record> 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<int> query(const Query &q) const;
private:
bool m_finalized = false;
std::unordered_map<std::string, std::vector<int> > 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
+41 -2
View File
@@ -241,7 +241,8 @@ static std::string selectLayout(const TownParams &params, District district,
const LayoutLib::LayoutLibrary *layouts,
std::mt19937 &rng,
std::uniform_real_distribution<float> &unit,
std::string *usedName)
std::string *usedName,
std::vector<std::string> *usedTags = NULL)
{
const char *defLayout = defaultLayoutForDistrict(district);
if (layouts) {
@@ -254,10 +255,24 @@ static std::string selectLayout(const TownParams &params, 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 &params, 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 &params,
* selection picks among tag-matching library
* entries. */
std::string usedLayoutName;
std::vector<std::string> usedLayoutTags;
std::string layoutSrc;
if (!layout.empty()) {
layoutSrc = layout;
@@ -691,11 +712,24 @@ TownResult generateTown(const RoadGraph &graph, const TownParams &params,
" \t\n\r")] == '(' ?
"<inline>" :
layout;
/* 6.8.2: an explicit edge-side LIBRARY
* layout carries its entry's tags;
* inline source has none. */
if (usedLayoutName != "<inline>" &&
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 &params,
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 &params,
b.roofType = roofType;
b.layout = layout;
b.layoutName = usedLayoutName;
b.tags = usedLayoutTags;
b.floorY = floorY;
b.minGroundY = minGroundY;
b.plan = std::move(plan);
@@ -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<std::string> tags;
/**
* Evaluated building plan (phase 4.5 item 2): grid metrics,
* per-cell flags, roofs, lot features, placements. Always
@@ -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<std::string> &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<std::string> &buildingTags)
{
for (const std::string &tag : buildingTags) {
if (tag == "shop")
return "salesman";
if (tag == "office")
return "clerk";
}
/* Any other commercial tag (e.g. "commercial" on a city
* tower). */
return "worker";
}
int LocationSet::homeOfBuilding(int building) const
{
for (const NpcLocation &loc : locations)
@@ -63,6 +97,37 @@ std::vector<int> 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<int> LocationSet::indicesWithTag(const std::string &tag) const
{
reindex();
std::unordered_map<std::string, std::vector<int> >::const_iterator
it = m_byTag.find(tag);
return it != m_byTag.end() ? it->second : std::vector<int>();
}
std::vector<int> LocationSet::indicesOfBuilding(int building) const
{
reindex();
std::unordered_map<int, std::vector<int> >::const_iterator it =
m_byBuilding.find(building);
return it != m_byBuilding.end() ? it->second : std::vector<int>();
}
std::string pickLocationName(const LocationSet &locs,
const std::vector<int> &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<int> work = locs.indicesOfKind(LocationKind::Work);
if (work.empty())
work = interest;
b.work = pickLocationName(locs, work, stableKey, "work", b.home);
{
std::vector<int> 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);
}
@@ -2,6 +2,7 @@
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
#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<std::string> &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<std::string> &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<std::string> 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<NpcLocation> locations;
@@ -78,17 +135,39 @@ struct LocationSet {
int homeOfBuilding(int building) const;
/** Indices of all locations of a kind, in location order. */
std::vector<int> 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<int> indicesWithTag(const std::string &tag) const;
/** Location indices of one building (-1 = standalone), in
* location order. */
std::vector<int> indicesOfBuilding(int building) const;
private:
mutable bool m_indexDirty = true;
mutable std::unordered_map<std::string, std::vector<int> >
m_byTag;
mutable std::unordered_map<int, std::vector<int> > 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);
@@ -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")) {
@@ -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)
@@ -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 &reg = CharacterRegistry::getSingleton();
const Ogre::Vector3 camPos = cameraPosition();
const std::vector<uint64_t> 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();) {
@@ -4,6 +4,7 @@
#include <cstdint>
#include <map>
#include <set>
#include <flecs.h>
#include <OgreVector3.h>
@@ -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<uint64_t, NpcDetail> m_npcs;
std::set<uint64_t> m_relocatedSpawns;
static NpcDetailSystem *ms_instance;
};
@@ -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<Ogre::Vector3> *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();
@@ -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<TerrainComponent>().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<std::string> &authoredIds)
{
m_world.query<NpcLocationComponent, TransformComponent>().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<PoiComponent, TransformComponent>().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<int> NpcPopulationSystem::areaIds() const
{
std::vector<int> 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)
@@ -2,6 +2,7 @@
#include <cstdint>
#include <map>
#include <set>
#include <string>
#include <vector>
@@ -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<int> 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<uint64_t> characterIds;
std::vector<uint64_t> 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<TownSchedule::NpcSchedule> 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<std::string> 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<std::string> &authoredIds);
flecs::world &m_world;
std::map<int, AreaPopulation> m_areas;
@@ -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;
@@ -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<AiDriverComponent>())
out.aiArrived =
car.vehicle.get<AiDriverComponent>().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<RigidBodyComponent>())
return;
RigidBodyComponent &rb =
car.vehicle.get_mut<RigidBodyComponent>();
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());
@@ -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;
@@ -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<std::pair<flecs::entity, PoiComponent>> pending;
m_world.query<PoiComponent>().each(
[&](flecs::entity e, const PoiComponent &poi) {
if (poi.activationKind == 0 ||
poi.behaviorTree.empty())
return;
bool present = false;
if (e.has<ActuatorComponent>()) {
const auto &actuator =
e.get<ActuatorComponent>();
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<ActuatorComponent>())
actuator = poiEntity.get<ActuatorComponent>();
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<ActuatorComponent>(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<Actor> actors;
flecs::entity player = m_editorApp->getPlayerCharacterEntity();
if (player.is_alive() && player.has<TransformComponent>()) {
const auto &t = player.get<TransformComponent>();
float speed = 0.0f;
if (player.has<CharacterComponent>())
speed = player.get<CharacterComponent>()
.linearVelocity.length();
if (t.node)
actors.push_back({ player, t.node->getPosition(),
speed });
}
m_world.query<CharacterComponent, CharacterIdentityComponent,
TransformComponent>()
.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<PoiComponent, TransformComponent>().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);
}
}
}
});
}
@@ -0,0 +1,78 @@
#ifndef EDITSCENE_POISYSTEM_HPP
#define EDITSCENE_POISYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <map>
#include <set>
#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<FireKey, double> m_lastFireTime;
std::set<FireKey> m_firedOnce;
std::map<FireKey, double> m_settleStart;
std::map<Ogre::String, int> m_activationCounts;
};
#endif // EDITSCENE_POISYSTEM_HPP
@@ -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<NpcLocationComponent>()) {
json["npcLocation"] = serializeNpcLocation(entity);
}
if (entity.has<PoiComponent>()) {
json["poi"] = serializePoi(entity);
}
if (entity.has<EventHandlerComponent>()) {
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<PoiComponent>();
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<Ogre::String>{});
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<double>();
}
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<PoiComponent>(poi);
}
void SceneSerializer::deserializeNpcLocation(flecs::entity entity,
const nlohmann::json &json)
{
@@ -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,
@@ -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
@@ -0,0 +1,69 @@
#ifndef EDITSCENE_SCHEDULESCORING_HPP
#define EDITSCENE_SCHEDULESCORING_HPP
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
/**
* 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<std::string, double>;
struct Candidate {
/** Stable location name (POI id / LocationSet name). */
std::string name;
/** Planner tags (kind, job tags, entrance roles). */
std::vector<std::string> 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<double(const Candidate &, const StatsSnapshot &)>;
/** 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
@@ -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;
}
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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 <cstdio>
#include <string>
#include "../systems/ScheduleScoring.hpp"
#include "../lua/LuaScheduleScoringApi.hpp"
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
static int testCount = 0;
static int passCount = 0;
#define TEST(name) \
do { \
testCount++; \
printf(" TEST %d: %s ... ", testCount, name); \
} while (0)
#define PASS() \
do { \
passCount++; \
printf("PASS\n"); \
} while (0)
#define FAIL(msg) \
do { \
printf("FAIL: %s\n", std::string(msg).c_str()); \
return 1; \
} while (0)
static 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;
}
@@ -0,0 +1,168 @@
#include "PoiDatabasePanel.hpp"
#include <imgui.h>
#include <string>
#include <unordered_map>
#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<std::string, size_t> 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();
}
@@ -0,0 +1,23 @@
#ifndef EDITSCENE_POIDATABASEPANEL_HPP
#define EDITSCENE_POIDATABASEPANEL_HPP
#pragma once
#include <string>
/**
* 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
+151
View File
@@ -0,0 +1,151 @@
#include "PoiEditor.hpp"
#include <imgui.h>
#include <cstring>
#include <string>
#include <vector>
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<Ogre::String> &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;
}
+17
View File
@@ -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<PoiComponent> {
public:
const char *getName() const override { return "Poi"; }
protected:
bool renderComponent(flecs::entity entity,
PoiComponent &component) override;
};
#endif // EDITSCENE_POIEDITOR_HPP