phase 6.8 checkpoint
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -241,7 +241,8 @@ static std::string selectLayout(const TownParams ¶ms, 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 ¶ms, District district,
|
||||
cands = layouts->matching(pref);
|
||||
if (cands.empty()) {
|
||||
*usedName = defLayout;
|
||||
if (usedTags) {
|
||||
/* A user library may override the
|
||||
* district default's name — its entry
|
||||
* tags win over the built-in's. */
|
||||
const LayoutLib::LayoutLibrary::Entry
|
||||
*de = layouts->findEntry(
|
||||
defLayout);
|
||||
*usedTags =
|
||||
de ? de->tags :
|
||||
LayoutLib::LayoutLibrary::
|
||||
builtinTags(defLayout);
|
||||
}
|
||||
return defLayout;
|
||||
}
|
||||
if (cands.size() == 1 && cands[0].first == defLayout) {
|
||||
*usedName = defLayout;
|
||||
if (usedTags)
|
||||
*usedTags = cands[0].second->tags;
|
||||
return defLayout;
|
||||
}
|
||||
double total = 0.0;
|
||||
@@ -281,9 +296,14 @@ static std::string selectLayout(const TownParams ¶ms, District district,
|
||||
}
|
||||
}
|
||||
*usedName = picked;
|
||||
if (usedTags)
|
||||
*usedTags =
|
||||
layouts->findEntry(picked)->tags;
|
||||
return picked;
|
||||
}
|
||||
*usedName = defLayout;
|
||||
if (usedTags)
|
||||
*usedTags = LayoutLib::LayoutLibrary::builtinTags(defLayout);
|
||||
return defLayout;
|
||||
}
|
||||
|
||||
@@ -681,6 +701,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
|
||||
* selection picks among tag-matching library
|
||||
* entries. */
|
||||
std::string usedLayoutName;
|
||||
std::vector<std::string> usedLayoutTags;
|
||||
std::string layoutSrc;
|
||||
if (!layout.empty()) {
|
||||
layoutSrc = layout;
|
||||
@@ -691,11 +712,24 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
|
||||
" \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 ¶ms,
|
||||
continue;
|
||||
}
|
||||
usedLayoutName = defLayout;
|
||||
usedLayoutTags =
|
||||
LayoutLib::LayoutLibrary::
|
||||
builtinTags(
|
||||
defLayout);
|
||||
}
|
||||
for (const std::string &w : layoutWarns)
|
||||
result.warnings.push_back(
|
||||
@@ -788,6 +826,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
|
||||
b.roofType = roofType;
|
||||
b.layout = layout;
|
||||
b.layoutName = usedLayoutName;
|
||||
b.tags = usedLayoutTags;
|
||||
b.floorY = floorY;
|
||||
b.minGroundY = minGroundY;
|
||||
b.plan = std::move(plan);
|
||||
|
||||
@@ -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 ® = 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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user