Phase 6.8 checkpoint
This commit is contained in:
@@ -308,7 +308,10 @@ cd demos/demo-taxi
|
||||
# hour reproduces positions bit-exactly (pure clock evaluation):
|
||||
./demoTaxi --headless --test-motion
|
||||
# ...or headless check of the NPC high-detail tier (phase 4.7.6): paused
|
||||
# clock, every population NPC spawns within the per-frame budget exactly
|
||||
# clock; the commute scrub deterministically scans candidate hours and
|
||||
# picks the one with the most spawn-eligible NPCs (the weekly whole-hour
|
||||
# arrivals turned a fixed hour into a structural dead zone, 6.8.4b);
|
||||
# then every eligible NPC spawns within the per-frame budget exactly
|
||||
# at its symbolic position, nobody despawns inside the hysteresis band,
|
||||
# all despawn cleanly and the symbolic positions are restored bit-exactly:
|
||||
./demoTaxi --headless --test-detail
|
||||
@@ -339,6 +342,35 @@ cd demos/demo-taxi
|
||||
# checks the live drop-off against the claimed park pose and carjacks
|
||||
# the car to verify fleet/claim release:
|
||||
./demoTaxi --headless --test-parking
|
||||
# ...or headless week-rollover probe (phase 6.8.4b): the runtime runs the
|
||||
# weekly day-of-week tiling, so the pure motion evaluation must reproduce
|
||||
# positions bit-exactly across a week boundary (same hours on day 0 vs
|
||||
# day 7 — the same weekly Monday view) and the weekend template must
|
||||
# visibly differ from the workday (Monday vs Saturday at noon):
|
||||
./demoTaxi --headless --test-week-rollover
|
||||
# ...or headless DSL interior-POI probe (phase 6.8.6): previews a layout
|
||||
# whose rooms declare POIs through the :poi option, asserting the runtime
|
||||
# PoiComponent entities (stable ids merged into the town's POI database),
|
||||
# the ensured E-prompt actuator, the OnApproach behavior fire and the
|
||||
# OnArrive Lua event, plus a re-preview reproducing every poiId:
|
||||
./demoTaxi --headless --test-dsl-poi
|
||||
# ...or headless week-revision probe (phase 6.8.10): an explicit
|
||||
# mid-week revision re-resolves the REST of the current week with the
|
||||
# CURRENT stats snapshot, spliced at the current clock (in-flight
|
||||
# goals finish first), recorded as a revision marker and saved with
|
||||
# the game (save → reload keeps weeks and revisions bit-identically):
|
||||
./demoTaxi --headless --test-week-revision
|
||||
# ...or headless emergency probe (phase 6.8.14): loads the shipped
|
||||
# stats rules module through the app's Lua state (registering the
|
||||
# emergency thresholds), drops the selected NPC's health through
|
||||
# ecs.stats.set, and asserts the emergency begins (the highest-
|
||||
# priority goal), the effective schedule is the 24-entry synthetic
|
||||
# at the hospital (or home rest without a hospital), the NPC is
|
||||
# transported there symbolically, save → reload restores it,
|
||||
# discharge resumes the pure baseline with no leftover state, taxi
|
||||
# goals are refused while it lasts, and endEmergency on a
|
||||
# non-emergency id is a no-op:
|
||||
./demoTaxi --headless --test-emergency
|
||||
# ...or headless animation-travel probe (phase 6.7): on the default
|
||||
# interior scene, a synthetic registry tree probes the animation state
|
||||
# machine travel — startState boot marker, travelTo hop-through (no
|
||||
@@ -763,7 +795,10 @@ name-suffix parsing), and — per building — either the **commercial pair
|
||||
inline sources and hand-built results) carries a commercial tag
|
||||
(`isCommercialBuilding`: shop/commercial/office/leisure) yields BOTH a
|
||||
Work location (`work:<address>`, tags + the job tag from
|
||||
`jobTagForBuilding`: shop→salesman, office→clerk, else worker) and a
|
||||
`jobTagForBuilding`: shop→salesman, office→clerk, else worker —
|
||||
with 6.8.7 the special service tags map FIRST (police→police,
|
||||
ambulance→medic, firefighter→firefighter, hospital→doctor) and
|
||||
join the commercial zoning set) and a
|
||||
Leisure location (`leisure:<address>`, the building's tags) — same
|
||||
building, different kind, tags and audience (a shop is a workplace for
|
||||
employees and a leisure venue for customers) — both anchored at the
|
||||
@@ -772,7 +807,34 @@ zoned buildings join NO Interest roll (that roll was the placeholder
|
||||
for exactly this), while tagless buildings keep the legacy roll
|
||||
("old towns without tags keep the Interest fallback").
|
||||
`NpcLocation` carries `tags` (the building's tags + the Work job tag)
|
||||
and `entranceRole` ("front" for generated locations). The
|
||||
and `entranceRole` ("front" for generated locations; "back" for
|
||||
employee Work locations when the layout declares a back door —
|
||||
6.8.8: employee Work locations anchor at the back doorstep and
|
||||
`appendEntries` emits the matching `entrance:<address>:back` POI,
|
||||
with `entryOfBuildingForRole` / `approachPosition` resolving the
|
||||
employee entry per role; customer (Leisure) and home keep the
|
||||
front). `TownBuilding` records the front/back/side doorsteps in
|
||||
TownGen from the plan's door flags, so a `(door back ...)` layout
|
||||
makes the endpoints differ while single-door buildings stay
|
||||
bit-identical. The candidate score measures against
|
||||
`TownLocations::approachPosition(loc)` (the building entry,
|
||||
6.8.12). **Reference layouts + stat effects (6.8.13):**
|
||||
`town_layouts.txt` ships `ref-home`/`ref-shop`/`ref-park` (one per
|
||||
location kind); rooms declare location-level stat effects with
|
||||
`:effects ((stat delta) ...)` (`BuildingPlan::roomStatEffects`),
|
||||
merged by `TownLocations::buildLocations` onto the Work/Leisure/
|
||||
Home locations by room-type role match (work =
|
||||
shop/store/office/commercial/work/shopfront/storage; leisure =
|
||||
park/cafe/bar/leisure; home = bedroom/living/livingroom/home/
|
||||
residence). On COMPLETED schedule entries
|
||||
`NpcMotionSystem` applies `loc.statEffects × hours` through
|
||||
`systems/StatEffectsApplier::applyLocationEffects`
|
||||
(`CharacterStatStore`, clamped), keyed
|
||||
`(npc, location, startHour)` so each entry applies exactly once
|
||||
per run (a back-scrub never re-applies; the completed set is
|
||||
runtime-only). Home entries are idle rest — no activation and no
|
||||
behavior trees at home; the home location's weaker stress
|
||||
recovery is just data. The
|
||||
`LocationSet` keeps a lazy tag→indices + building→indices index
|
||||
(deterministic, location order; `indicesWithTag()` /
|
||||
`indicesOfBuilding()`; call `reindex()` after direct mutation of
|
||||
@@ -847,7 +909,7 @@ WeekScheduleRules, stats = {}, scoreHook = {}, warnings* )`:
|
||||
only) for the stat-driven choices (6.8.10).
|
||||
- Fallbacks warn once per distinct line per build.
|
||||
|
||||
**Runtime store (6.8.4a — runtime motion/traffic NOT flipped yet):**
|
||||
**Runtime store (6.8.4a) + day-tiling flip (6.8.4b):**
|
||||
`NpcPopulationSystem::generateAreaPopulation` runs the weekly build
|
||||
alongside the daily one (seed `effSeed ^ kWeekScheduleSeedXor
|
||||
0x27d4eb2f`, default rules) and stores it on the area —
|
||||
@@ -855,21 +917,136 @@ alongside the daily one (seed `effSeed ^ kWeekScheduleSeedXor
|
||||
`weekUsageOf(areaIndex)` the build's pick tally. The scoring flows
|
||||
through an adapter onto `systems/ScheduleScoring::score`, so
|
||||
`ecs.schedule.set_score_hook` steers weekly picks too; stats
|
||||
snapshots stay empty until 6.8.10. **Migration:** the
|
||||
snapshots stay empty until 6.8.10. **The runtime consumes the week
|
||||
(6.8.4b):** pure `TownSchedule::splitWeekToDays(week)` cuts one
|
||||
DAILY-shaped `NpcSchedule` per day-of-week (that day's whole-hour
|
||||
[0, 24) tiling), the store keeps the views (`weekDayViews`, built at
|
||||
generation time) and `weekDayScheduleOf(registryId, day)` returns
|
||||
them; `NpcMotionSystem::update` and both traffic call sites (the
|
||||
`rebuildClaims` day-arc replay and `evalSymbolic`) evaluate
|
||||
`weekDayScheduleOf(id, absDay % 7)`, falling back to the legacy
|
||||
daily schedule when the area has no weekly build — every consumer
|
||||
keeps its daily-shaped `entryAt()` machinery, so walking and driving
|
||||
cannot disagree. **Migration:** the
|
||||
`npcWork`/`npcInterest` registry columns rebind to the weekly
|
||||
resolves (the first week's sticky `work:<address>` and the first
|
||||
`leisure:<address>` pick; the old Interest placeholders remain only
|
||||
for tagless areas). Pure probes stay bit-identical; the runtime
|
||||
swap (motion `entryAt`/helpers + traffic legs) is 6.8.4b.
|
||||
for tagless areas). Pure probes stay bit-identical.
|
||||
`NpcScheduleDebugOverlay` (**F7**) gained a Week pane per selected
|
||||
NPC: Mon-Sun day selector (initial = the clock's day-of-week), a
|
||||
"today" line, From/To/Activity/**Query -> Location** rows and the
|
||||
usage tally (top-4).
|
||||
|
||||
**Realism rules (6.8.5, pure defaults are no-ops):**
|
||||
`WeekScheduleRules::openHoursByTag` gives venue hours per tag
|
||||
(first tag in the location's tag order wins; unmatched = always
|
||||
open) and `softCapacity`/`capacityByTag` sink a candidate's score
|
||||
past its resolved pick limit (`TownScheduleScore::Candidate::
|
||||
capacity` + `Weights::capacity`, mirrored through
|
||||
`systems/ScheduleScoring` — one kernel; the Lua hook still works).
|
||||
The generator filters BOTH pools (the sticky-job resolve for the
|
||||
work block the member will hold, and the per-day leisure slot) to
|
||||
venues open for the slot's FULL span and falls back otherwise
|
||||
(leisure → curb → interest → home; work → binding when the town
|
||||
has no Work locations, HOME when Work exists but all are shut —
|
||||
the closing pull). Default towns keep their weekly builds
|
||||
bit-identical; `NpcPopulationSystem::demoWeeklyRules()` opts the
|
||||
demo in (shop 9-18, soft capacity 8). Realism fixture checks live
|
||||
in the week probe (`runTownWeekScheduleProbe`).
|
||||
|
||||
**Stat-driven weekly variety (6.8.10):** `generateWeekSchedules`
|
||||
takes a stats snapshot map with ONLY members that have EXPLICIT
|
||||
stat values (defaults stay inert). Per-NPC isolated draws shape
|
||||
the week: stress > 60 → more leisure, money < 25 → fewer paid
|
||||
outings plus an extra weekend job shift, health < 30 → a
|
||||
deterministic full-home rest day, stress > 85 → a deterministic
|
||||
full-home rest day; the baseline is bit-identical with an empty
|
||||
snapshot. `NpcLocation::statEffects` rides the POI
|
||||
feed/projection into `WeekCandidate::effects` (the hook can prefer
|
||||
money-positive or stress-relieving venues while the default score
|
||||
stays stats-free). The weekly store (`weekSchedules` /
|
||||
`weekDayViews` / `weekUsage` / `weekStats` / `rosters` /
|
||||
`revisions`) rides `saveData["npcWeekSchedules"]`
|
||||
(`NpcPopulationSystem::serializeWeekSchedules` /
|
||||
`deserializeWeekSchedules`); old saves without it regenerate from
|
||||
defaults. `NpcPopulationSystem::reviseWeek(areaIndex, reason,
|
||||
graphVersion)` re-runs the build with the CURRENT stats and
|
||||
splices at the current absolute clock via
|
||||
`TownSchedule::spliceWeekAfter` — past days identical, in-flight
|
||||
entry kept whole, rest of week rewritten, revision markers
|
||||
appended. `EditorApp::loadGame` deactivates terrain before
|
||||
loading a new base scene (otherwise `m_active` stayed true with a
|
||||
stale `m_terrainEntityId`), and revision history is preserved both
|
||||
across `generateAreaPopulation` and the update's
|
||||
clear+regenerate cycle. Coverage: `runWeekVarietyProbe` in
|
||||
road_geometry_overlap_test + `demoTaxi --headless
|
||||
--test-week-revision`.
|
||||
Clock-pure verification gates worth remembering: the whole-hour
|
||||
weekly arrivals make the walk/drive windows 1-2 game-minutes wide,
|
||||
the demo `--test-traffic-schedule`/`--test-parking` sweeps probe
|
||||
~1.7 min apart, and `GameClockSystem::update` RE-ADOPTS the first
|
||||
sun on any divergence — including `timeSpeed` — taking the sun's
|
||||
TIME with it, so a test that sets the clock speed to a value the
|
||||
scene suns do not carry must align the suns' `timeSpeed` first or
|
||||
its `setTime` is silently undone (both traffic batteries do).
|
||||
`demoTaxi --headless --test-week-rollover` pins the flip: positions
|
||||
across the week boundary (day 0 vs day 7 at the same hours) are
|
||||
bit-exact and the weekend template differs from the workday.
|
||||
|
||||
**Emergency rescheduling + hospitalization (6.8.14):** a
|
||||
stat-threshold emergency is the HIGHEST-priority goal, stored as
|
||||
per-NPC override state on `AreaPopulation`
|
||||
(`EmergencyState{reason, location, startAbs, schedule}`) with
|
||||
`beginEmergency`/`endEmergency`/`hasActiveEmergency`/
|
||||
`emergencyScheduleOf` on `NpcPopulationSystem`. ALL schedule
|
||||
consumers read `effectiveDayScheduleOf(id, day)` (= emergency →
|
||||
week day → daily): `NpcMotionSystem::update` and both
|
||||
`NpcTrafficSystem` evaluation sites. The synthetic schedule is a
|
||||
24-entry whole-hour tiling at the emergency location (activity
|
||||
"hospital"), so the clock-pure evaluation transports the NPC
|
||||
there symbolically; the destination is the town's hospital Work
|
||||
location (the `hospital` tag — the `ref-hospital` layout ships
|
||||
one, ward `:effects ((health 5))` healing over time per 6.8.13),
|
||||
falling back to `home:<address>` rest with one logged warning
|
||||
when the town has none. `beginEmergency` cancels the taxi goal
|
||||
and despawns the live car (`NpcTrafficSystem::despawnLiveCar`);
|
||||
`NpcTaxiSystem::assignTaxiGoal` refuses during an emergency.
|
||||
Thresholds are DATA, registered from the stats rules module via
|
||||
`ecs.emergency.set_thresholds{health_below, discharge_at}`
|
||||
(`lua/LuaEmergencyApi.cpp`, registered in EditorApp next to the
|
||||
rosters API); the engine subscribes ONCE to `CharacterStatStore`
|
||||
change events (never polling) and begins/ends emergencies from
|
||||
the health crossings. `ecs.emergency.begin/end/active` allow
|
||||
scripted emergencies. State rides save/load (the `emergencies`
|
||||
section of the week store, synthetic schedule rebuilt from the
|
||||
location) and survives the clear+regenerate cycles. F7 shows a
|
||||
red EMERGENCY banner (activity, location, live health). Known
|
||||
(pre-existing, 4.x-era) robustness gap, found by the battery: a
|
||||
detail entity spawned below a road embankment can never reach a
|
||||
boarding door above it — the taxi battery rotates candidates on
|
||||
stall. Coverage: `demoTaxi --headless --test-emergency`.
|
||||
|
||||
Runtime is NOT wired yet (6.8.4): motion/traffic keep the daily
|
||||
`generateSchedules()` product; the week verifier
|
||||
`TownSchedule::weekEntryAt(week, day, hour)` exists for it.
|
||||
|
||||
**Special-job rosters (6.8.7):** `roadlib/TownRosters` (OGRE-free;
|
||||
the weekly-build adapter is `TownRostersWeeks::weekJobMembers`)
|
||||
derives per-area rosters as an OUTPUT of the weekly build —
|
||||
special tag -> registry-id-ordered `{registryId, stableKey, home,
|
||||
work}` members. The special SERVICE roles (police, medic,
|
||||
firefighter, doctor) map from the building tags in
|
||||
`TownLocations::jobTagForBuilding` (special tags checked before
|
||||
shop/office) and count as commercial zoning, so a `police`-tagged
|
||||
lot is a Work+Leisure workplace and its adults roster. The
|
||||
runtime store: `NpcPopulationSystem::areaRosters(areaIndex)`
|
||||
(nullptr without a weekly build); Lua: `ecs.rosters.area(idx)` /
|
||||
`ecs.rosters.tags()` / `ecs.rosters.is_special(tag)`; the F7
|
||||
population section lists the per-area rosters. No AI behavior —
|
||||
DATA for later game systems. Coverage: `runTownRostersProbe`
|
||||
(road_geometry_overlap_test) + `rosters_lua_test` (CTest
|
||||
`rostersLuaTest`).
|
||||
|
||||
Verification: `NpcScheduleDebugOverlay` (game mode, toggled with **F7**) shows
|
||||
the clock (pause/speed/time-of-day controls), the population NPCs grouped by
|
||||
town area with the schedule entry active at the current time, and the
|
||||
@@ -1046,10 +1223,13 @@ layer 1 (`layer1Mesh`; layer 0 is the bald scalp) and, when the NPC's
|
||||
public), garments from layer 2 (`layer2Mesh`) — stored in the record's
|
||||
`inlineSlotSelections` so buildCharacter resolves real meshes instead of
|
||||
the nude layer-0 fallback. Headless verification:
|
||||
`demoTaxi --headless --test-detail` (paused clock, spawn
|
||||
budget + spawn-at-symbolic accuracy, hysteresis hold, full despawn, exact
|
||||
symbolic restore; the snapshot settles two frames first because the
|
||||
population seeds `rec->position` with the home doorstep and the
|
||||
`demoTaxi --headless --test-detail` (paused clock; the commute scrub
|
||||
scans candidate hours and picks the one with the most
|
||||
spawn-eligible NPCs — the weekly whole-hour arrivals need a
|
||||
window-anchored hour, 6.8.4b; then spawn
|
||||
budget + spawn-at-symbolic accuracy, hysteresis hold, full despawn,
|
||||
exact symbolic restore; the snapshot settles two frames first because
|
||||
the population seeds `rec->position` with the home doorstep and the
|
||||
first pure evaluation overwrites it).
|
||||
|
||||
Character rendering performance (phase 5.5, 2026-10-18): two OGRE
|
||||
@@ -1358,7 +1538,13 @@ endpoints give pure event times, then a global (time, driver-id,
|
||||
leg)-ordered replay claims the destination slot at the WALK departure
|
||||
(the live tier needs it to drive to) and releases a visitor origin at
|
||||
the DRIVE departure (the car physically leaves then). Home spots stay
|
||||
permanently claimed. The same clock always reproduces the same
|
||||
permanently claimed. The claim-time slot tiers (free bays → free
|
||||
overflow → curb chain) and the occupancy-blind timing picks stay
|
||||
within the venue's `parkFallbackRadiusM` (80 m default, 0 =
|
||||
unlimited — 6.8.5): a full frontage falls to the venue's own curb
|
||||
instead of routing a 140-370 m town-wide detour whose live leg
|
||||
wedged `--test-traffic-schedule`. The same clock always reproduces
|
||||
the same
|
||||
assignments bit-exactly, so clock scrubs, pauses and scene reloads need
|
||||
no reconciliation code; the per-leg claimed endpoints are recorded in
|
||||
`TrafficCar::legClaims` and the route geometry uses them while the
|
||||
@@ -2102,7 +2288,8 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
|
||||
overflow parking POI ("<address> parking-ovf", nose along the
|
||||
street) at the driveway mouth — a car parks there only when every
|
||||
designated slot is taken (the pick tiers: free bays -> free
|
||||
overflow -> curb chain), never stacked (one exclusively-claimed
|
||||
overflow -> curb chain, scoped to `parkFallbackRadiusM` of the
|
||||
venue — 6.8.5), never stacked (one exclusively-claimed
|
||||
pose) and never in front of the door (it sits at the road end of
|
||||
the driveway). Narrow lots with neither driveway room nor a
|
||||
side-wall strip are a PLAN PROBLEM / TODO for the later, more
|
||||
@@ -2391,6 +2578,20 @@ authored ids — capture `rec.id` BEFORE
|
||||
authored set) merges components into the area's POI set, so
|
||||
runtime-added POIs appear without a full regeneration.
|
||||
|
||||
**DSL-authored interior POIs (6.8.6):** the layout DSL declares
|
||||
them per room — `(room ... :poi ((name :kind k :tags (...)
|
||||
:effect (stat delta) :activation manual|approach|arrive
|
||||
:behavior action :lua path :at (x z) :level n :hours (open
|
||||
close) :entrance role :radius n :cooldown s :once ...)))`.
|
||||
`LayoutLib::planPois` materializes into `TownGenSystem::
|
||||
createPlanPois` runtime `PoiComponent` children of the building
|
||||
(id `poi.<gridUid>.<roomName>[-fN].<name>`, grid uid
|
||||
`town-<address>`, so rebuilds keep ids); the entity's scene node
|
||||
hangs at the ROOT at the derived world-render position because the
|
||||
proximity scan and the POI-database merge read node positions
|
||||
verbatim. Positions are footprint fractions of the shell (the
|
||||
room-rect space). Probe: `demoTaxi --headless --test-dsl-poi`.
|
||||
|
||||
`PoiSystem` (`systems/PoiSystem.hpp/.cpp`, EditorApp-owned,
|
||||
`getPoiSystem()`, Game+Playing only, updated next to ZoneSystem)
|
||||
is the mechanical side: it ensures an `ActuatorComponent` for
|
||||
@@ -2420,7 +2621,13 @@ called from `NpcPopulationSystem` right after the feed) creates id
|
||||
`entrance:<address>`, kind `entrance`, domain Exterior, tag
|
||||
`entrance`, role `front`, position = the building's Home location
|
||||
(the doorstep — "one Home per building" is the enterability proxy).
|
||||
Resolution helpers (all pure): `entryId(address)`,
|
||||
**6.8.8 (entrance roles):** layouts declaring `(door back ...)` get
|
||||
`TownBuilding::hasBackDoor/backDoorstep` (and side-doorsteps), and
|
||||
employee Work locations anchor at the back doorstep instead of the
|
||||
front pickup POI; `appendEntries` then also emits
|
||||
`entrance:<address>:back`, and `entryOfBuildingForRole`/
|
||||
`approachPosition` resolve the employee (back) entry. Single-door
|
||||
buildings keep front-only behavior bit-identically. Resolution helpers (all pure): `entryId(address)`,
|
||||
`entryOfBuilding(set, building, address)` (index match first, then
|
||||
address) and `approachPosition(set, rec)` — Interior → the entry
|
||||
position (falling back to the POI position when the building has no
|
||||
|
||||
@@ -238,13 +238,16 @@ set(EDITSCENE_SOURCES
|
||||
lua/LuaSaveLoadApi.cpp
|
||||
lua/LuaGlobalStateApi.cpp
|
||||
lua/LuaCharacterStatsApi.cpp
|
||||
lua/LuaEmergencyApi.cpp
|
||||
lua/LuaScheduleScoringApi.cpp
|
||||
lua/LuaRostersApi.cpp
|
||||
lua/LuaDoorApi.cpp
|
||||
lua/LuaHudApi.cpp
|
||||
lua/LuaFurnitureApi.cpp
|
||||
lua/LuaTerrainApi.cpp
|
||||
lua/LuaSceneSwitchApi.cpp
|
||||
systems/TerrainTests.cpp
|
||||
systems/StatEffectsApplier.cpp
|
||||
)
|
||||
|
||||
set(EDITSCENE_HEADERS
|
||||
@@ -867,6 +870,31 @@ add_test(NAME scheduleScoringTest
|
||||
COMMAND schedule_scoring_test
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Phase 6.8.7 roster Lua binding (headless)
|
||||
# ---------------------------------------------------------------------------
|
||||
# The pure derivation (roadlib/TownRosters) + the ecs.rosters binding;
|
||||
# no OGRE, no flecs — the provider pattern is exercised with a fake area.
|
||||
add_executable(rosters_lua_test
|
||||
tests/rosters_lua_test.cpp
|
||||
roadlib/TownRosters.cpp
|
||||
lua/LuaRostersApi.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(rosters_lua_test
|
||||
lua
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
|
||||
target_include_directories(rosters_lua_test PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
|
||||
)
|
||||
|
||||
add_test(NAME rostersLuaTest
|
||||
COMMAND rosters_lua_test
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Phase 6.8.9 character stat container (headless)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -988,6 +1016,10 @@ add_library(RoadGeometryLib STATIC
|
||||
roadlib/TownSchedule.hpp
|
||||
roadlib/TownScheduleScore.cpp
|
||||
roadlib/TownScheduleScore.hpp
|
||||
roadlib/TownRosters.cpp
|
||||
roadlib/TownRosters.hpp
|
||||
roadlib/TownRostersWeeks.cpp
|
||||
roadlib/TownRostersWeeks.hpp
|
||||
roadlib/PoiDatabase.cpp
|
||||
roadlib/PoiDatabase.hpp
|
||||
)
|
||||
@@ -1033,6 +1065,8 @@ target_include_directories(RoadGeometryDemo PRIVATE
|
||||
# "Ax Ay Az Bx By Bz Cx Cy Cz" analyse a single custom configuration.
|
||||
add_executable(road_geometry_overlap_test
|
||||
tests/road_geometry_overlap_test.cpp
|
||||
systems/CharacterStatStore.cpp
|
||||
systems/StatEffectsApplier.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(road_geometry_overlap_test
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
#include "lua/LuaGlobalStateApi.hpp"
|
||||
#include "lua/LuaCharacterStatsApi.hpp"
|
||||
#include "lua/LuaScheduleScoringApi.hpp"
|
||||
#include "lua/LuaRostersApi.hpp"
|
||||
#include "lua/LuaEmergencyApi.hpp"
|
||||
#include "systems/CharacterStatStore.hpp"
|
||||
#include "lua/LuaSceneSwitchApi.hpp"
|
||||
#include "systems/PlayerControllerSystem.hpp"
|
||||
@@ -1027,11 +1029,34 @@ void EditorApp::setup()
|
||||
editScene::registerLuaGlobalStateApi(L);
|
||||
editScene::registerLuaCharacterStatsApi(L);
|
||||
editScene::registerLuaScheduleScoringApi(L);
|
||||
editScene::registerLuaRostersApi(L);
|
||||
/* 6.8.14: the emergency layer (threshold registration
|
||||
* + begin/end/active bindings). */
|
||||
editScene::registerLuaEmergencyApi(L);
|
||||
editScene::registerLuaDoorApi(L);
|
||||
editScene::registerLuaHudApi(L);
|
||||
editScene::registerLuaFurnitureApi(L, &m_world);
|
||||
editScene::setLuaCellGridSystem(m_cellGridSystem.get());
|
||||
editScene::setLuaHudSystem(m_gameHudSystem.get());
|
||||
/* 6.8.7: the Lua roster binding reads the same
|
||||
* per-area store the C++ accessor surfaces. */
|
||||
editScene::setLuaRostersProvider(
|
||||
[](int areaIndex, void *user,
|
||||
TownRosters::Rosters &out) {
|
||||
auto *pop = static_cast<
|
||||
NpcPopulationSystem *>(
|
||||
user);
|
||||
if (!pop)
|
||||
return false;
|
||||
const TownRosters::Rosters
|
||||
*rosters =
|
||||
pop->areaRosters(
|
||||
areaIndex);
|
||||
if (!rosters)
|
||||
return false;
|
||||
out = *rosters;
|
||||
return true;
|
||||
},
|
||||
m_npcPopulationSystem.get()); editScene::setLuaHudSystem(m_gameHudSystem.get());
|
||||
editScene::setSceneSwitchEditorApp(this);
|
||||
editScene::setLuaPlayerCharacterResolver(
|
||||
[this]() { return getPlayerCharacterEntity(); });
|
||||
@@ -1871,6 +1896,12 @@ void EditorApp::saveGame(const std::string &slotPath,
|
||||
saveData["globalState"] = GlobalStateStore::getInstance().serialize();
|
||||
saveData["characterStats"] =
|
||||
CharacterStatStore::getInstance().serialize();
|
||||
/* 6.8.10: the weekly schedule store (weeks, bindings, usage,
|
||||
* the stats snapshot used, revision markers) is scene-
|
||||
* independent state and rides the save game. */
|
||||
if (m_npcPopulationSystem)
|
||||
saveData["npcWeekSchedules"] =
|
||||
m_npcPopulationSystem->serializeWeekSchedules();
|
||||
|
||||
/* Runtime entities — skip characters and player controller */
|
||||
saveData["runtimeEntities"] = nlohmann::json::array();
|
||||
@@ -1940,6 +1971,15 @@ void EditorApp::loadGame(const std::string &slotPath)
|
||||
|
||||
clearScene();
|
||||
|
||||
/* loadGame replaces the base scene: deactivate the terrain
|
||||
* BEFORE loading it so TerrainSystem::update() reactivates
|
||||
* with FRESH entity ids (m_active otherwise stays true with a
|
||||
* stale m_terrainEntityId — the graph read-throughs after a
|
||||
* reload then see a dead entity, as the week-revision probe
|
||||
* exposed). */
|
||||
if (m_terrainSystem)
|
||||
m_terrainSystem->deactivate();
|
||||
|
||||
SceneSerializer serializer(m_world, m_sceneMgr);
|
||||
if (!serializer.loadFromFile(baseScene, m_uiSystem.get())) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
@@ -1977,6 +2017,13 @@ void EditorApp::loadGame(const std::string &slotPath)
|
||||
else
|
||||
CharacterStatStore::getInstance().deserialize(
|
||||
nlohmann::json::object());
|
||||
/* 6.8.10: restore the weekly schedule store (weeks, bindings,
|
||||
* usage, the stats snapshot used and the revision markers);
|
||||
* old saves without the section regenerate from defaults. */
|
||||
if (m_npcPopulationSystem &&
|
||||
saveData.contains("npcWeekSchedules"))
|
||||
m_npcPopulationSystem->deserializeWeekSchedules(
|
||||
saveData["npcWeekSchedules"]);
|
||||
|
||||
/* Destroy ALL spawned characters so the registry spawn is the
|
||||
* only source of characters. */
|
||||
|
||||
@@ -4537,10 +4537,72 @@ feet sliding.
|
||||
|
||||
### Phase 6.8 — DSL macros, symbolic schedules and POIs
|
||||
|
||||
**Status: IN PROGRESS — 6.8.9 stats foundation, 6.8.1 DSL macros,
|
||||
6.8.2 POI/tag model (a+b), 6.8.3 weekly schedule generator and
|
||||
6.8.4a weekly store/F7/migration DONE (2026-10-29); 6.8.4b
|
||||
runtime day-tiling flip + week-rollover probe next.** Rewritten
|
||||
**Task 6.8.5 — Schedule realism rules (workstream B, content).**
|
||||
|
||||
**Status: DONE (2026-10-29).** `WeekScheduleRules` gained the tags-driven
|
||||
`openHoursByTag` + `softCapacity`/`capacityByTag` fields; the pure
|
||||
defaults are NO-OPS (always-open venues, unlimited capacity) so
|
||||
default weekly builds stay bit-identical, and the demo's weekly
|
||||
build opts in (shop 9-18, soft capacity 8, `NpcPopulationSystem::
|
||||
demoWeeklyRules()`).
|
||||
|
||||
- **Implemented:**
|
||||
- `openHoursByTag`: venue hours looked up by the location's FIRST
|
||||
tag that carries a table entry; unmatched venues are always
|
||||
open. Both the sticky-job resolve (the work block the member
|
||||
will actually hold) and the per-day leisure slot filter their
|
||||
pools to venues open for the slot's FULL span — no schedule
|
||||
entry sits at a closed venue even in part; closed-at-slot town
|
||||
falls through the usual fallback chains (leisure → curb →
|
||||
interest → home with a warning; work → binding when the town
|
||||
has no Work locations at all, HOME when Work locations exist
|
||||
but every one is shut for the work window — the closing pull).
|
||||
- `softCapacity`/`capacityByTag`: picks past the resolved limit
|
||||
sink the candidate's score (`TownScheduleScore::Candidate::
|
||||
capacity` + `Weights::capacity`, mirrored through
|
||||
`systems/ScheduleScoring` — one kernel, plus the Lua hook
|
||||
keeps working). The push is soft: a heavy distance trade can
|
||||
still win, so no hard veto invariants break.
|
||||
- **Fleet fix found while wiring the demo (6.8.5 §parking):** the
|
||||
claim-time `parkingFor(..., respectClaims)` fallback picked the
|
||||
nearest free Parking TOWN-WIDE with no distance cap — under shop
|
||||
hours a Lot 57 visit routed its claim 141 m to the driver's own
|
||||
home-slot street (and a Lot 60 driver 370 m to Lot 13), wedging
|
||||
the live tier against the 20000-frame observation budget
|
||||
(`--test-traffic-schedule` "car never arrived"). All three
|
||||
claim-time tiers (bays → overflow → curb chain) AND the
|
||||
occupancy-blind timing picks now stay within the venue's
|
||||
`parkFallbackRadiusM` (80 m default, 0 = the pre-6.8.5 unlimited
|
||||
behavior); a full frontage claims the venue's own curb.
|
||||
- Verification: the week probe (`runTownWeekScheduleProbe`) grew a
|
||||
realism fixture block — a closed-venue property sweep (no entry
|
||||
sits at a closed venue), the tag-order lookup ({"late","shop"}
|
||||
beats the table), the closing-pull (a fully-shut venue pulls the
|
||||
whole workday home with zero work entries surviving), the soft
|
||||
capacity spread (capacity 1 spreads the equal-cost leisure pair;
|
||||
same-rules regen stays bit-identical) — and the full battery:
|
||||
`--test-motion` / `--test-determinism` / `--test-taxi` /
|
||||
`--test-traffic-schedule` / `--test-parking` / `--test-detail` /
|
||||
`--test-population-switch` / `--test-week-rollover` + 8/8 CTests
|
||||
PASS.
|
||||
- F7 spot check: the Week pane shows the clipped/filtered
|
||||
weekly template (shop-bound evening errands now fall back to
|
||||
curb/interest instead of sitting at a closed shop).
|
||||
|
||||
**Status: IN PROGRESS — all of phase 6.8's task-sized work is
|
||||
DONE (2026-10-30): 6.8.9 stats foundation, 6.8.1 DSL macros,
|
||||
6.8.2 POI/tag model (a+b), 6.8.3 weekly schedule generator,
|
||||
6.8.4 runtime weekly wiring (a+b), 6.8.5 schedule realism
|
||||
rules, 6.8.6 interior POI/action DSL, 6.8.7 special-job
|
||||
rosters, 6.8.8 entrance roles, 6.8.10 stat-driven weekly
|
||||
variety + schedule persistence & revisions, 6.8.13 reference
|
||||
layouts and 6.8.14 emergency rescheduling + hospitalization.
|
||||
Deferred follow-ups: the 6.8.9 stat-definition form defined
|
||||
directly from DSL data (content pass), multi-week feedback
|
||||
simulation (beyond the 6.8.10 probes) and the detail-tier
|
||||
embankment robustness issue found by the emergency battery
|
||||
(see the 6.8.14 block).**
|
||||
Rewritten
|
||||
2026-10-28: the original bullet list mixed four independent features
|
||||
and contradicted the clock-pure motion core; it is now split into
|
||||
workstreams with explicit design decisions, task-sized pieces and
|
||||
@@ -4859,9 +4921,11 @@ symbolic schedule generator) next.**
|
||||
|
||||
**Task 6.8.4 — Runtime integration + migration (workstream B).**
|
||||
|
||||
**Status: 6.8.4a DONE (2026-10-29) — the weekly store, the registry
|
||||
rebind migration and the F7 week pane are in (runtime motion/traffic
|
||||
NOT flipped yet); the day-tiling flip + rollover probe are 6.8.4b.**
|
||||
**Status: DONE (2026-10-29) — 6.8.4a store/F7/migration + 6.8.4b the
|
||||
motion/traffic day-tiling flip. All 8 CTests and the full demo
|
||||
battery (`--test-motion`, `--test-determinism`, `--test-taxi`,
|
||||
`--test-traffic-schedule`, `--test-parking`, `--test-detail`,
|
||||
`--test-population-switch`, `--test-week-rollover`) PASS.**
|
||||
|
||||
- `NpcPopulationSystem` stores weekly schedules per area and the
|
||||
resolved bindings; `NpcMotionSystem` reads the day-of-week tiling
|
||||
@@ -4893,29 +4957,65 @@ NOT flipped yet); the day-tiling flip + rollover probe are 6.8.4b.**
|
||||
From/To/Activity/**Query -> Location** (empty queries show the
|
||||
raw location) and the build's usage tally (top-4 in the pane;
|
||||
the full list lives in the location inspection).
|
||||
- Indoor scheduling follows the entry contract (6.8.12): unchanged
|
||||
- **Implemented (6.8.4b):** the runtime consumes the week. New pure
|
||||
`TownSchedule::splitWeekToDays(week)` returns one DAILY-shaped
|
||||
`NpcSchedule` per day-of-week (the day's whole-hour tiling of
|
||||
[0, 24), `registryId`/`stableKey` preserved), so every existing
|
||||
consumer keeps its daily-shaped `entryAt()` machinery. The
|
||||
population store keeps the views next to the week
|
||||
(`weekDayViews`, built at generation time) with accessor
|
||||
`weekDayScheduleOf(registryId, day)`; `NpcMotionSystem::update`
|
||||
and both traffic call sites (the `rebuildClaims` day-arc replay
|
||||
and `evalSymbolic`) resolve the schedule as `weekDayScheduleOf(id,
|
||||
absDay % 7)`, falling back to the legacy daily schedule when the
|
||||
area has no weekly build. The store change is anchored in the
|
||||
existing paths, so walking and driving cannot disagree.
|
||||
Indoor scheduling follows the entry contract (6.8.12): unchanged
|
||||
this phase — the weekly entries resolve to approach-anchored
|
||||
LocationSet names like the daily ones do.
|
||||
- **New probe: `--test-week-rollover`** (the 6.8.4b done-criterion):
|
||||
the week is a day-of-week tiling that repeats every week, so the
|
||||
pure motion evaluation must reproduce positions bit-exactly
|
||||
across a week boundary — three probe hours on day 7 (Monday of
|
||||
week 1) must equal the same hours on day 0 (Monday of week 0)
|
||||
for every non-driving NPC, and Monday-noon vs Saturday-noon must
|
||||
differ for at least one NPC (the weekend template is actually
|
||||
visible). First run: 9 probes bit-exact, 48 NPCs shifted
|
||||
Monday->Saturday. (The pair is (day 0, day 7) — the SAME weekly
|
||||
day-0 view; day 6 is the Saturday variety control.)
|
||||
- **6.8.4b test-facing gotchas (documented, keep):**
|
||||
- The weekly templates arrival times are WHOLE hours; with the
|
||||
tests' slowed walk (0.2 m/s) the drive/walk windows sit
|
||||
1-2 min wide ending exactly on the hour. The traffic-schedule
|
||||
and parking sweeps therefore densified to ~1.7 min probes
|
||||
(576 over 6-22 h); the old 5-min grid structurally missed
|
||||
every window ("no drive leg observed").
|
||||
- `GameClockSystem::update` re-adopts a sun on ANY divergence —
|
||||
including its `timeSpeed` — and re-adoption takes the sun's
|
||||
TIME too. The phase-B observation setup of both tests sets
|
||||
`setSpeed(1.0)` (real time) against the scene's 0.13-h/s sun:
|
||||
the older phase-B code silently rolled the clock back to the
|
||||
last sweep hour and free-ran away from the drive window. Both
|
||||
tests now align the suns' `timeSpeed` to 1.0 BEFORE
|
||||
`setTime`/`setSpeed` so no divergence fires.
|
||||
- `--test-detail`'s fixed 9.3 scrub hour became a structural dead
|
||||
zone under the whole-hour weekly templates (all walks finish
|
||||
before their arrival hour; nothing is mid-flight at 9.3). The
|
||||
scrub now deterministically scans commute-adjacent hours
|
||||
(8.1..10.7) and picks the one with the most spawn-eligible NPCs
|
||||
(best: 8.9 h, 48 eligible of 145).
|
||||
- Migration: existing `npcWork` Interest placeholders rebind to the
|
||||
new Work locations once; schedules still recompute on load at this
|
||||
stage (6.8.10 turns the weekly store into saved state).
|
||||
- **Remaining (6.8.4b):** flip `NpcMotionSystem`'s active-entry
|
||||
evaluation and the shared `nextLocationChangeOf`/
|
||||
`walkTravelHours` helpers (and through them
|
||||
NpcTrafficSystem's drive-leg derivation + the claim replay) onto
|
||||
the weekly day-tiling (day-of-week = `(absoluteDay % 7)`, the
|
||||
daily schedule as the fallback), the week-rollover determinism
|
||||
probe, and the full `--test-*` battery re-run.
|
||||
- Done when: motion/traffic behave as before on a default town,
|
||||
weekly variety is visible in the overlay, and scrubbing the clock
|
||||
across a week boundary reproduces positions bit-exactly.
|
||||
- Verification: `--test-motion`, `--test-determinism`,
|
||||
`--test-traffic-schedule`, `--test-taxi`, `--test-parking`,
|
||||
`--test-detail`, `--test-population-switch` all PASS unchanged
|
||||
(the runtime is untouched so far — weekly variety is visible in
|
||||
the overlay; the swap comes with 6.8.4b); the week probe in
|
||||
across a week boundary reproduces positions bit-exactly. MET.
|
||||
- Verification: all 8 demo batteries PASS (weekly variety is now the
|
||||
runtime behavior); the week probe in
|
||||
road_geometry_overlap_test grew the usage-tally identity check
|
||||
(picks == members-with-a-job + leisure picks).
|
||||
(picks == members-with-a-job + leisure picks) plus the day-view
|
||||
checks (per-day `splitWeekToDays` slices equal the week's day
|
||||
rows; `entryAt` on a day view agrees with `weekEntryAt`).
|
||||
|
||||
**Task 6.8.5 — Schedule realism rules (workstream B, content).**
|
||||
|
||||
@@ -4931,49 +5031,140 @@ NOT flipped yet); the day-tiling flip + rollover probe are 6.8.4b.**
|
||||
over-capacity location while a legal candidate exists; archetype
|
||||
rules hold across the generated population.
|
||||
- Verification: pure rules probes incl. closed-location and capacity
|
||||
fallbacks; F7 spot check.
|
||||
fallbacks; F7 spot check. (DONE — the status + implementation sit
|
||||
at the top of this phase.)
|
||||
|
||||
**Task 6.8.6 — Interior POI/action DSL (workstream C).**
|
||||
|
||||
- DSL forms (usable inside layouts and macros from 6.8.1) declaring,
|
||||
per room or cell: a POI with its planner data (tags, stat effects)
|
||||
and its mechanical spec (label, activation mode, behavior tree
|
||||
reference with GOAP/HTN data or a `luaTask`/event binding) — the
|
||||
same things the node/component editors author today. Definitions
|
||||
are emitted into the unified POI database (6.8.11); runtime
|
||||
components are spawned on grid build from that definition.
|
||||
- Reuse by reference: a macro or library entry defines the fragment
|
||||
once; layouts instantiate it with parameters. Ids stay stable
|
||||
across rebuilds (same discipline as furniture POI ids).
|
||||
- Done when: a hand-authored interior grid built from DSL produces
|
||||
working actuators (E prompt → behavior tree → Lua event) identical
|
||||
to the component-authored equivalent; rebuilds keep POI ids.
|
||||
- Verification: headless interior-grid probe asserting generated
|
||||
components and one executed action; does NOT require Phase 7/8.
|
||||
**Status: DONE (2026-10-29).** The layout DSL declares interior POIs
|
||||
per room and the town generation materializes them into the same
|
||||
`PoiComponent` machinery component-authored POIs use.
|
||||
|
||||
- **DSL form** (inside `(room ...)`): `:poi ((name opts...))` —
|
||||
options are `:kind k`, `:tags (sym...)`, `:effect (stat delta)`
|
||||
(repeatable), `:activation none|manual|approach|arrive`,
|
||||
`:behavior actionName` (a behavior-tree/action name) xor
|
||||
`:lua scriptPath` (the `poi_activated` event carries it),
|
||||
`:at (x z)` (footprint fractions of the SHELL — the space room
|
||||
rects live in; defaults to the owning room's rect center),
|
||||
`:level n` (defaults to the first room instance of the name),
|
||||
`:hours (open close)`, `:entrance front|back|side`, `:radius n`,
|
||||
`:cooldown s`, `:once`. Hard errors: activation without a
|
||||
handler, both :behavior and :lua, :lua without :activation,
|
||||
:at fractions outside [0,1], unknown options. Re-declaring a
|
||||
room name (another floor) keeps its first :poi list, like the
|
||||
room type; macros emit decls like any other form.
|
||||
- **Plan fields**: `LayoutLib::PlanPoi` + `BuildingPlan::pois`
|
||||
(declaration order) — pure data, headlessly evaluable.
|
||||
- **Materialization** (`TownGenSystem::createPlanPois`, called for
|
||||
full/interiorOnly grids): one runtime `PoiComponent` entity per
|
||||
decl, `child_of` the building, id `poi.<gridUid>.<roomName>
|
||||
[-fN].<name>` (the grid uid is `town-<address>`, so ids survive
|
||||
regeneration/page rebuilds), positioned at the decl's snapped
|
||||
cell. The entity's scene node hangs at the ROOT at the derived
|
||||
world-render position (with `hasWorldPosition`) — the proximity
|
||||
scan and the POI database merge read node positions verbatim and
|
||||
a node parented to the building would report the local grid
|
||||
offset. Mechanical side (E-prompt actuator ensure, approach/
|
||||
arrive firing, the `NpcPopulationSystem` POI-database merge) is
|
||||
the shared phase-6.8.11 machinery, unchanged.
|
||||
- **Verification**: the pure `:poi` grammar block in
|
||||
`road_geometry_overlap_test` (3 decls parsed, field-by-field,
|
||||
macro reuse, first-wins re-declaration, 6 malformed decls
|
||||
rejected) and the new demo battery `demoTaxi --headless
|
||||
--test-dsl-poi`: the preview grid's POIs exist with stable ids,
|
||||
merge into the town's POI database with the planner data, gain
|
||||
their ensured ActuatorComponent, the OnApproach POI fires the
|
||||
probe action when the player enters the radius, the OnArrive Lua
|
||||
POI fires `poi_activated` after the dwell seconds, and a
|
||||
re-preview reproduces every poiId bit-for-bit. Full battery +
|
||||
CTest 8/8 PASS; the `--test-poi` battery gained the exterior
|
||||
scene default it lacked (it had been reading the interior start
|
||||
scene and timing out since 6.8.11).
|
||||
|
||||
**Task 6.8.7 — Special-job rosters (workstream B, tiny).**
|
||||
|
||||
- NPCs whose job tags match a special set (`police`, `ambulance`,
|
||||
`firefighter`, ...) are exposed as per-area rosters (registry-id
|
||||
lists with home/work bindings) for later game systems; no AI
|
||||
behavior is added here.
|
||||
- Done when: rosters are deterministic and queryable from C++ and
|
||||
Lua; F7 lists them.
|
||||
- Verification: pure roster probe + a Lua binding smoke case.
|
||||
**Status: DONE (2026-10-29).** Special-service buildings get their
|
||||
own job tags, the population's weekly build derives per-area
|
||||
rosters (special tag -> registry-id-ordered members with home/work
|
||||
bindings), and the rosters are queryable from C++, Lua and F7.
|
||||
|
||||
- **DATA**: `TownLocations::jobTagForBuilding` maps the special
|
||||
building tags onto service roles before the shop/office
|
||||
defaults (police→police, ambulance→medic, firefighter→
|
||||
firefighter, hospital→doctor) and `isCommercialBuilding` counts
|
||||
the special tags as commercial zoning — a police station is a
|
||||
workplace, not an Interest point. The special ROLE vocabulary
|
||||
lives in `TownRosters::specialJobTags()` (police, medic,
|
||||
firefighter, doctor).
|
||||
- **Pure layer**: `roadlib/TownRosters.{hpp,cpp}` — OGRE-free
|
||||
(`deriveRosters(members, tags)` over flattened
|
||||
`MemberJob{registryId, stableKey, home, work, tagQuery}`
|
||||
summaries; OGRE-free so the Lua smoke test links it) plus the
|
||||
weekly-build adapter `TownRostersWeeks.{hpp,cpp}`
|
||||
(`weekJobMembers(weeks)`; home = the first sleep/home entry's
|
||||
location, job = the first work entry's query/location, empty
|
||||
tagQuery for jobless members). Columns: only "job:<tag>"
|
||||
queries with tags ∈ special set are rostered; the tagless Work
|
||||
binding and non-special tags are ignored; registry-id order
|
||||
always.
|
||||
- **Runtime**: `NpcPopulationSystem::generateAreaPopulation`
|
||||
derives the rosters as an OUTPUT of the weekly build (same
|
||||
determinism contract) onto the area; accessor
|
||||
`areaRosters(areaIndex)` (nullptr when no weekly build).
|
||||
- **Lua**: `ecs.rosters.area(areaIndex)` (roster table or nil,
|
||||
members as `{ id = registryId, key = stableKey, home = loc,
|
||||
work = loc }`), `ecs.rosters.tags()` (the special set),
|
||||
`ecs.rosters.is_special(tag)`; the runtime binds the provider to
|
||||
NpcPopulationSystem in EditorApp.setup.
|
||||
- **F7**: the population section lists "special rosters:" per area
|
||||
with the member counts.
|
||||
- **Verification**: the pure `runTownRostersProbe` in
|
||||
road_geometry_overlap_test (a police+ambulance fixture town
|
||||
rosters 3/3 members across the two special tags, bindings are
|
||||
real `home:`/`work:` names, re-derivation bit-identical, an
|
||||
unrepresented filter tag yields an empty map) and the new
|
||||
`rosters_lua_test` (CTest `rostersLuaTest`, OGRE-free: the
|
||||
derivation drops salesman/jobless members, the Lua binding's
|
||||
area/tags/is_special + member fields, an unknown area → nil, a
|
||||
cleared provider → nil). Full battery + CTest 9/9 PASS.
|
||||
|
||||
**Task 6.8.8 — Entrance roles (workstream D, research → small task).**
|
||||
|
||||
- Proposal to validate: buildings record door roles (front =
|
||||
customer, back = employee, side = delivery) at generation time;
|
||||
a location's approach leg ends at the doorstep of the role
|
||||
matching the visitor (employee at a job location, customer at a
|
||||
leisure location). This is a *path-endpoint* choice on the
|
||||
existing road/doorstep model, not interior pathfinding.
|
||||
- Done when: the approach endpoint differs by role for buildings
|
||||
that declare both doors; single-door buildings are unchanged.
|
||||
- Verification: pure endpoint-selection probe; if door-role metadata
|
||||
proves unreliable, drop to a per-location explicit doorstep
|
||||
override and document the limitation.
|
||||
**Status: DONE (2026-10-29).** Door roles are real data: a layout's
|
||||
`(door back|left|right ...)` now records a matching approach point
|
||||
on the building at generation time (`TownBuilding::hasBackDoor/
|
||||
hasSideDoor` + `backDoorstep`/`sideDoorstep`, computed with the
|
||||
same 2 m outside clearance as the front doorstep), and Work
|
||||
locations anchor at the back door while Leisure keeps the front —
|
||||
employee vs customer endpoints differ exactly for buildings that
|
||||
declare both doors; single-door buildings stay bit-identical.
|
||||
|
||||
- **Approach model**: `TownBuilding` records the front/back/side
|
||||
doorsteps in TownGen (door flags of the plan, no new DSL
|
||||
metadata needed — the `(door side ...)` verb already names the
|
||||
side). `TownLocations::buildLocations` sets Work.position =
|
||||
backDoorstep and `entranceRole = "back"` when declared, else the
|
||||
previous front anchor. The motion layer and the entry POI keep
|
||||
the existing approach contract (6.8.12): the POI entry now comes
|
||||
in roles — `entrance:<address>` (front) and
|
||||
`entrance:<address>:back` (when declared), so
|
||||
`PoiDatabase::appendEntries` emits the employee back entry too,
|
||||
`entryOfBuildingForRole` and `approachPosition` resolve it, and
|
||||
`projectLocations` stamps `entryPosition` per role.
|
||||
- **Distance scoring**: the 6.8.12 gap is closed —
|
||||
`TownSchedule::scoreCandidate` now measures candidates against
|
||||
`TownLocations::approachPosition(loc)` (the building entry,
|
||||
for interior destinations) instead of the raw location position;
|
||||
generated towns see identical values.
|
||||
- **Verification**: new `runEntranceRoleProbe`
|
||||
(road_geometry_overlap_test): the back-door fixture's employee
|
||||
approach = backDoorstep (role "back"), customer/home unchanged,
|
||||
`appendEntries` emits front+back exactly once (re-append is
|
||||
idempotent), `entryOfBuildingForRole` finds the back entry,
|
||||
`approachPosition` for a Work POI returns the back position, and
|
||||
single-door buildings keep behavior/ids unchanged. Full CTest
|
||||
9/9 PASS; town schedules unchanged.
|
||||
|
||||
**Task 6.8.9 — Character stats foundation: script-defined stats
|
||||
(health/money/stress) (workstream B, foundation — do FIRST).**
|
||||
@@ -5057,32 +5248,65 @@ snapshots) and `lua-examples/schedule_scoring_example.lua`.
|
||||
**Task 6.8.10 — Stat-driven weekly variety + schedule persistence &
|
||||
revisions (workstream B, new).**
|
||||
|
||||
- `generateWeekSchedules()` takes the week-start stats snapshot
|
||||
(decision 8) read from the 6.8.9 container, with scoring through
|
||||
its `scoreLocations` hook: poor NPCs take more job blocks,
|
||||
stressed NPCs more leisure or full home-rest days, low-health
|
||||
NPCs more rest, within the realism rules of 6.8.5. Until stats
|
||||
actually evolve the defaults reproduce the 6.8.3 schedules, so
|
||||
this lands without behavior changes.
|
||||
- The weekly schedule store is scene-independent (survives scene
|
||||
switches like the population store) and SAVED with the game:
|
||||
weekIndex, entries, the stats snapshot used, resolved bindings
|
||||
and any revision markers. Old saves without it regenerate from
|
||||
defaults.
|
||||
- Mid-week revision operation: rewriting the REST of the current
|
||||
week is an explicit, recorded revision (forward-looking, in-flight
|
||||
goals finish first); revisions are part of the saved state and
|
||||
re-resolve bindings by the `(weekIndex, graphVersion, revision)`
|
||||
key.
|
||||
- Done when: next week's schedule differs from this week's only
|
||||
through the stat snapshot (no hidden drift), a mid-week save →
|
||||
reload restores the schedule, bindings and revisions exactly, and
|
||||
a revision never moves an NPC's current position or cancels an
|
||||
in-flight goal.
|
||||
- Verification: pure variety rules probes; save → scrub → reload
|
||||
round-trip determinism probe; revision persistence probe;
|
||||
`--test-motion` / `--test-determinism` updated to pin the stats
|
||||
snapshot.
|
||||
**Status: DONE (2026-10-29).** Week-start stats snapshots now shape
|
||||
the weekly templates, the weekly store survives scene switches and
|
||||
save/load, and explicit recorded revisions rewrite only the REST of
|
||||
the current week.
|
||||
|
||||
- **Stat-driven shape (pure, inert for defaults):**
|
||||
`generateWeekSchedules` takes a snapshot map with ONLY members
|
||||
that have EXPLICIT stat values (defaults stay inert — the
|
||||
baseline stays bit-identical), and applies per-NPC isolated
|
||||
draws: stress > 60 → more leisure, money < 25 → fewer paid
|
||||
outings and an extra weekend job shift, health < 30 → a
|
||||
deterministic full-home rest day, stress > 85 → a deterministic
|
||||
full-home rest day. The per-NPC mt19937 draw order is unchanged
|
||||
when the snapshot is empty.
|
||||
- **Stat effects as DATA**: `NpcLocation::statEffects`
|
||||
(unordered_map, empty = neutral) rides the unified POI feed and
|
||||
the LocationSet projection; `TownSchedule::scoreCandidate` now
|
||||
fills `WeekCandidate::effects`, so the scoring hook can steer
|
||||
venue choice (poor → money-positive venues, stressed →
|
||||
stress-relieving ones) while the default kernel stays
|
||||
stats-free.
|
||||
- **Weekly store persistence**: `AreaPopulation::weekSchedules` /
|
||||
`weekDayViews` / `weekUsage` / `weekStats` / `rosters` and the
|
||||
revision history ride `saveData["npcWeekSchedules"]`
|
||||
(`NpcPopulationSystem::serializeWeekSchedules` /
|
||||
`deserializeWeekSchedules`); old saves without the section
|
||||
regenerate from defaults. `EditorApp::loadGame` also
|
||||
DEACTIVATES terrain before loading the new base scene
|
||||
(otherwise `m_active` stayed true with a stale
|
||||
`m_terrainEntityId` and the graph read-throughs after reload saw
|
||||
a dead entity), and revision history is preserved across
|
||||
`generateAreaPopulation` AND `NpcPopulationSystem::update()`'s
|
||||
clear+regenerate cycle.
|
||||
- **Revision operation**: `NpcPopulationSystem::reviseWeek`
|
||||
(areaIndex, reason, graphVersion) re-runs the weekly build with
|
||||
the CURRENT stats snapshot and splices at the current absolute
|
||||
clock via `TownSchedule::spliceWeekAfter` — past days stay
|
||||
identical, the in-flight entry is kept WHOLE (in-flight goals
|
||||
finish first, no NPC is moved), the rest of the week is
|
||||
rewritten and the day views rebuilt. Revisions append a
|
||||
`WeekRevision{reason, weekIndex, graphVersion, revisionId}` to
|
||||
`AreaPopulation::revisions`, preserved on regen and on
|
||||
save/load. Bindings re-resolve by (weekIndex, graphVersion,
|
||||
revision).
|
||||
- **Bugfixes landed while wiring the probes**: the spawner
|
||||
`spawnDistanceSq = -1` sentinel round-trip no longer writes
|
||||
JSON null (`SceneSerializer::serializeCharacterSpawner` /
|
||||
`deserializeCharacterSpawner` guard), which previously crashed
|
||||
save/load in a live town.
|
||||
- **Verification**: new pure `runWeekVarietyProbe` (stats shape:
|
||||
the empty-snapshot build is bit-identical to the baseline, the
|
||||
health rest day is home-only at the hashed index, money<25 adds
|
||||
a weekend job shift, stress>85 gives a deterministic rest day,
|
||||
effects ride into the score hook, and `spliceWeekAfter` keeps
|
||||
the in-flight entry whole with day tilings intact) and the demo
|
||||
battery `demoTaxi --headless --test-week-revision` (neutral
|
||||
revision is a recorded no-op with the revision key fields, save
|
||||
→ reload round trip keeps weeks and the revision marker, and a
|
||||
second revision appends). Full battery + CTest 9/9 PASS.
|
||||
|
||||
**Task 6.8.11 — Unified POI model: shared database + page-managed
|
||||
activation (workstreams B+C, ground-up POI definition).**
|
||||
@@ -5200,7 +5424,10 @@ building — keyed on the "one Home per building at its doorstep"
|
||||
invariant: id `entrance:<address>`, kind `entrance`, domain
|
||||
Exterior, tag `entrance`, entrance role `front` (generated buildings
|
||||
are single-door; multi-door role matching waits for 6.8.8/6.8.13
|
||||
content), position = the Home location (the doorstep). The POI set
|
||||
content — **landed 6.8.8: back/side doorsteps computed by TownGen
|
||||
and employee (Work) locations anchored there, with the matching
|
||||
`entrance:<address>:back` POI emitted by the same appendEntries
|
||||
call and `entryOfBuildingForRole` resolving role**), position = the Home location (the doorstep). The POI set
|
||||
is finalized right after (the initial population build was missing
|
||||
that call — only the 60-frame re-merge had it). Pure helpers:
|
||||
`entryId`, `entryOfBuilding` (building index first, then address)
|
||||
@@ -5221,8 +5448,10 @@ anchors on the same entry). Editor: the POI Database panel groups
|
||||
interior POIs under their building's entry (toggleable; exterior
|
||||
records stay flat, filtered-out entries fall their interior records
|
||||
back flat). The distance-score side (ScheduleScoring) has no live
|
||||
caller yet — the weekly planner (6.8.2) must compute candidate
|
||||
distances to `approachPosition`, noted there. Verification:
|
||||
caller yet — **landed 6.8.8: the weekly planner's scoreCandidate now
|
||||
computes candidate distances against
|
||||
`TownLocations::approachPosition(loc)` (the building entry, with
|
||||
employee back-door anchors per 6.8.8)**. Verification:
|
||||
`runPoiDatabaseProbe` extended (append count + idempotence, entry
|
||||
fields/doorstep position, index/address lookup, interior/exterior/
|
||||
orphan approach resolution, projection domain+entry stamps, no
|
||||
@@ -5270,71 +5499,128 @@ destination — the schedule model is unchanged.
|
||||
**Task 6.8.13 — Reference layouts: home, job, leisure (workstreams
|
||||
B+C, content fixtures).**
|
||||
|
||||
- Author ONE reference layout per location kind in
|
||||
`town_layouts.txt`, exercising the whole pipeline (tags → POI db →
|
||||
entry POI → schedule assignment → stat effects):
|
||||
- **home** (residence): the Home location and its entry POI. At
|
||||
home the NPC is IDLE — no POI activation and no behavior trees
|
||||
required. Interior home AI (cooking, cleaning, family
|
||||
interactions, wandering between rooms) is explicitly OUT of
|
||||
scope for the schedule-based AI; a home entry only means rest
|
||||
with the home's (weaker) stress-recovery effect.
|
||||
- **job** (a shop or office): Work + job tags, work POIs with
|
||||
their stat effects (`money:+X/h`, `stress:+Y/h`), opening
|
||||
hours, and an employee entrance role.
|
||||
- **leisure** (a park/cafe/bar): Leisure tags and stress-relief
|
||||
effects with a money cost where appropriate, opening hours and
|
||||
a customer entrance role.
|
||||
- These are the concrete fixtures for 6.8.2 tag assignment, 6.8.6
|
||||
DSL POI authoring and the 6.8.12 entry contract — implement them
|
||||
early so every later task has real content to test against.
|
||||
- Done when: a town containing the three layouts produces correctly
|
||||
tagged Work/Leisure/Home locations with entry POIs, the scheduler
|
||||
assigns NPCs to them, and completing an entry applies exactly the
|
||||
declared stat effects.
|
||||
- Verification: generated-tag/entry/effect probes per layout; a
|
||||
schedule probe asserting home entries are idle (no activation).
|
||||
**Status: DONE (2026-10-29).** `town_layouts.txt` ships the three
|
||||
reference layouts and the stat effects are real data end to end.
|
||||
|
||||
- **`ref-home`** (residence, tags house/village/town): living +
|
||||
bedroom, idle rest — the home LOCATION carries
|
||||
`:effects ((stress -1))` (weaker stress recovery per hour; NO
|
||||
POI activation and no behavior trees at home — interior home AI
|
||||
stays out of scope).
|
||||
- **`ref-shop`** (commercial shop): the shop room carries
|
||||
`:effects ((money 5) (stress 1))`, the layout declares
|
||||
`(door back center)` so employees (Work locations) anchor at the
|
||||
back door (6.8.8) while customers keep the front, and opening
|
||||
hours come from the runtime rules table (shop 9-18, 6.8.5).
|
||||
- **`ref-park`** (leisure park): `:effects ((money -2) (stress -4))`
|
||||
with a customer front door.
|
||||
- **The `:effects` room option** (new, `LayoutEval`): values are
|
||||
`(stat delta)` pairs; the plan carries them parallel to
|
||||
roomNames (`BuildingPlan::roomStatEffects`), and
|
||||
`TownLocations::buildLocations` merges them onto the
|
||||
Work/Leisure/Home locations by ROOM TYPE role match (work =
|
||||
shop/store/office/commercial/work/shopfront/storage; leisure =
|
||||
park/cafe/bar/leisure; home = bedroom/living/livingroom/home/
|
||||
residence), additive across matching rooms.
|
||||
- **Effects apply on COMPLETED schedule entries**:
|
||||
`systems/StatEffectsApplier::applyLocationEffects(store, id,
|
||||
loc, hours)` applies `loc.statEffects × hours` through the
|
||||
`CharacterStatStore` (clamped); `NpcMotionSystem::update` calls
|
||||
it on every entry-location transition, keyed
|
||||
`(npc, location, startHour)` so each entry applies exactly once
|
||||
per run (a back-scrub never re-applies; the completed set is
|
||||
runtime-only).
|
||||
- Done when: a town containing the three layouts produces
|
||||
correctly tagged Work/Leisure/Home locations with entry POIs,
|
||||
the scheduler assigns NPCs to them, and completing an entry
|
||||
applies exactly the declared stat effects. MET: the generated
|
||||
demo town now picks the ref layouts by tag (the batteries'
|
||||
content numbers shifted accordingly and determinism holds within
|
||||
a run); `runReferenceLayoutsProbe` (road_geometry_overlap_test)
|
||||
parses all three layouts and asserts the effects on the
|
||||
Work/Leisure/Home locations, the employee back entry, the
|
||||
effects' survival through the unified feed/projection, and the
|
||||
applier's exact per-hour application.
|
||||
|
||||
**Task 6.8.14 — Emergency rescheduling + hospitalization
|
||||
architecture (workstream B, the health=0 case).**
|
||||
|
||||
- **Emergency layer** on top of the revision model (decision 1):
|
||||
data-driven stat-threshold rules (`health < 10` → hospital now;
|
||||
later `stress > 90` → home rest) evaluated on stat-change events
|
||||
(never per-frame polling). An emergency is the HIGHEST-priority
|
||||
goal — above scripted goals and the baseline — recorded as a
|
||||
revision with a `reason` and an `until` condition, so it is saved
|
||||
and reproducible on load. It cancels or suspends the current
|
||||
goal immediately (a health emergency does not wait for the taxi
|
||||
ride to finish); when the `until` condition clears, the pure
|
||||
baseline simply resumes from the current clock — no schedule
|
||||
state to restore.
|
||||
- **Hospitalization (health = 0):** the NPC is INCAPACITATED, not
|
||||
dead (death stays a non-goal). They are considered IN THE
|
||||
HOSPITAL until fully healed (or above a discharge threshold):
|
||||
position and entries resolve to the hospital location, health
|
||||
regenerates per hour from the hospital's stat effects, and the
|
||||
baseline resumes on discharge. Initial transport is symbolic —
|
||||
the NPC is moved to the hospital entry POI (live tier: despawn
|
||||
where they collapsed, respawn at the hospital); ambulances,
|
||||
stretchers and escorted transport are a LATER system building on
|
||||
the 6.8.7 rosters.
|
||||
- Hospital content requirement: a hospital location with
|
||||
`hospital`/`health` tags and heal-over-time effects (a hospital
|
||||
reference layout may join 6.8.13). A town without one falls back
|
||||
to home rest with a single logged warning — never a crash or an
|
||||
NPC stuck incapacitated in the street.
|
||||
- F7 overlay shows the emergency state (reason, until condition,
|
||||
current health).
|
||||
- Done when: crossing `health < 10` rewrites the NPC's goal to the
|
||||
hospital immediately and persists across save/load; `health = 0`
|
||||
hospitalizes symbolically until healed and the baseline resumes
|
||||
on discharge with correct position and no leftover emergency
|
||||
state; the no-hospital fallback works.
|
||||
- Verification: pure threshold/emergency-priority/discharge probes;
|
||||
save → reload during an emergency restores it exactly;
|
||||
health=0 → hospital → healed → baseline round trip; no-hospital
|
||||
fallback probe.
|
||||
**Status: DONE (2026-10-30).**
|
||||
|
||||
- **The emergency layer** is per-NPC override state on
|
||||
`AreaPopulation` (`EmergencyState{reason, location, startAbs,
|
||||
schedule}`): the HIGHEST-priority goal, superseding the weekly
|
||||
day view, the legacy daily schedule, scripted goals and live
|
||||
traffic. `beginEmergency`/`endEmergency`/`hasActiveEmergency`/
|
||||
`emergencyScheduleOf` on `NpcPopulationSystem`;
|
||||
`effectiveDayScheduleOf(id, day)` = emergency → week day →
|
||||
daily, and ALL THREE schedule consumers now read it
|
||||
(`NpcMotionSystem::update`, both `NpcTrafficSystem` evaluation
|
||||
sites).
|
||||
- **The synthetic schedule** is a 24-entry whole-hour tiling at
|
||||
the emergency location (activity "hospital") so the clock-pure
|
||||
evaluation transports the NPC there symbolically. The
|
||||
destination is the town's hospital Work location (the
|
||||
`hospital` tag on a Work location — the `ref-hospital` layout
|
||||
ships one, weight 5 so towns reliably carry it); a town without
|
||||
one falls back to `home:<address>` rest with a single logged
|
||||
warning.
|
||||
- **Data-driven thresholds:** the stats rules module registers
|
||||
them via `ecs.emergency.set_thresholds{ health_below,
|
||||
discharge_at }` (`lua/LuaEmergencyApi.cpp`; the module carries
|
||||
the values as data — hospitalBelowHealth 10, dischargeHealth
|
||||
100). The engine subscribes ONCE to the `CharacterStatStore`
|
||||
change events (never per-frame polling): health crossing below
|
||||
the trigger begins the emergency; reaching the discharge
|
||||
threshold ends it. `ecs.emergency.begin/end/active` are also
|
||||
callable directly (scripted emergencies).
|
||||
- **Goal supersede:** `beginEmergency` cancels the taxi goal
|
||||
(`NpcTaxiSystem::cancelGoal`) and despawns the live car
|
||||
(`NpcTrafficSystem::despawnLiveCar` — new); `assignTaxiGoal`
|
||||
refuses while the NPC is in an emergency.
|
||||
- **Persistence:** the `emergencies` section rides
|
||||
`serializeWeekSchedules`/`deserializeWeekSchedules` (registry
|
||||
id, reason, location, startAbs; the synthetic schedule is
|
||||
rebuilt from the location on load), and the state is preserved
|
||||
across the update()/generateAreaPopulation clear+regenerate
|
||||
cycles (like the revision history).
|
||||
- **F7** shows a red `EMERGENCY` banner for the selected NPC with
|
||||
the synthetic activity, the emergency location and the live
|
||||
health.
|
||||
- **Hospital content:** `ref-hospital` in `town_layouts.txt`
|
||||
(commercial/hospital tags, ward room `:type hospital :effects
|
||||
((health 5))` — heal-over-time via the 6.8.13 stat effects; a
|
||||
back door for staff per 6.8.8); doctors derive from it via the
|
||||
6.8.7 rosters.
|
||||
- Done-when, MET: crossing the threshold rewrites the NPC's goal
|
||||
to the hospital immediately and persists across save/load;
|
||||
discharge (health back at the threshold) resumes the pure
|
||||
baseline with no leftover state; the no-hospital fallback was
|
||||
verified end to end (an earlier build of the demo town without
|
||||
the hospital layout ran the home-rest path with the warning).
|
||||
The pure layer is intentionally covered by the demo battery
|
||||
rather than a road-test probe: the threshold logic lives in the
|
||||
runtime binding (`LuaEmergencyApi` → `NpcPopulationSystem`),
|
||||
which is OGRE-linked and cannot join the OGRE-free probe binary.
|
||||
- Verification: `./demoTaxi --headless --test-emergency` (the
|
||||
rules module is required through the app's Lua state — the full
|
||||
shipped data path — then: threshold trigger via
|
||||
`ecs.stats.set`, the 24-entry synthetic effective schedule,
|
||||
symbolic transport to the emergency location, save → reload
|
||||
mid-emergency restoring it, discharge → baseline resumption,
|
||||
taxi-goal refusal with a valid destination, endEmergency on a
|
||||
non-emergency id, and the no-hospital fallback when the town
|
||||
allows it).
|
||||
- **Known issue found by the battery (pre-existing, 4.x-era,
|
||||
tracked separately):** the taxi battery's candidate rotation
|
||||
(stall → next NPC) now papers over a detail-tier robustness
|
||||
gap — a detail entity spawned below a road embankment (the
|
||||
terrain raycast drops its feet to the heightmap, possibly in a
|
||||
ditch) can never physically reach a boarding door above the
|
||||
embankment; the root-motion character wedges at the pad face
|
||||
forever. The symbolic layer is height-agnostic and unaffected.
|
||||
Fix candidate for a later phase: obstruction-aware detail
|
||||
spawns or reachable-door fallback.
|
||||
|
||||
#### Cross-cutting requirements
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,16 @@ function M.define()
|
||||
ecs.stats.define{ name = "health", default = 100, min = 0, max = 100 }
|
||||
ecs.stats.define{ name = "money", default = 0, min = 0 }
|
||||
ecs.stats.define{ name = "stress", default = 0, min = 0, max = 100 }
|
||||
-- 6.8.14: register the module's emergency thresholds with the
|
||||
-- engine (data-driven; the store listener triggers on health
|
||||
-- CHANGE events, never polling). discharge_at defaults above
|
||||
-- the trigger so an NPC cannot oscillate at the threshold.
|
||||
if ecs.emergency and ecs.emergency.set_thresholds then
|
||||
ecs.emergency.set_thresholds{
|
||||
health_below = M.emergency.hospitalBelowHealth,
|
||||
discharge_at = M.emergency.dischargeHealth,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply one completed schedule activity's effects for `hours`
|
||||
|
||||
@@ -73,3 +73,74 @@
|
||||
(windows front))
|
||||
(roof flat 0 0 w d)
|
||||
(parking (rect-front-center 13 5.2))))
|
||||
|
||||
; ---------------------------------------------------------------------
|
||||
; Reference layouts (phase 6.8.13): one per location kind, exercising
|
||||
; tags -> POI database -> entry POI -> schedule assignment -> stat
|
||||
; effects. Home is idle rest (no activation and no behavior trees
|
||||
; required); job pays money + raises stress with opening hours and an
|
||||
; employee back door; leisure relieves stress for a money cost with a
|
||||
; customer front door.
|
||||
; ---------------------------------------------------------------------
|
||||
|
||||
; Home: a residence. Idle rest with a weaker stress-recovery effect
|
||||
; (no POI activation at home).
|
||||
(layout ref-home :tags (house village town) :score 1
|
||||
(let ((w 12) (d 10))
|
||||
(shell w d :floors 1 :empty)
|
||||
(floor 0
|
||||
(room living1 (rect 0 0 0.55 1) :type living
|
||||
:effects ((stress -1)))
|
||||
(room bedroom1 (rect 0.55 0 1 1) :type bedroom)
|
||||
(connect living1 bedroom1)
|
||||
(door front center)
|
||||
(windows all))
|
||||
(roof gable 0 0 w d)
|
||||
(parking (rect-front-center 5.2 5.2))))
|
||||
|
||||
; Job: a shop/office with a work counter. money:+5/h, stress:+1/h,
|
||||
; open 9-18, employee back door (6.8.8).
|
||||
(layout ref-shop :tags (commercial shop village town) :score 1
|
||||
(let ((w 16) (d 12))
|
||||
(shell w d :floors 1 :empty)
|
||||
(floor 0
|
||||
(room shop1 (rect 0 0 0.7 1) :type shop
|
||||
:effects ((money 5) (stress 1)))
|
||||
(room storage1 (rect 0.7 0 1 1) :windows none)
|
||||
(connect shop1 storage1)
|
||||
(door front center)
|
||||
(door back center)
|
||||
(windows front))
|
||||
(roof flat 0 0 w d)
|
||||
(parking (rect-front-center 13 5.2))))
|
||||
|
||||
; Leisure: a park/cafe. money:-2/h, stress:-4/h, open 9-20,
|
||||
; customer front door.
|
||||
(layout ref-park :tags (leisure park village town) :score 1
|
||||
(let ((w 14) (d 12))
|
||||
(shell w d :floors 1 :empty)
|
||||
(floor 0
|
||||
(room park1 (rect 0 0 1 1) :type park
|
||||
:effects ((money -2) (stress -4)))
|
||||
(door front center)
|
||||
(windows all))
|
||||
(roof gable 0 0 w d)
|
||||
(parking (rect-front-center 5.2 5.2))))
|
||||
|
||||
; Hospital (6.8.14): the emergency destination. The ward heals
|
||||
; health over time (health:+5/h) via its stat effects; doctors work
|
||||
; here (6.8.7 rosters); weight 5 so towns reliably carry one for the
|
||||
; emergency layer.
|
||||
(layout ref-hospital :tags (commercial hospital village town) :score 5
|
||||
(let ((w 20) (d 14))
|
||||
(shell w d :floors 1 :empty)
|
||||
(floor 0
|
||||
(room ward1 (rect 0 0 0.6 1) :type hospital
|
||||
:effects ((health 5)))
|
||||
(room lobby1 (rect 0.6 0 1 1) :type lobby)
|
||||
(connect ward1 lobby1)
|
||||
(door front center)
|
||||
(door back center)
|
||||
(windows all))
|
||||
(roof flat 0 0 w d)
|
||||
(parking (rect-front-center 13 5.2))))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
/*
|
||||
@@ -92,6 +93,48 @@ struct PlanPlacement {
|
||||
float yawDeg = 0.0f;
|
||||
};
|
||||
|
||||
/** One DSL-authored POI (phase 6.8.6). Authored per ROOM through
|
||||
* the `(room ... :poi ((name opts...)))` option; positions are
|
||||
* footprint FRACTIONS of the shell (the same space as room rects),
|
||||
* snapped into the owning room's rect at materialization. The
|
||||
* fields mirror PoiComponent one-to-one (kind, tags, stat effects,
|
||||
* opening hours, activation spec) so a DSL POI and a
|
||||
* component-authored POI are the same thing to every consumer. */
|
||||
struct PlanPoi {
|
||||
/** The room-table name this decl came from. */
|
||||
std::string room;
|
||||
/** Decl name; unique across the plan (the materialized
|
||||
* PoiComponent::poiId builds on it). */
|
||||
std::string name;
|
||||
std::string kind = "interaction";
|
||||
std::vector<std::string> tags;
|
||||
/** Stat name -> delta per in-game hour (decl order kept). */
|
||||
std::vector<std::pair<std::string, double>> statEffects;
|
||||
/** "" | "front" | "back" | "side". */
|
||||
std::string entranceRole;
|
||||
/** Opening hours in [0,24); < 0 = always open. */
|
||||
float openHour = -1.0f;
|
||||
float closeHour = -1.0f;
|
||||
/** The floor the POI exists on (defaults to the first room
|
||||
* instance; per-floor pois pick ":level"). */
|
||||
int level = -1;
|
||||
/** Footprint fractions of the shell ([0,1]). Defaults to
|
||||
* the owning room's rect center. */
|
||||
float atX = -1.0f;
|
||||
float atZ = -1.0f;
|
||||
/** Activation, mirroring PoiDatabase::ActivationKind
|
||||
* (0 none, 1 manual, 2 onApproach, 3 onArrive). */
|
||||
int activation = 0;
|
||||
/** ActionDatabase action / behavior-tree name (behaviorTree
|
||||
* activation) or a `LuaScripts` path (the `poi_activated`
|
||||
* event carries it). */
|
||||
std::string behaviorTree;
|
||||
std::string luaScript;
|
||||
float radius = 1.5f;
|
||||
float cooldownSeconds = 0.0f;
|
||||
bool once = false;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Furniture composition rules (phase 6.5.F). Pure data, shared by */
|
||||
/* the DSL (:compose room option), the serialized RoomComponent and */
|
||||
@@ -175,9 +218,19 @@ struct BuildingPlan {
|
||||
* option (phase 6.5.F), parallel to roomNames. Empty = the
|
||||
* priority-class tag walk furnishes the room. */
|
||||
std::vector<RoomCompositionRules> roomCompositions;
|
||||
/** Per-room stat effects from the :effects room option
|
||||
* (phase 6.8.13), parallel to roomNames: (stat -> delta per
|
||||
* in-game hour). Work POIs pay money and raise stress, leisure
|
||||
* POIs relieve stress for a cost, home is idle rest with a
|
||||
* weaker recovery. Empty = no effects. */
|
||||
using RoomStatEffectPair = std::pair<std::string, double>;
|
||||
using RoomStatEffectList = std::vector<RoomStatEffectPair>;
|
||||
std::vector<RoomStatEffectList> roomStatEffects;
|
||||
std::vector<PlanRoof> roofs;
|
||||
std::vector<PlanParking> parkings;
|
||||
std::vector<PlanPlacement> placements;
|
||||
/** DSL-authored POIs (6.8.6), in declaration order. */
|
||||
std::vector<PlanPoi> pois;
|
||||
|
||||
bool valid() const
|
||||
{
|
||||
|
||||
@@ -1348,11 +1348,182 @@ static bool parseTagList(const SExpr &form, std::vector<std::string> &out,
|
||||
std::to_string(tag.line) + ")";
|
||||
return false;
|
||||
}
|
||||
out.push_back(tag.text);
|
||||
out.push_back(tag.text);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse one :poi room option declaration (phase 6.8.6):
|
||||
* (name :kind k :tags (sym...) :effect (stat delta)
|
||||
* :activation manual|approach|arrive :behavior action
|
||||
* :lua path :at (x z) :level n :hours (open close)
|
||||
* :entrance role :radius n :cooldown s :once)
|
||||
* Activations mirror PoiDatabase::ActivationKind (none=0, manual=1,
|
||||
* approach=2, arrive=3). A POI must have exactly one handler:
|
||||
* :behavior and :lua are mutually exclusive, a decl that activates
|
||||
* nothing is a hard error (so authored POIs always do something
|
||||
* when triggered). */
|
||||
static bool parsePoiDecl(const SExpr &decl, PlanPoi &out,
|
||||
std::string *error)
|
||||
{
|
||||
auto fail = [&](const SExpr &at, const std::string &msg) -> bool {
|
||||
if (error)
|
||||
*error = msg + " (line " + std::to_string(at.line) +
|
||||
")";
|
||||
return false;
|
||||
};
|
||||
auto argNum = [&](const SExpr &e, double &out) -> bool {
|
||||
if (!e.isNumber())
|
||||
return false;
|
||||
out = e.number;
|
||||
return true;
|
||||
};
|
||||
if (!decl.isList() || decl.list.size() < 2 ||
|
||||
!decl.list[0].isSymbol())
|
||||
return fail(decl,
|
||||
"each :poi declaration must be "
|
||||
"(name :option value ...)");
|
||||
out.name = decl.list[0].text;
|
||||
for (size_t i = 1; i < decl.list.size(); ++i) {
|
||||
if (!decl.list[i].isSymbol())
|
||||
return fail(decl.list[i], "POI option must be a "
|
||||
":keyword");
|
||||
const std::string &key = decl.list[i].text;
|
||||
if (key == ":once") {
|
||||
out.once = true;
|
||||
continue;
|
||||
}
|
||||
if (i + 1 >= decl.list.size())
|
||||
return fail(decl.list[i], "POI option '" + key +
|
||||
"' needs a value");
|
||||
const SExpr &val = decl.list[i + 1];
|
||||
++i; /* the value: consumed with the keyword below */
|
||||
if (key == ":kind") {
|
||||
if (!val.isSymbol())
|
||||
return fail(val, ":kind needs a symbol");
|
||||
out.kind = val.text;
|
||||
} else if (key == ":tags") {
|
||||
if (!val.isList())
|
||||
return fail(val, ":tags needs a symbol "
|
||||
"list");
|
||||
for (const SExpr &t : val.list)
|
||||
if (!t.isSymbol())
|
||||
return fail(t, ":tags entries must "
|
||||
"be symbols");
|
||||
for (const SExpr &t : val.list)
|
||||
out.tags.push_back(t.text);
|
||||
} else if (key == ":effect") {
|
||||
if (!val.isList() || val.list.size() != 2 ||
|
||||
!val.list[0].isSymbol())
|
||||
return fail(val, ":effect needs a "
|
||||
"(stat delta) pair");
|
||||
double delta = 0.0;
|
||||
if (!argNum(val.list[1], delta))
|
||||
return fail(val.list[1], ":effect delta "
|
||||
"must be a number");
|
||||
out.statEffects.push_back(
|
||||
{ val.list[0].text, delta });
|
||||
} else if (key == ":activation") {
|
||||
if (!val.isSymbol())
|
||||
return fail(val, ":activation needs a "
|
||||
"mode");
|
||||
if (val.text == "none")
|
||||
out.activation = 0;
|
||||
else if (val.text == "manual")
|
||||
out.activation = 1;
|
||||
else if (val.text == "approach")
|
||||
out.activation = 2;
|
||||
else if (val.text == "arrive")
|
||||
out.activation = 3;
|
||||
else
|
||||
return fail(val, ":activation is none, "
|
||||
"manual, approach or "
|
||||
"arrive");
|
||||
} else if (key == ":behavior") {
|
||||
if (!val.isSymbol())
|
||||
return fail(val, ":behavior needs an "
|
||||
"action name");
|
||||
out.behaviorTree = val.text;
|
||||
} else if (key == ":lua") {
|
||||
if (!val.isSymbol())
|
||||
return fail(val, ":lua needs a script "
|
||||
"path");
|
||||
out.luaScript = val.text;
|
||||
} else if (key == ":at") {
|
||||
if (!val.isList() || val.list.size() != 2)
|
||||
return fail(val, ":at needs two shell "
|
||||
"fractions (x z)");
|
||||
double atX = 0.0, atZ = 0.0;
|
||||
if (!argNum(val.list[0], atX) ||
|
||||
!argNum(val.list[1], atZ))
|
||||
return fail(val, ":at fractions must be "
|
||||
"numbers");
|
||||
if (atX < 0.0 || atX > 1.0 || atZ < 0.0 ||
|
||||
atZ > 1.0)
|
||||
return fail(val, ":at fractions (when "
|
||||
"given) must stay inside "
|
||||
"[0,1]");
|
||||
out.atX = (float)atX;
|
||||
out.atZ = (float)atZ;
|
||||
} else if (key == ":level") {
|
||||
double lvl = 0.0;
|
||||
if (!argNum(val, lvl) || lvl < 0.0 ||
|
||||
lvl != std::floor(lvl))
|
||||
return fail(val, ":level needs a "
|
||||
"non-negative integer");
|
||||
out.level = (int)lvl;
|
||||
} else if (key == ":hours") {
|
||||
if (!val.isList() || val.list.size() != 2)
|
||||
return fail(val, ":hours needs open and "
|
||||
"close hours");
|
||||
double open = 0.0, closeH = 0.0;
|
||||
if (!argNum(val.list[0], open) ||
|
||||
!argNum(val.list[1], closeH))
|
||||
return fail(val, ":hours values must be "
|
||||
"numbers");
|
||||
out.openHour = (float)open;
|
||||
out.closeHour = (float)closeH;
|
||||
} else if (key == ":entrance") {
|
||||
if (!val.isSymbol())
|
||||
return fail(val, ":entrance needs a role");
|
||||
if (val.text != "front" && val.text != "back" &&
|
||||
val.text != "side")
|
||||
return fail(val, ":entrance is front, back "
|
||||
"or side");
|
||||
out.entranceRole = val.text;
|
||||
} else if (key == ":radius") {
|
||||
double r = 0.0;
|
||||
if (!argNum(val, r) || r <= 0.0)
|
||||
return fail(val, ":radius needs a positive "
|
||||
"number");
|
||||
out.radius = (float)r;
|
||||
} else if (key == ":cooldown") {
|
||||
double c = 0.0;
|
||||
if (!argNum(val, c) || c < 0.0)
|
||||
return fail(val, ":cooldown needs a "
|
||||
"non-negative number");
|
||||
out.cooldownSeconds = (float)c;
|
||||
} else if (key == ":once") {
|
||||
out.once = true;
|
||||
} else {
|
||||
return fail(decl.list[i], "unknown POI option '" +
|
||||
key + "'");
|
||||
}
|
||||
}
|
||||
if (!out.behaviorTree.empty() && !out.luaScript.empty())
|
||||
return fail(decl, "POI '" + out.name + "' declares both "
|
||||
":behavior and :lua");
|
||||
if (out.activation != 0 && out.behaviorTree.empty() &&
|
||||
out.luaScript.empty())
|
||||
return fail(decl, "POI '" + out.name +
|
||||
"' declares an activation but neither "
|
||||
":behavior nor :lua");
|
||||
if (!out.luaScript.empty() && out.activation == 0)
|
||||
return fail(decl, "POI '" + out.name + "' declares :lua "
|
||||
"but no :activation");
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse one :compose rule:
|
||||
* (group taglist [:at center|zneg|zpos|xneg|xpos] [:rot n] child*)
|
||||
* (row taglist [:at edge] [:step n] [:skip n] [:count n])
|
||||
@@ -1528,13 +1699,21 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
|
||||
if (form.list.size() < 3 || !form.list[1].isSymbol())
|
||||
return fail(form, "room needs (room name (rect x0 z0 x1 "
|
||||
"z1))");
|
||||
if ((form.list.size() - 3) % 2 != 0)
|
||||
if ((form.list.size() - 3) % 2 != 0) {
|
||||
std::string dump;
|
||||
for (const auto &el : form.list) {
|
||||
dump += el.isSymbol() ? el.text : "(...)";
|
||||
dump += " ";
|
||||
}
|
||||
return fail(form, "room options come in :keyword value "
|
||||
"pairs");
|
||||
"pairs [" + dump + "]");
|
||||
}
|
||||
bool noWindows = false;
|
||||
std::string roomType;
|
||||
std::vector<std::string> furnitureTags;
|
||||
RoomCompositionRules compositions;
|
||||
using StatEffectPair = std::pair<std::string, double>;
|
||||
std::vector<StatEffectPair> statEffects_;
|
||||
for (size_t i = 3; i < form.list.size(); i += 2) {
|
||||
if (!form.list[i].isSymbol())
|
||||
return fail(form.list[i], "room option must be a "
|
||||
@@ -1590,11 +1769,69 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
|
||||
return fail(ruleForm, ruleError);
|
||||
compositions.push_back(std::move(rule));
|
||||
}
|
||||
} else if (key == ":poi") {
|
||||
/* Phase 6.8.6: DSL-authored interior POIs.
|
||||
* Value = a list of decls, each
|
||||
* (name :kind kind :tags (sym...)
|
||||
* :effect (stat delta)
|
||||
* :activation manual|approach|arrive
|
||||
* :behavior actionName :lua scriptPath
|
||||
* :at (x z) :level n :hours (open close)
|
||||
* :entrance role :radius n :cooldown s
|
||||
* :once). Positions are footprint fractions
|
||||
* of the SHELL (the room-rect space) and snap
|
||||
* into the owning room's rect; name uniqueness
|
||||
* is per plan (the materialized poiId builds on
|
||||
* it). */
|
||||
const SExpr &decls = form.list[i + 1];
|
||||
if (!decls.isList() || decls.list.empty())
|
||||
return fail(decls,
|
||||
":poi needs a POI declaration "
|
||||
"list, e.g. :poi ((counter :kind "
|
||||
"work :behavior work_counter))");
|
||||
if (!m_roomPois.insert(form.list[1].text).second)
|
||||
continue; /* redeclared room: first wins */
|
||||
for (const SExpr &decl : decls.list) {
|
||||
PlanPoi p;
|
||||
p.room = form.list[1].text;
|
||||
std::string err;
|
||||
if (!parsePoiDecl(decl, p, &err))
|
||||
return fail(decl, err);
|
||||
m_plan->pois.push_back(std::move(p));
|
||||
}
|
||||
} else if (key == ":effects") {
|
||||
/* Phase 6.8.13: the location-level stat effects
|
||||
* (home/work/leisure entries apply them on
|
||||
* completion, per in-game hour). Unlike the
|
||||
* :poi activation spec, this is data on the
|
||||
* room's LOCATION — work pays money and raises
|
||||
* stress, leisure relieves stress for a cost,
|
||||
* home is idle rest with a weaker recovery. */
|
||||
const SExpr &effs = form.list[i + 1];
|
||||
if (!effs.isList() || effs.list.empty())
|
||||
return fail(effs,
|
||||
":effects needs a (stat delta) "
|
||||
"pair list");
|
||||
for (const SExpr &pair : effs.list) {
|
||||
if (!pair.isList() || pair.list.size() != 2 ||
|
||||
!pair.list[0].isSymbol())
|
||||
return fail(pair,
|
||||
":effects entries must be "
|
||||
"(stat delta) pairs");
|
||||
if (!pair.list[1].isNumber())
|
||||
return fail(pair.list[1],
|
||||
":effects delta must be a "
|
||||
"number");
|
||||
statEffects_.push_back(
|
||||
{ pair.list[0].text,
|
||||
pair.list[1].number });
|
||||
}
|
||||
} else {
|
||||
return fail(form.list[i], "unknown room option '" +
|
||||
key +
|
||||
"' (only :windows, :type, "
|
||||
":furniture, :compose)");
|
||||
":furniture, :compose, :poi, "
|
||||
":effects)");
|
||||
}
|
||||
}
|
||||
const SExpr &rect = form.list[2];
|
||||
@@ -1611,7 +1848,7 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
|
||||
* room tables; re-declaring a name (e.g. on another floor)
|
||||
* reuses its id and its first type wins. */
|
||||
auto registerRoom = [this, &form, &roomType, &furnitureTags,
|
||||
&compositions]() -> int {
|
||||
&compositions, &statEffects_]() -> int {
|
||||
auto nit = m_roomNameIds.find(form.list[1].text);
|
||||
if (nit != m_roomNameIds.end())
|
||||
return nit->second;
|
||||
@@ -1620,6 +1857,7 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
|
||||
m_plan->roomTypes.push_back(roomType);
|
||||
m_plan->roomFurnitureTags.push_back(furnitureTags);
|
||||
m_plan->roomCompositions.push_back(std::move(compositions));
|
||||
m_plan->roomStatEffects.push_back(statEffects_);
|
||||
m_roomNameIds.emplace(form.list[1].text, id);
|
||||
return id;
|
||||
};
|
||||
@@ -2303,6 +2541,7 @@ bool LayoutEval::evaluate(const std::string &sourceOrName, BuildingPlan &out,
|
||||
m_warnings = warnings;
|
||||
m_rooms.clear();
|
||||
m_roomNameIds.clear();
|
||||
m_roomPois.clear();
|
||||
|
||||
/* Predefined bindings. */
|
||||
std::map<std::string, Value> base;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -338,6 +339,9 @@ private:
|
||||
/* roomIds index for a room name of the current evaluation:
|
||||
* name -> index into BuildingPlan::roomNames. */
|
||||
std::map<std::string, int> m_roomNameIds;
|
||||
/** Rooms that declared :poi entries (first declaration wins,
|
||||
* matching the room-table dedupe discipline). */
|
||||
std::set<std::string> m_roomPois;
|
||||
};
|
||||
|
||||
} /* namespace LayoutLib */
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "LuaEmergencyApi.hpp"
|
||||
|
||||
#include "../systems/CharacterStatStore.hpp"
|
||||
#include "../systems/NpcPopulationSystem.hpp"
|
||||
|
||||
#include <lualib.h>
|
||||
|
||||
namespace editScene {
|
||||
|
||||
namespace {
|
||||
|
||||
/** The registered thresholds (data, from the stats rules module).
|
||||
* healthBelow < 0 = the emergency layer is disabled. */
|
||||
struct Thresholds {
|
||||
double healthBelow = -1.0;
|
||||
double dischargeAt = 100.0;
|
||||
bool registered = false;
|
||||
};
|
||||
|
||||
Thresholds &thresholds()
|
||||
{
|
||||
static Thresholds t;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** The store listener: fires on stat-CHANGE events only (never
|
||||
* per-frame polling). The health emergency triggers when health
|
||||
* crosses below health_below and discharges at discharge_at. */
|
||||
void onStatChanged(uint64_t id, const std::string &name, double newValue)
|
||||
{
|
||||
Thresholds &t = thresholds();
|
||||
if (!t.registered || name != "health")
|
||||
return;
|
||||
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
|
||||
if (!pop)
|
||||
return;
|
||||
const bool active = pop->hasActiveEmergency(id);
|
||||
if (!active && newValue < t.healthBelow)
|
||||
pop->beginEmergency(id, "health-emergency");
|
||||
else if (active && newValue >= t.dischargeAt)
|
||||
pop->endEmergency(id);
|
||||
}
|
||||
|
||||
/** ecs.emergency.set_thresholds{ health_below = n, discharge_at = n
|
||||
* } — registers the data-driven thresholds and the store listener
|
||||
* (once). */
|
||||
int luaSetThresholds(lua_State *L)
|
||||
{
|
||||
Thresholds &t = thresholds();
|
||||
luaL_checktype(L, 1, LUA_TTABLE);
|
||||
lua_getfield(L, 1, "health_below");
|
||||
if (lua_isnumber(L, -1))
|
||||
t.healthBelow = lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, 1, "discharge_at");
|
||||
if (lua_isnumber(L, -1))
|
||||
t.dischargeAt = lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
if (!t.registered && t.healthBelow >= 0.0) {
|
||||
t.registered = true;
|
||||
CharacterStatStore::getInstance().subscribe(
|
||||
[](uint64_t id, const std::string &name,
|
||||
double newValue) {
|
||||
onStatChanged(id, name, newValue);
|
||||
});
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int luaBegin(lua_State *L)
|
||||
{
|
||||
uint64_t id = (uint64_t)luaL_checkinteger(L, 1);
|
||||
const char *reason = luaL_optstring(L, 2, "emergency");
|
||||
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
|
||||
lua_pushboolean(L,
|
||||
pop && pop->beginEmergency(id, reason) ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int luaEnd(lua_State *L)
|
||||
{
|
||||
uint64_t id = (uint64_t)luaL_checkinteger(L, 1);
|
||||
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
|
||||
lua_pushboolean(L, pop && pop->endEmergency(id) ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int luaActive(lua_State *L)
|
||||
{
|
||||
uint64_t id = (uint64_t)luaL_checkinteger(L, 1);
|
||||
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
|
||||
lua_pushboolean(L,
|
||||
pop && pop->hasActiveEmergency(id) ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void registerLuaEmergencyApi(lua_State *L)
|
||||
{
|
||||
lua_getglobal(L, "ecs");
|
||||
if (!lua_istable(L, -1)) {
|
||||
lua_pop(L, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
lua_newtable(L);
|
||||
lua_pushcfunction(L, luaSetThresholds);
|
||||
lua_setfield(L, -2, "set_thresholds");
|
||||
lua_pushcfunction(L, luaBegin);
|
||||
lua_setfield(L, -2, "begin");
|
||||
lua_pushcfunction(L, luaEnd);
|
||||
lua_setfield(L, -2, "end");
|
||||
lua_pushcfunction(L, luaActive);
|
||||
lua_setfield(L, -2, "active");
|
||||
lua_setfield(L, -2, "emergency");
|
||||
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
|
||||
} // namespace editScene
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
extern "C" {
|
||||
#include <lua.h>
|
||||
#include <lauxlib.h>
|
||||
}
|
||||
|
||||
namespace editScene {
|
||||
|
||||
/** Lua-facing emergency layer (phase 6.8.14). The data-driven
|
||||
* thresholds are registered by the stats rules module
|
||||
* (`ecs.emergency.set_thresholds{ health_below = n, discharge_at =
|
||||
* n }`); the engine subscribes to the stat-change events and
|
||||
* triggers/clears emergencies itself — never per-frame polling.
|
||||
* begin/end are also callable directly (scripted emergencies). */
|
||||
|
||||
void registerLuaEmergencyApi(lua_State *L);
|
||||
|
||||
} // namespace editScene
|
||||
@@ -0,0 +1,102 @@
|
||||
#include "LuaRostersApi.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <lauxlib.h>
|
||||
}
|
||||
|
||||
namespace editScene {
|
||||
|
||||
namespace {
|
||||
|
||||
RostersProvider g_provider = nullptr;
|
||||
void *g_user = nullptr;
|
||||
|
||||
/** ecs.rosters.area(areaIndex) -> table or nil
|
||||
*
|
||||
* { "<tag>" = { { id = <registryId>, key = <stableKey>,
|
||||
* home = "<loc>", work = "<loc>" }, ... }, ... }
|
||||
* nil when the area has no weekly build. */
|
||||
int luaArea(lua_State *L)
|
||||
{
|
||||
int areaIndex = (int)luaL_checkinteger(L, 1);
|
||||
if (!g_provider) {
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
TownRosters::Rosters rosters;
|
||||
if (!g_provider(areaIndex, g_user, rosters)) {
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_newtable(L);
|
||||
for (const auto &kv : rosters) {
|
||||
lua_newtable(L);
|
||||
for (size_t i = 0; i < kv.second.size(); ++i) {
|
||||
const TownRosters::RosterMember &m = kv.second[i];
|
||||
lua_newtable(L);
|
||||
lua_pushinteger(L, (lua_Integer)m.registryId);
|
||||
lua_setfield(L, -2, "id");
|
||||
lua_pushstring(L, m.stableKey.c_str());
|
||||
lua_setfield(L, -2, "key");
|
||||
lua_pushstring(L, m.homeLocation.c_str());
|
||||
lua_setfield(L, -2, "home");
|
||||
lua_pushstring(L, m.workLocation.c_str());
|
||||
lua_setfield(L, -2, "work");
|
||||
lua_rawseti(L, -2, (lua_Integer)(i + 1));
|
||||
}
|
||||
lua_setfield(L, -2, kv.first.c_str());
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** ecs.rosters.tags() -> the special job tag list. */
|
||||
int luaTags(lua_State *L)
|
||||
{
|
||||
lua_newtable(L);
|
||||
const std::vector<std::string> &tags =
|
||||
TownRosters::specialJobTags();
|
||||
for (size_t i = 0; i < tags.size(); ++i) {
|
||||
lua_pushstring(L, tags[i].c_str());
|
||||
lua_rawseti(L, -2, (lua_Integer)(i + 1));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** ecs.rosters.is_special(tag) -> bool */
|
||||
int luaIsSpecial(lua_State *L)
|
||||
{
|
||||
const char *tag = luaL_checkstring(L, 1);
|
||||
lua_pushboolean(L,
|
||||
TownRosters::isSpecialJobTag(tag) ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setLuaRostersProvider(RostersProvider provider, void *user)
|
||||
{
|
||||
g_provider = provider;
|
||||
g_user = user;
|
||||
}
|
||||
|
||||
void registerLuaRostersApi(lua_State *L)
|
||||
{
|
||||
lua_getglobal(L, "ecs");
|
||||
if (!lua_istable(L, -1)) {
|
||||
lua_pop(L, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
lua_newtable(L);
|
||||
lua_pushcfunction(L, luaArea);
|
||||
lua_setfield(L, -2, "area");
|
||||
lua_pushcfunction(L, luaTags);
|
||||
lua_setfield(L, -2, "tags");
|
||||
lua_pushcfunction(L, luaIsSpecial);
|
||||
lua_setfield(L, -2, "is_special");
|
||||
lua_setfield(L, -2, "rosters");
|
||||
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
|
||||
} // namespace editScene
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "../roadlib/TownRosters.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <lua.h>
|
||||
}
|
||||
|
||||
namespace editScene {
|
||||
|
||||
/** Lua-facing roster provider: returns the area's rosters
|
||||
* (TownRosters type). The runtime (EditorApp.setup) binds it to
|
||||
* NpcPopulationSystem; the headless Lua smoke test binds a fake
|
||||
* instead. Returns false when the area has no weekly build. */
|
||||
using RostersProvider = bool (*)(int areaIndex, void *user,
|
||||
TownRosters::Rosters &out);
|
||||
|
||||
/** Bind the provider (nullptr clears). `user` flows back to the
|
||||
* callback (the runtime passes the NpcPopulationSystem). */
|
||||
void setLuaRostersProvider(RostersProvider provider, void *user);
|
||||
|
||||
void registerLuaRostersApi(lua_State *L);
|
||||
|
||||
} // namespace editScene
|
||||
@@ -146,6 +146,31 @@ int appendEntries(Set &set, const TownLocations::LocationSet &locations)
|
||||
if (set.add(std::move(rec)))
|
||||
++appended;
|
||||
}
|
||||
|
||||
/* 6.8.8: a building that declares a back/side door gets the
|
||||
* matching employee entry as a SECOND POI, anchored at the
|
||||
* back door (its Work location already anchors there). The
|
||||
* customer (front) entry is unchanged. */
|
||||
for (const TownLocations::NpcLocation &loc : locations.locations) {
|
||||
if (loc.kind != TownLocations::LocationKind::Work ||
|
||||
loc.entranceRole != "back" || loc.address.empty())
|
||||
continue;
|
||||
const std::string id = entryId(loc.address) + ":back";
|
||||
if (set.findById(id) != nullptr)
|
||||
continue;
|
||||
Record rec;
|
||||
rec.id = id;
|
||||
rec.domain = Domain::Exterior;
|
||||
rec.kind = "entrance";
|
||||
rec.name = id;
|
||||
rec.address = loc.address;
|
||||
rec.building = loc.building;
|
||||
rec.position = loc.position;
|
||||
rec.tags = { "entrance" };
|
||||
rec.entranceRole = "back";
|
||||
if (set.add(std::move(rec)))
|
||||
++appended;
|
||||
}
|
||||
return appended;
|
||||
}
|
||||
|
||||
@@ -165,11 +190,23 @@ const Record *entryOfBuilding(const Set &set, int building,
|
||||
return byAddress;
|
||||
}
|
||||
|
||||
const Record *entryOfBuildingForRole(const Set &set,
|
||||
const std::string &role, int building,
|
||||
const std::string &address)
|
||||
{
|
||||
if (role.empty() || role == "front")
|
||||
return entryOfBuilding(set, building, address);
|
||||
const std::string want = entryId(address) + ":" + role;
|
||||
const Record *rec = set.findById(want);
|
||||
return rec ? rec : entryOfBuilding(set, building, address);
|
||||
}
|
||||
|
||||
Ogre::Vector3 approachPosition(const Set &set, const Record &rec)
|
||||
{
|
||||
if (rec.domain != Domain::Interior)
|
||||
return rec.position;
|
||||
const Record *entry = entryOfBuilding(set, rec.building, rec.address);
|
||||
const Record *entry = entryOfBuildingForRole(
|
||||
set, rec.entranceRole, rec.building, rec.address);
|
||||
return entry ? entry->position : rec.position;
|
||||
}
|
||||
|
||||
@@ -210,7 +247,9 @@ TownLocations::LocationSet projectLocations(const Set &set)
|
||||
loc.indoor = rec.domain == Domain::Interior;
|
||||
if (loc.indoor) {
|
||||
const Record *entry =
|
||||
entryOfBuilding(set, rec.building, rec.address);
|
||||
entryOfBuildingForRole(
|
||||
set, rec.entranceRole, rec.building,
|
||||
rec.address);
|
||||
if (entry) {
|
||||
loc.entryPosition = entry->position;
|
||||
loc.hasEntry = true;
|
||||
@@ -221,6 +260,8 @@ TownLocations::LocationSet projectLocations(const Set &set)
|
||||
* LocationSet). */
|
||||
loc.tags = rec.tags;
|
||||
loc.entranceRole = rec.entranceRole;
|
||||
/* 6.8.10: stat effects ride the projection too. */
|
||||
loc.statEffects = rec.statEffects;
|
||||
out.locations.push_back(loc);
|
||||
}
|
||||
return out;
|
||||
@@ -268,6 +309,9 @@ Set feedLocationSet(const TownLocations::LocationSet &locations)
|
||||
std::vector<std::string>{ rec.kind } :
|
||||
loc.tags;
|
||||
rec.entranceRole = loc.entranceRole;
|
||||
/* 6.8.10: the location's stat effects travel with the
|
||||
* record (generated baseline keeps them empty). */
|
||||
rec.statEffects = loc.statEffects;
|
||||
/* Stat effects and activation are left empty for the
|
||||
* generated baseline (richer authoring paths fill
|
||||
* them). */
|
||||
|
||||
@@ -152,6 +152,11 @@ int appendEntries(Set &set, const TownLocations::LocationSet &locations);
|
||||
* then by address; null when the building has no entry. */
|
||||
const Record *entryOfBuilding(const Set &set, int building,
|
||||
const std::string &address);
|
||||
/** 6.8.8: role-aware entry resolution — prefer the declared back/
|
||||
* side entry (employee door) when it exists, else the front entry. */
|
||||
const Record *entryOfBuildingForRole(const Set &set,
|
||||
const std::string &role, int building,
|
||||
const std::string &address);
|
||||
|
||||
/** The approach position of a POI: for an Interior-domain POI the
|
||||
* position of its building's entry POI (falling back to the POI's
|
||||
|
||||
@@ -884,6 +884,83 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms,
|
||||
b.doorstep.y = sampleY(sampleHeight, b.doorstep.x,
|
||||
b.doorstep.z, lot.centroid.y);
|
||||
|
||||
/* 6.8.8: optional back/side door roles. The
|
||||
* front door is always computed above; when the
|
||||
* layout declares a back/side door I record the
|
||||
* matching approach point with the same 2 m
|
||||
* outside clearance — employee (Work) locations
|
||||
* anchor at the back door, customer (Leisure)
|
||||
* locations stay at the front. */
|
||||
{
|
||||
const Ogre::Vector3 axisX(-v.z, 0.0f, v.x);
|
||||
for (int side = 1; side <= 3; ++side) {
|
||||
bool horizontal = side >= 2;
|
||||
int cells = horizontal ?
|
||||
b.plan.cellsZ :
|
||||
b.plan.cellsX;
|
||||
uint64_t flag = side == 1 ?
|
||||
LayoutLib::PlanFlags::
|
||||
DoorZNeg :
|
||||
(side == 2 ?
|
||||
LayoutLib::PlanFlags::
|
||||
DoorXNeg :
|
||||
LayoutLib::PlanFlags::
|
||||
DoorXPos);
|
||||
int idx = -1;
|
||||
for (int c = 0; c < cells; ++c) {
|
||||
int x = horizontal ?
|
||||
(side == 2 ?
|
||||
0 :
|
||||
b.plan.cellsX - 1) :
|
||||
c;
|
||||
int z = horizontal ? c : 0;
|
||||
if (b.plan.at(x, 0, z) & flag) {
|
||||
idx = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx < 0)
|
||||
continue;
|
||||
if (side == 1) {
|
||||
float backOffX =
|
||||
(idx + 0.5f) *
|
||||
b.plan.cellSize -
|
||||
width * 0.5f;
|
||||
b.backDoorstep =
|
||||
center2d +
|
||||
v * (depth * 0.5f +
|
||||
2.0f) +
|
||||
axisX * backOffX;
|
||||
b.backDoorstep.y = sampleY(
|
||||
sampleHeight,
|
||||
b.backDoorstep.x,
|
||||
b.backDoorstep.z,
|
||||
lot.centroid.y);
|
||||
b.hasBackDoor = true;
|
||||
} else {
|
||||
float doorOffZ =
|
||||
(idx + 0.5f) *
|
||||
b.plan.cellSize -
|
||||
depth * 0.5f;
|
||||
b.sideDoorstep =
|
||||
center2d +
|
||||
axisX *
|
||||
((side == 2 ?
|
||||
-1.0f :
|
||||
1.0f) *
|
||||
(width * 0.5f +
|
||||
2.0f)) +
|
||||
v * doorOffZ;
|
||||
b.sideDoorstep.y = sampleY(
|
||||
sampleHeight,
|
||||
b.sideDoorstep.x,
|
||||
b.sideDoorstep.z,
|
||||
lot.centroid.y);
|
||||
b.hasSideDoor = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Address from the frontage street; split siblings
|
||||
* share the number with a letter suffix in
|
||||
* frontage order ("Main Street 12A", "12B"). */
|
||||
|
||||
@@ -137,6 +137,19 @@ struct TownBuilding {
|
||||
* unless the caller fills it).
|
||||
*/
|
||||
Ogre::Vector3 doorstep = Ogre::Vector3::ZERO;
|
||||
/** 6.8.8: optional back/side door roles (declared by the
|
||||
* layout's `(door back|left|right ...)`). The front door stays
|
||||
* `doorstep`. When a back/side door is declared the building
|
||||
* records the matching approach point (2 m outside that door
|
||||
* at ground level); employee (Work) locations anchor at the
|
||||
* back door while customer (Leisure) locations keep the front,
|
||||
* and entry POIs carry the role. hasBackDoor/hasSideDoor are
|
||||
* false for the stock single-door layouts, which therefore
|
||||
* stay bit-identical. */
|
||||
Ogre::Vector3 backDoorstep = Ogre::Vector3::ZERO;
|
||||
Ogre::Vector3 sideDoorstep = Ogre::Vector3::ZERO;
|
||||
bool hasBackDoor = false;
|
||||
bool hasSideDoor = false;
|
||||
/** "<street> <n>" or "Lot <lotIndex+1>". */
|
||||
std::string address;
|
||||
/** Frontage edge (index into RoadGraph::edges) and side
|
||||
|
||||
@@ -53,9 +53,21 @@ bool isIndoorKind(LocationKind kind)
|
||||
* (6.8.2b) will extend or replace them. Grounded in the shipped
|
||||
* layout libraries: corner-shop declares (commercial shop ...),
|
||||
* the built-in district defaults tag houses (house/village/town)
|
||||
* and city towers (tower/center/commercial). */
|
||||
* and city towers (tower/center/commercial). The 6.8.7
|
||||
* special-job tags join the commercial list — a police station
|
||||
* is a workplace, not an Interest point. */
|
||||
static const char *const COMMERCIAL_TAGS[] = {
|
||||
"shop", "commercial", "office", "leisure",
|
||||
"police", "ambulance", "firefighter", "hospital",
|
||||
};
|
||||
|
||||
/* The 6.8.7 special-job mapping: a building carrying the tag maps
|
||||
* onto its service job (checked before the shop/office roles). */
|
||||
static const char *const SPECIAL_JOB_TAGS[] = {
|
||||
"police", "ambulance", "firefighter", "hospital",
|
||||
};
|
||||
static const char *const SPECIAL_JOB_ROLE[] = {
|
||||
"police", "medic", "firefighter", "doctor",
|
||||
};
|
||||
|
||||
bool isCommercialBuilding(const std::vector<std::string> &buildingTags)
|
||||
@@ -67,19 +79,6 @@ bool isCommercialBuilding(const std::vector<std::string> &buildingTags)
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *jobTagForBuilding(const std::vector<std::string> &buildingTags)
|
||||
{
|
||||
for (const std::string &tag : buildingTags) {
|
||||
if (tag == "shop")
|
||||
return "salesman";
|
||||
if (tag == "office")
|
||||
return "clerk";
|
||||
}
|
||||
/* Any other commercial tag (e.g. "commercial" on a city
|
||||
* tower). */
|
||||
return "worker";
|
||||
}
|
||||
|
||||
int LocationSet::homeOfBuilding(int building) const
|
||||
{
|
||||
for (const NpcLocation &loc : locations)
|
||||
@@ -186,6 +185,70 @@ NpcBindings computeBindings(const LocationSet &locs,
|
||||
return b;
|
||||
}
|
||||
|
||||
const char *jobTagForBuilding(const std::vector<std::string> &buildingTags)
|
||||
{
|
||||
for (const std::string &tag : buildingTags) {
|
||||
for (size_t s = 0;
|
||||
s < sizeof(SPECIAL_JOB_TAGS) /
|
||||
sizeof(SPECIAL_JOB_TAGS[0]);
|
||||
++s)
|
||||
if (tag == SPECIAL_JOB_TAGS[s])
|
||||
return SPECIAL_JOB_ROLE[s];
|
||||
}
|
||||
for (const std::string &tag : buildingTags) {
|
||||
if (tag == "shop")
|
||||
return "salesman";
|
||||
if (tag == "office")
|
||||
return "clerk";
|
||||
}
|
||||
/* Any other commercial tag (e.g. "commercial" on a city
|
||||
* tower). */
|
||||
return "worker";
|
||||
}
|
||||
|
||||
/* 6.8.13: which room :type a location kind takes its stat effects
|
||||
* from. Work POIs are commercial (shop/store/office/commercial/
|
||||
* work/shopfront); leisure POIs are leisure venues (park/cafe/bar/
|
||||
* leisure); home is a residence (bedroom/living/livingroom/home/
|
||||
* residence). */
|
||||
static bool roomTypeIn(const std::string &type,
|
||||
const char *const *roles, size_t count)
|
||||
{
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
if (type == roles[i])
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void mergeRoomEffects(
|
||||
const LayoutLib::BuildingPlan &plan,
|
||||
const char *const *roles, size_t count,
|
||||
std::unordered_map<std::string, double> &out)
|
||||
{
|
||||
static const char *const emptyRoles[] = { nullptr };
|
||||
(void)emptyRoles;
|
||||
for (size_t i = 0; i < plan.roomStatEffects.size() &&
|
||||
i < plan.roomTypes.size();
|
||||
++i) {
|
||||
const std::string &type = plan.roomTypes[i];
|
||||
if (!roomTypeIn(type, roles, count))
|
||||
continue;
|
||||
for (const auto &se : plan.roomStatEffects[i])
|
||||
out[se.first] += se.second;
|
||||
}
|
||||
}
|
||||
|
||||
static const char *const WORK_ROOM_TYPES[] = {
|
||||
"shop", "store", "office", "commercial", "work", "shopfront",
|
||||
"storage", "hospital", "clinic", "ward",
|
||||
};
|
||||
static const char *const LEISURE_ROOM_TYPES[] = {
|
||||
"park", "cafe", "bar", "leisure",
|
||||
};
|
||||
static const char *const HOME_ROOM_TYPES[] = {
|
||||
"bedroom", "living", "livingroom", "home", "residence",
|
||||
};
|
||||
|
||||
LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
|
||||
float interestRatio)
|
||||
{
|
||||
@@ -222,6 +285,14 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
|
||||
home.position = b.doorstep != Ogre::Vector3::ZERO ?
|
||||
b.doorstep :
|
||||
b.center;
|
||||
/* 6.8.13: home entries are idle rest, so the home
|
||||
* location may carry a weaker stress-recovery
|
||||
* effect from the residence rooms (bedroom/living/
|
||||
* home). */
|
||||
mergeRoomEffects(b.plan, HOME_ROOM_TYPES,
|
||||
sizeof(HOME_ROOM_TYPES) /
|
||||
sizeof(HOME_ROOM_TYPES[0]),
|
||||
home.statEffects);
|
||||
home.building = (int)bi;
|
||||
add(home);
|
||||
|
||||
@@ -247,12 +318,24 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
|
||||
work.kind = LocationKind::Work;
|
||||
work.name = std::string("work:") + b.address;
|
||||
work.address = b.address;
|
||||
work.position = anchor;
|
||||
/* 6.8.8: employees approach the back door when
|
||||
* the layout declares one; single-door buildings
|
||||
* keep the front (unchanged). */
|
||||
work.position = b.hasBackDoor ?
|
||||
b.backDoorstep :
|
||||
anchor;
|
||||
work.building = (int)bi;
|
||||
work.tags = b.tags;
|
||||
work.tags.push_back(
|
||||
jobTagForBuilding(b.tags));
|
||||
work.entranceRole = "front";
|
||||
work.entranceRole = b.hasBackDoor ? "back" : "front";
|
||||
/* 6.8.13: work POIs carry their stat
|
||||
* effects (money/stress per in-game hour)
|
||||
* from the commercial rooms. */
|
||||
mergeRoomEffects(b.plan, WORK_ROOM_TYPES,
|
||||
sizeof(WORK_ROOM_TYPES) /
|
||||
sizeof(WORK_ROOM_TYPES[0]),
|
||||
work.statEffects);
|
||||
add(work);
|
||||
|
||||
NpcLocation leisure;
|
||||
@@ -264,6 +347,13 @@ LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed,
|
||||
leisure.building = (int)bi;
|
||||
leisure.tags = b.tags;
|
||||
leisure.entranceRole = "front";
|
||||
/* 6.8.13: leisure POIs carry their
|
||||
* stress-relief effects (often for a money
|
||||
* cost) from the leisure rooms. */
|
||||
mergeRoomEffects(b.plan, LEISURE_ROOM_TYPES,
|
||||
sizeof(LEISURE_ROOM_TYPES) /
|
||||
sizeof(LEISURE_ROOM_TYPES[0]),
|
||||
leisure.statEffects);
|
||||
add(leisure);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,14 @@ struct NpcLocation {
|
||||
* locations (generated buildings are single-door).
|
||||
*/
|
||||
std::string entranceRole;
|
||||
|
||||
/** Stat effects offered per in-game hour at this location
|
||||
* (6.8.10). The unified POI record's statEffects travel
|
||||
* through the LocationSet (feed + projection), so a Work or
|
||||
* Leisure POI that earns money or relieves stress is data on
|
||||
* the location the schedule layer chooses. Empty = neutral
|
||||
* (no effects). */
|
||||
std::unordered_map<std::string, double> statEffects;
|
||||
};
|
||||
|
||||
/** The approach endpoint of a location: its entry POI position when
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#include "TownRosters.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace TownRosters {
|
||||
|
||||
const std::vector<std::string> &specialJobTags()
|
||||
{
|
||||
static const std::vector<std::string> tags = {
|
||||
"police", "medic", "firefighter", "doctor"
|
||||
};
|
||||
return tags;
|
||||
}
|
||||
|
||||
bool isSpecialJobTag(const std::string &tag)
|
||||
{
|
||||
for (const std::string &t : specialJobTags())
|
||||
if (tag == t)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Rosters deriveRosters(
|
||||
const std::vector<MemberJob> &members,
|
||||
const std::vector<std::string> &tags)
|
||||
{
|
||||
Rosters out;
|
||||
|
||||
/* Registry-id order (decision 4 discipline): the source
|
||||
* vector's build order is irrelevant. */
|
||||
std::vector<const MemberJob *> ordered;
|
||||
ordered.reserve(members.size());
|
||||
for (const MemberJob &m : members)
|
||||
ordered.push_back(&m);
|
||||
std::sort(ordered.begin(), ordered.end(),
|
||||
[](const MemberJob *a, const MemberJob *b) {
|
||||
return a->registryId < b->registryId;
|
||||
});
|
||||
|
||||
for (const MemberJob *m : ordered) {
|
||||
if (m->tagQuery.rfind("job:", 0) != 0)
|
||||
continue; /* no resolved job tag */
|
||||
const std::string tag = m->tagQuery.substr(4);
|
||||
bool wanted = false;
|
||||
for (const std::string &t : tags)
|
||||
if (tag == t) {
|
||||
wanted = true;
|
||||
break;
|
||||
}
|
||||
if (!wanted)
|
||||
continue;
|
||||
|
||||
RosterMember entry;
|
||||
entry.registryId = m->registryId;
|
||||
entry.stableKey = m->stableKey;
|
||||
entry.homeLocation = m->homeLocation;
|
||||
entry.workLocation = m->workLocation;
|
||||
out[tag].push_back(std::move(entry));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} /* namespace TownRosters */
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* Special-job rosters (demo-taxi phase 6.8.7).
|
||||
*
|
||||
* A population's jobs are already resolved by the weekly schedule
|
||||
* generator (decision 5, weekly-sticky): every working adult's
|
||||
* WeekEntry carries the tag query it answered ("job:<tag>") and
|
||||
* the resolved work location; their home is where the sleep
|
||||
* bookends sit. A roster is the slice of those members whose job
|
||||
* tag belongs to the special set (police, ambulance, ...),
|
||||
* registry-id ordered, exposed for later game systems (patrol
|
||||
* dispatch, emergency pickup, ...). No AI behavior lives here —
|
||||
* this is DATA.
|
||||
*
|
||||
* Pure std; the derivation input is the FLATTENED job list
|
||||
* (MemberJob) so tests and the Lua binding can link this without
|
||||
* the OGRE-bound town headers — the weekly-build adapter lives in
|
||||
* TownRostersWeeks.hpp (linked into the OGRE taring).
|
||||
*/
|
||||
namespace TownRosters {
|
||||
|
||||
/** One rostered member: who (registry id), where they live and
|
||||
* where their special job is (LocationSet names). */
|
||||
struct RosterMember {
|
||||
uint64_t registryId = 0;
|
||||
std::string stableKey;
|
||||
std::string homeLocation;
|
||||
std::string workLocation;
|
||||
};
|
||||
|
||||
/** Special tag -> members, both in registry-id order. */
|
||||
using Rosters = std::map<std::string, std::vector<RosterMember>>;
|
||||
|
||||
/** One member's resolved job summary (the derivation input). */
|
||||
struct MemberJob {
|
||||
uint64_t registryId = 0;
|
||||
std::string stableKey;
|
||||
std::string homeLocation;
|
||||
std::string workLocation;
|
||||
/** The job tag query the weekly build resolved ("job:<tag>",
|
||||
* "" when the member carries no job at all — pulled home by
|
||||
* the 6.8.5 closing rule — or works the tagless binding). */
|
||||
std::string tagQuery;
|
||||
};
|
||||
|
||||
/** The special job tags (the fixed vocabulary; jobTagForBuilding
|
||||
* maps building tags onto it). */
|
||||
const std::vector<std::string> &specialJobTags();
|
||||
|
||||
/** True when the job tag belongs to the special set. */
|
||||
bool isSpecialJobTag(const std::string &tag);
|
||||
|
||||
/** Derive the rosters from the members' job summaries. The member
|
||||
* order follows ascending registry id regardless of the input
|
||||
* order; a member with several "job:" entries (impossible today —
|
||||
* the job is sticky) contributes its first. Tags outside the
|
||||
* special set are ignored, as is the tagless Work binding. */
|
||||
Rosters deriveRosters(
|
||||
const std::vector<MemberJob> &members,
|
||||
const std::vector<std::string> &tags = specialJobTags());
|
||||
|
||||
} /* namespace TownRosters */
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "TownRostersWeeks.hpp"
|
||||
|
||||
namespace TownRosters {
|
||||
|
||||
std::vector<MemberJob> weekJobMembers(
|
||||
const std::vector<TownSchedule::WeekSchedule> &weeks)
|
||||
{
|
||||
std::vector<MemberJob> out;
|
||||
out.reserve(weeks.size());
|
||||
for (const TownSchedule::WeekSchedule &ws : weeks) {
|
||||
MemberJob m;
|
||||
m.registryId = ws.registryId;
|
||||
m.stableKey = ws.stableKey;
|
||||
for (const TownSchedule::WeekEntry &e : ws.entries) {
|
||||
if (m.homeLocation.empty() &&
|
||||
(e.activity == "sleep" || e.activity == "home"))
|
||||
m.homeLocation = e.location;
|
||||
if (e.activity == "work" &&
|
||||
m.tagQuery.empty()) {
|
||||
m.tagQuery = e.tagQuery;
|
||||
m.workLocation = e.location;
|
||||
}
|
||||
}
|
||||
out.push_back(std::move(m));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} /* namespace TownRosters */
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "TownRosters.hpp"
|
||||
#include "TownSchedule.hpp"
|
||||
|
||||
/**
|
||||
* Weekly-build adapter for the roster derivation (phase 6.8.7).
|
||||
* OGRE-bound through TownSchedule.hpp — headless-linkable into the
|
||||
* road geometry probe, while the pure core (TownRosters.hpp) stays
|
||||
* OGRE-free for the Lua binding test.
|
||||
*/
|
||||
namespace TownRosters {
|
||||
|
||||
/** Flatten a weekly build into the derivation input: the member's
|
||||
* home is its first sleep/home entry's location, its job the first
|
||||
* work entry's query/location. Members without work (children,
|
||||
* or the 6.8.5 closing pull) carry an empty tagQuery. */
|
||||
std::vector<MemberJob> weekJobMembers(
|
||||
const std::vector<TownSchedule::WeekSchedule> &weeks);
|
||||
|
||||
} /* namespace TownRosters */
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "TownSchedule.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <random>
|
||||
|
||||
namespace TownSchedule {
|
||||
@@ -175,10 +177,64 @@ WeekIndex buildWeekIndex(const TownLocations::LocationSet &locs)
|
||||
return ix;
|
||||
}
|
||||
|
||||
/* ===== 6.8.5 realism rules ===== */
|
||||
|
||||
/** A stat value with a neutral default (missing key = inert). */
|
||||
double statValue(const StatsSnapshot &snapshot, const std::string &name,
|
||||
double fallback)
|
||||
{
|
||||
auto it = snapshot.find(name);
|
||||
return it == snapshot.end() ? fallback : it->second;
|
||||
}
|
||||
|
||||
/** The location's open window under the rules: the FIRST tag in
|
||||
* the location's tag order that carries a table entry wins; an
|
||||
* unmatched location is open all day. Degenerate table entries
|
||||
* (close <= open) read as closed-but-valid bounds — a slot can
|
||||
* never fit inside them, so such venues self-filter. */
|
||||
std::pair<float, float> openWindowOf(
|
||||
const WeekScheduleRules &rules,
|
||||
const TownLocations::NpcLocation &loc)
|
||||
{
|
||||
for (const std::string &tag : loc.tags) {
|
||||
std::map<std::string,
|
||||
std::pair<float, float> >::const_iterator it =
|
||||
rules.openHoursByTag.find(tag);
|
||||
if (it != rules.openHoursByTag.end())
|
||||
return it->second;
|
||||
}
|
||||
return std::pair<float, float>(0.0f, 24.0f);
|
||||
}
|
||||
|
||||
/** True when the open window covers the whole slot (a schedule
|
||||
* entry may never sit at a closed venue, even in part). */
|
||||
bool openFor(const std::pair<float, float> &win, float slotStart,
|
||||
float slotEnd)
|
||||
{
|
||||
return win.first <= slotStart + 1.0e-4f &&
|
||||
win.second >= slotEnd - 1.0e-4f;
|
||||
}
|
||||
|
||||
/** The resolved soft capacity for a location (tag overrides first;
|
||||
* 0 default rules = unlimited). */
|
||||
int capacityOf(const WeekScheduleRules &rules,
|
||||
const TownLocations::NpcLocation &loc)
|
||||
{
|
||||
for (const std::string &tag : loc.tags) {
|
||||
std::map<std::string, int>::const_iterator it =
|
||||
rules.capacityByTag.find(tag);
|
||||
if (it != rules.capacityByTag.end())
|
||||
return it->second;
|
||||
}
|
||||
return rules.softCapacity;
|
||||
}
|
||||
|
||||
/** Score one candidate: the hook wins, else the pure default
|
||||
* (usage + straight-line home distance + name-hash jitter). */
|
||||
* (usage + straight-line home distance + name-hash jitter). The
|
||||
* rules resolve the candidate's soft capacity (6.8.5). */
|
||||
double scoreCandidate(const ScoreHook &hook,
|
||||
const TownLocations::LocationSet &locs, int idx,
|
||||
const WeekScheduleRules &rules,
|
||||
const TownLocations::NpcLocation *home, int usage,
|
||||
const StatsSnapshot &snapshot)
|
||||
{
|
||||
@@ -187,11 +243,26 @@ double scoreCandidate(const ScoreHook &hook,
|
||||
c.name = loc.name;
|
||||
c.tags = loc.tags;
|
||||
if (home) {
|
||||
float dx = loc.position.x - home->position.x;
|
||||
float dz = loc.position.z - home->position.z;
|
||||
/* 6.8.12: candidate distances are scored against the
|
||||
* APPROACH anchor (the building entry, for interior
|
||||
* destinations) rather than the interior cell — same
|
||||
* value for generated towns (entry == position), and
|
||||
* the employee back-door anchor for 6.8.8. */
|
||||
const Ogre::Vector3 &anchor =
|
||||
TownLocations::approachPosition(loc);
|
||||
float dx = anchor.x - home->position.x;
|
||||
float dz = anchor.z - home->position.z;
|
||||
c.distanceMeters = std::sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
c.usage = usage;
|
||||
int cap = capacityOf(rules, loc);
|
||||
c.capacity = cap > 0 ? (double)cap : 1.0e9;
|
||||
/* 6.8.10: the location's stat effects are visible to the
|
||||
* score hook (poor NPCs prefer money-positive venues,
|
||||
* stressed NPCs stress-relieving ones). The default score
|
||||
* keeps its deterministic no-stats kernel. */
|
||||
for (const auto &se : loc.statEffects)
|
||||
c.effects[se.first] = se.second;
|
||||
if (hook)
|
||||
return hook(c, snapshot);
|
||||
return TownScheduleScore::defaultScore(c, snapshot);
|
||||
@@ -363,13 +434,50 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
randRange(rng, -0.5f, 0.5f)));
|
||||
if (errandLen < 1)
|
||||
errandLen = 1;
|
||||
|
||||
/* 6.8.10 stat-driven weekly variety. The stats map
|
||||
* carries ONLY members with EXPLICIT stat values
|
||||
* (defaults stay inert), so an empty/default stats
|
||||
* snapshot reproduces the 6.8.3 week bit-for-bit.
|
||||
* health < 30 or stress > 85 → a deterministic
|
||||
* full-home rest day; money < 25 → fewer paid outings
|
||||
* and an extra weekend job shift (poor NPCs take
|
||||
* more job blocks); stress > 60 → more leisure. */
|
||||
float errandP = rules.daily.errandProbability;
|
||||
int healthRestDay = -1, stressRestDay = -1;
|
||||
bool extraWeekendJob = false;
|
||||
if (!stats.empty()) {
|
||||
const double health = statValue(snapshot,
|
||||
"health", 100.0);
|
||||
const double money = statValue(snapshot,
|
||||
"money", 100.0);
|
||||
const double stress = statValue(snapshot,
|
||||
"stress", 0.0);
|
||||
if (stress > 60.0)
|
||||
errandP *= 1.2f;
|
||||
if (money < 25.0)
|
||||
errandP *= 0.6f;
|
||||
if (health < 30.0)
|
||||
errandP *= 0.5f;
|
||||
errandP = clampf(errandP, 0.0f, 1.0f);
|
||||
if (health < 30.0)
|
||||
healthRestDay = (int)(fnv1a(stableKey +
|
||||
"|health-rest") %
|
||||
7);
|
||||
if (stress > 85.0)
|
||||
stressRestDay = (int)(fnv1a(stableKey +
|
||||
"|stress-rest") %
|
||||
7);
|
||||
extraWeekendJob = money < 25.0 && adult;
|
||||
}
|
||||
bool errand[7];
|
||||
for (int d = 0; d < 7; ++d)
|
||||
errand[d] = randRange(rng, 0.0f, 1.0f) <
|
||||
rules.daily.errandProbability;
|
||||
errand[d] = randRange(rng, 0.0f, 1.0f) < errandP;
|
||||
|
||||
/* Job resolution (once per member; decision 5 — jobs
|
||||
* are weekly-sticky). */
|
||||
* are weekly-sticky). 6.8.5: only venues open for the
|
||||
* whole work block qualify (unmatched tags are always
|
||||
* open, so tagless towns are unaffected). */
|
||||
std::string jobTag;
|
||||
std::string workName;
|
||||
if (adult) {
|
||||
@@ -389,7 +497,42 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
"used)");
|
||||
pool = ix.works;
|
||||
}
|
||||
if (pool.empty()) {
|
||||
/* The candidate slot: the work block the member
|
||||
* will hold (the pushed entry rounds the end to
|
||||
* a whole hour, so feasibility checks the same
|
||||
* boundary). */
|
||||
float wStart = (float)workStart;
|
||||
float wEnd = wStart + rules.daily.workHours;
|
||||
float
|
||||
slotEnd = std::round(clampf(wEnd, wStart,
|
||||
24.0f));
|
||||
bool closedOut = false;
|
||||
if (!pool.empty()) {
|
||||
std::vector<int> open;
|
||||
for (int idx : pool)
|
||||
if (openFor(
|
||||
openWindowOf(
|
||||
rules,
|
||||
locations
|
||||
.locations
|
||||
[idx]),
|
||||
wStart, slotEnd))
|
||||
open.push_back(idx);
|
||||
if ((int)open.size() < (int)pool.size())
|
||||
closedOut = true;
|
||||
pool = open;
|
||||
}
|
||||
if (pool.empty() && closedOut) {
|
||||
/* 6.8.5 closing pull: every Work venue is
|
||||
* shut for the member's whole work block,
|
||||
* so the workday stays home — no schedule
|
||||
* entry may sit at a closed venue even
|
||||
* after the usual fallbacks failed. */
|
||||
workName = bind.home;
|
||||
warn("week schedule: no Work location is "
|
||||
"open for the work window; workers "
|
||||
"stay home");
|
||||
} else if (pool.empty()) {
|
||||
/* Decision 7: work -> the daily binding
|
||||
* (the Interest placeholder or home). */
|
||||
workName = bind.work;
|
||||
@@ -400,7 +543,8 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
int bestIdx = -1;
|
||||
for (int idx : pool) {
|
||||
double s = scoreCandidate(
|
||||
hook, locations, idx, home,
|
||||
hook, locations, idx,
|
||||
rules, home,
|
||||
usage.get(
|
||||
locations.locations
|
||||
[idx]
|
||||
@@ -453,6 +597,19 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
workdayWake);
|
||||
push(wake, bind.home, "sleep", "", day);
|
||||
|
||||
/* 6.8.10: deterministic full-home rest days
|
||||
* (health < 30, stress > 85) — the rest of the
|
||||
* day is home-idle, no work and no outings;
|
||||
* the week keeps its bookends and tiles. */
|
||||
if (day == healthRestDay || day == stressRestDay) {
|
||||
push((float)sleepHour, bind.home, "home", "",
|
||||
day);
|
||||
push(24.0f, bind.home, "sleep", "", day);
|
||||
if (t < 24.0f && !ws.entries.empty())
|
||||
ws.entries.back().endHour = 24.0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* The day's window: work for working adults, or
|
||||
* the day's outing window. */
|
||||
if (adult && !isWeekend) {
|
||||
@@ -468,9 +625,20 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
} else if (adult) {
|
||||
/* Adult weekend: an afternoon outing
|
||||
* window (the leisure slot lands
|
||||
* inside it). */
|
||||
push((float)weekendWake + 4.0f, bind.home,
|
||||
"home", "", day);
|
||||
* inside it). 6.8.10: poor NPCs take
|
||||
* a weekend job shift instead (extra
|
||||
* job block at the sticky venue). */
|
||||
if (extraWeekendJob) {
|
||||
push((float)weekendWake + 8.0f,
|
||||
workName, "work",
|
||||
jobTag.empty() ?
|
||||
std::string() :
|
||||
"job:" + jobTag,
|
||||
day);
|
||||
} else {
|
||||
push((float)weekendWake + 4.0f, bind.home,
|
||||
"home", "", day);
|
||||
}
|
||||
} else {
|
||||
float outHour =
|
||||
pm->age == "child" ?
|
||||
@@ -503,13 +671,48 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
"(all Leisure used)");
|
||||
pool = ix.leisures;
|
||||
}
|
||||
/* 6.8.5: only venues open for the whole
|
||||
* leisure slot (a visit never sits at a
|
||||
* closed venue; closed-at-slot venue
|
||||
* towns fall through the usual
|
||||
* curb/interest chain). The fallback
|
||||
* chain itself stays unfiltered — curbs
|
||||
* and interests are street-side, never
|
||||
* venue-hours-gated. */
|
||||
if (!pool.empty()) {
|
||||
float slotEnd =
|
||||
t + (float)errandLen;
|
||||
std::vector<int> open;
|
||||
for (int idx : pool)
|
||||
if (openFor(
|
||||
openWindowOf(
|
||||
rules,
|
||||
locations
|
||||
.locations
|
||||
[idx]),
|
||||
t, slotEnd))
|
||||
open.push_back(idx);
|
||||
if ((int)open.size() <
|
||||
(int)pool.size()) {
|
||||
pool = open;
|
||||
if (pool.empty())
|
||||
warn("week "
|
||||
"schedule: "
|
||||
"every Leisure "
|
||||
"location is "
|
||||
"closed at the "
|
||||
"leisure slot; "
|
||||
"fallbacks "
|
||||
"used");
|
||||
}
|
||||
}
|
||||
int leisureIdx = -1;
|
||||
if (!pool.empty()) {
|
||||
double best = -1.0e30;
|
||||
for (int idx : pool) {
|
||||
double s = scoreCandidate(
|
||||
hook, locations,
|
||||
idx, home,
|
||||
idx, rules, home,
|
||||
usage.get(
|
||||
locations
|
||||
.locations
|
||||
@@ -586,9 +789,85 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
return out;
|
||||
}
|
||||
|
||||
const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour)
|
||||
WeekSchedule spliceWeekAfter(const WeekSchedule &stored,
|
||||
const WeekSchedule &fresh,
|
||||
double absoluteHours)
|
||||
{
|
||||
/* Each day's entries tile [0, 24); the exact bucket wins. */
|
||||
WeekSchedule out;
|
||||
out.registryId = stored.registryId;
|
||||
out.stableKey = stored.stableKey;
|
||||
out.weekIndex = fresh.weekIndex;
|
||||
|
||||
const int curDay = (int)std::floor(absoluteHours / 24.0);
|
||||
const float tod = (float)(absoluteHours -
|
||||
(double)curDay * 24.0);
|
||||
|
||||
for (int day = 0; day < 7; ++day) {
|
||||
if (day < curDay) {
|
||||
for (const WeekEntry &e : stored.entries)
|
||||
if (e.day == day)
|
||||
out.entries.push_back(e);
|
||||
continue;
|
||||
}
|
||||
if (day > curDay) {
|
||||
for (const WeekEntry &e : fresh.entries)
|
||||
if (e.day == day)
|
||||
out.entries.push_back(e);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Current day: keep the stored entries up to and
|
||||
* including the in-flight entry (startHour <= tod).
|
||||
* The in-flight entry (startHour < tod < endHour)
|
||||
* is kept WHOLE — in-flight goals finish first. */
|
||||
float boundary = 0.0f;
|
||||
bool haveFlight = false;
|
||||
for (const WeekEntry &e : stored.entries) {
|
||||
if (e.day != day)
|
||||
continue;
|
||||
if (e.startHour <= tod + 1e-4f) {
|
||||
out.entries.push_back(e);
|
||||
if (e.endHour > tod + 1e-4f)
|
||||
haveFlight = true;
|
||||
boundary = e.endHour;
|
||||
}
|
||||
}
|
||||
if (!haveFlight) {
|
||||
/* Nothing in flight (a whole-hour boundary):
|
||||
* the boundary is the next stored start or 24. */
|
||||
boundary = 24.0f;
|
||||
for (const WeekEntry &e : stored.entries)
|
||||
if (e.day == day && e.startHour > tod + 1e-4f) {
|
||||
boundary = std::min(boundary,
|
||||
e.startHour);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const WeekEntry &e : fresh.entries) {
|
||||
if (e.day != day)
|
||||
continue;
|
||||
if (e.startHour >= boundary - 1e-4f)
|
||||
out.entries.push_back(e);
|
||||
}
|
||||
/* The day tiles [0, 24): the last entry reaches 24. */
|
||||
if (!out.entries.empty()) {
|
||||
WeekEntry &last = out.entries.back();
|
||||
if (last.day == day && last.endHour < 24.0f)
|
||||
last.endHour = 24.0f;
|
||||
}
|
||||
}
|
||||
|
||||
std::stable_sort(out.entries.begin(), out.entries.end(),
|
||||
[](const WeekEntry &a, const WeekEntry &b) {
|
||||
if (a.day != b.day)
|
||||
return a.day < b.day;
|
||||
return a.startHour < b.startHour;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour)
|
||||
{ /* Each day's entries tile [0, 24); the exact bucket wins. */
|
||||
float t = hour - 24.0f * std::floor(hour / 24.0f);
|
||||
for (const WeekEntry &e : week.entries) {
|
||||
if (e.day != day)
|
||||
@@ -599,4 +878,26 @@ const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour)
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::array<NpcSchedule, 7> splitWeekToDays(const WeekSchedule &week)
|
||||
{
|
||||
std::array<NpcSchedule, 7> days;
|
||||
for (int d = 0; d < 7; ++d) {
|
||||
days[d].registryId = week.registryId;
|
||||
days[d].stableKey = week.stableKey;
|
||||
}
|
||||
for (const WeekEntry &e : week.entries) {
|
||||
if (e.day < 0 || e.day >= 7)
|
||||
continue;
|
||||
ScheduleEntry s;
|
||||
s.startHour = e.startHour;
|
||||
s.endHour = e.endHour;
|
||||
s.location = e.location;
|
||||
s.activity = e.activity;
|
||||
days[e.day].entries.push_back(s);
|
||||
}
|
||||
/* `week.entries` is already sorted by (day, startHour), so
|
||||
* each view inherits the per-day order. */
|
||||
return days;
|
||||
}
|
||||
|
||||
} /* namespace TownSchedule */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
@@ -145,10 +146,34 @@ struct WeekSchedule {
|
||||
};
|
||||
|
||||
/** Weekly rules: the existing per-day numerics plus the weekend
|
||||
* day indexes (0..6; default {5, 6}). */
|
||||
* day indexes (0..6; default {5, 6}) and the 6.8.5 realism
|
||||
* constraints (opening hours per location tag + soft capacity).
|
||||
* The pure defaults are NO-OPS (always-open venues, unlimited
|
||||
* capacity) so default towns keep their prior weekly schedules
|
||||
* bit-identically; real content opts in per build (the demo's
|
||||
* weekly build passes shop 9-18 + capacity 8). */
|
||||
struct WeekScheduleRules {
|
||||
ScheduleRules daily;
|
||||
std::vector<int> weekendDays = { 5, 6 };
|
||||
|
||||
/** 6.8.5: opening hours per location TAG — a location whose
|
||||
* tags carry a table key (first tag in the location's tag
|
||||
* order wins) is OPEN on [first, second) hours; the
|
||||
* generator filters candidates to venues open for a slot's
|
||||
* full span (schedules never place an NPC at a closed venue
|
||||
* while a legal candidate exists) and pulls workers/visitors
|
||||
* through the usual fallback chains otherwise. Unmatched
|
||||
* locations are always open. Empty = always open. */
|
||||
std::map<std::string, std::pair<float, float> > openHoursByTag;
|
||||
|
||||
/** 6.8.5: soft capacity — reservations past this per-location
|
||||
* pick count sink the candidate's score (one popular venue
|
||||
* does not absorb the whole town; the score stays comparable
|
||||
* so the push is soft, never a hard veto). <= 0 = unlimited;
|
||||
* tag overrides win over the default (same first-tag-order
|
||||
* lookup as the opening hours). */
|
||||
int softCapacity = 0;
|
||||
std::map<std::string, int> capacityByTag;
|
||||
};
|
||||
|
||||
/** Generate the baseline week for every population member.
|
||||
@@ -196,4 +221,26 @@ std::vector<WeekSchedule> generateWeekSchedules(
|
||||
const WeekEntry *weekEntryAt(const WeekSchedule &week, int day,
|
||||
float hour);
|
||||
|
||||
/** Day-shaped views of a week (6.8.4b): per day-of-week one
|
||||
* `NpcSchedule` whose entries are that day's whole-hour tiling of
|
||||
* [0, 24). The runtime consumers (motion nextLocationChange /
|
||||
* traffic claim replay / evalSymbolic) keep their daily-shaped
|
||||
* evaluation path; this is the adapter, so the week's day-of-week
|
||||
* tiling feeds the same `entryAt()` machinery unchanged. Views of
|
||||
* every day of an empty week are empty (single-entry schedules,
|
||||
* never moves). */
|
||||
std::array<NpcSchedule, 7> splitWeekToDays(const WeekSchedule &week);
|
||||
|
||||
/** 6.8.10: merge a stored week with a freshly resolved week at an
|
||||
* absolute game hour. The past (days before the current day and
|
||||
* the stored entries whose start is at or before the clock) stays
|
||||
* IDENTICAL; the current in-flight entry is kept WHOLE (in-flight
|
||||
* goals finish first); only the REST of the week is rewritten from
|
||||
* the fresh build. registryId/stableKey travel from the stored
|
||||
* week, weekIndex from the fresh one. Entries never overlap and
|
||||
* every day tiles [0, 24). */
|
||||
WeekSchedule spliceWeekAfter(const WeekSchedule &stored,
|
||||
const WeekSchedule &fresh,
|
||||
double absoluteHours);
|
||||
|
||||
} /* namespace TownSchedule */
|
||||
|
||||
@@ -7,8 +7,9 @@ namespace TownScheduleScore {
|
||||
double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot,
|
||||
const Weights &weights)
|
||||
{
|
||||
/* Usage and distance push the score down, a name-hash jitter
|
||||
* breaks ties deterministically; the stats snapshot is
|
||||
/* Usage and distance push the score down, over-capacity picks
|
||||
* (6.8.5 soft capacity) push it further down, and a name-hash
|
||||
* jitter breaks ties deterministically; the stats snapshot is
|
||||
* deliberately unused by the default (decision 8: stats are
|
||||
* sampled at week start and only steer via the override hook,
|
||||
* not silently inside the default). */
|
||||
@@ -19,9 +20,12 @@ double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot,
|
||||
h *= 16777619u;
|
||||
}
|
||||
double jitter = (double)h / (double)UINT32_MAX;
|
||||
return -(weights.usage * (double)candidate.usage +
|
||||
weights.distance * candidate.distanceMeters) +
|
||||
weights.jitter * jitter;
|
||||
double score = -(weights.usage * (double)candidate.usage +
|
||||
weights.distance * candidate.distanceMeters);
|
||||
double over = (double)candidate.usage - candidate.capacity;
|
||||
if (over > 0.0)
|
||||
score -= weights.capacity * over;
|
||||
return score + weights.jitter * jitter;
|
||||
}
|
||||
|
||||
} // namespace TownScheduleScore
|
||||
|
||||
@@ -50,6 +50,11 @@ struct Candidate {
|
||||
int usage = 0;
|
||||
/** Stat effects offered per in-game hour (e.g. money=+10). */
|
||||
std::unordered_map<std::string, double> effects;
|
||||
/** Soft capacity (6.8.5): picks past it sink the score, so
|
||||
* one popular location does not absorb the whole town. The
|
||||
* generator resolves the limit from the rules' tag table;
|
||||
* the default (huge) is unlimited. */
|
||||
double capacity = 1.0e9;
|
||||
};
|
||||
|
||||
/** Candidate scoring weights (mirrors
|
||||
@@ -58,9 +63,13 @@ struct Weights {
|
||||
double usage = 1.0;
|
||||
double distance = 0.01;
|
||||
double jitter = 0.001;
|
||||
/** Score penalty per pick above the candidate's soft
|
||||
* capacity (6.8.5). */
|
||||
double capacity = 1.0;
|
||||
};
|
||||
|
||||
/** The pure default: -(usage*w + distance*w) + hashJitter*w. */
|
||||
/** The pure default: -(usage*w + distance*w + overflow*w) +
|
||||
* hashJitter*w. */
|
||||
double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot,
|
||||
const Weights &weights = Weights{});
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
#include <set>
|
||||
|
||||
#include "CharacterRegistry.hpp"
|
||||
#include "CharacterStatStore.hpp"
|
||||
#include "StatEffectsApplier.hpp"
|
||||
|
||||
#include "GameClockSystem.hpp"
|
||||
#include "NpcPopulationSystem.hpp"
|
||||
#include "RoadSystem.hpp"
|
||||
@@ -219,7 +222,15 @@ void NpcMotionSystem::update()
|
||||
m_states.erase(id);
|
||||
continue;
|
||||
}
|
||||
const TownSchedule::NpcSchedule *sched = pop->scheduleOf(id);
|
||||
/* 6.8.4b: the active schedule is the WEEK's day-of-week
|
||||
* tiling when the area carries a weekly build
|
||||
* (day-of-week = absolute day % 7; the day view is a
|
||||
* daily-shaped [0,24) tiling), else the legacy daily
|
||||
* schedule. 6.8.14: an active EMERGENCY supersedes
|
||||
* all of that (the highest-priority goal). */
|
||||
int weekday = (int)std::floor(tabs / 24.0) % 7;
|
||||
const TownSchedule::NpcSchedule *sched =
|
||||
pop->effectiveDayScheduleOf(id, weekday);
|
||||
int areaIdx = pop->areaIndexOf(id);
|
||||
const TownLocations::LocationSet *locs =
|
||||
areaIdx >= 0 ? pop->areaLocations(areaIdx) : nullptr;
|
||||
@@ -245,6 +256,40 @@ void NpcMotionSystem::update()
|
||||
const TownSchedule::ScheduleEntry *active = lc.active;
|
||||
const TownLocations::NpcLocation *cur = lc.from;
|
||||
|
||||
/* 6.8.13: on COMPLETED schedule entries, apply the
|
||||
* location's declared stat effects per in-game hour
|
||||
* spent at it (data on the POI, never the POI
|
||||
* mutating stats itself). Each (npc, location,
|
||||
* startHour) key applies exactly once per run — a
|
||||
* back-scrub never re-applies an already completed
|
||||
* entry, and the completed set is runtime-only. */
|
||||
{
|
||||
auto prev = m_lastEntry.find(id);
|
||||
if (prev != m_lastEntry.end()) {
|
||||
const TownSchedule::ScheduleEntry &p = prev->second;
|
||||
if (p.location != active->location) {
|
||||
const TownLocations::NpcLocation
|
||||
*ploc = locs->findByName(
|
||||
p.location);
|
||||
if (ploc) {
|
||||
EntryKey key{ id, p.location,
|
||||
p.startHour };
|
||||
if (m_completedEntries
|
||||
.insert(key)
|
||||
.second)
|
||||
StatEffectsApplier::
|
||||
applyLocationEffects(
|
||||
CharacterStatStore::
|
||||
getInstance(),
|
||||
id, *ploc,
|
||||
p.endHour -
|
||||
p.startHour);
|
||||
}
|
||||
}
|
||||
}
|
||||
m_lastEntry[id] = *active;
|
||||
}
|
||||
|
||||
if (!lc.next) {
|
||||
/* Single-location schedule: never moves. */
|
||||
if (cur)
|
||||
|
||||
@@ -149,6 +149,25 @@ private:
|
||||
|
||||
flecs::world &m_world;
|
||||
std::map<uint64_t, NpcMotion> m_states;
|
||||
/* 6.8.13: per-NPC last evaluated entry (for completed-entry
|
||||
* stat effects), and the (npc, location, startHour) keys
|
||||
* already applied this run so a back-scrub never
|
||||
* re-applies an entry. */
|
||||
struct EntryKey {
|
||||
uint64_t id = 0;
|
||||
std::string location;
|
||||
float startHour = 0.0f;
|
||||
bool operator<(const EntryKey &other) const
|
||||
{
|
||||
if (id != other.id)
|
||||
return id < other.id;
|
||||
if (location != other.location)
|
||||
return location < other.location;
|
||||
return startHour < other.startHour;
|
||||
}
|
||||
};
|
||||
std::map<uint64_t, TownSchedule::ScheduleEntry> m_lastEntry;
|
||||
std::set<EntryKey> m_completedEntries;
|
||||
/* NPCs driven externally (taxi rides); skipped by update(). */
|
||||
std::set<uint64_t> m_suspended;
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
#include <Ogre.h>
|
||||
|
||||
#include "CharacterStatStore.hpp"
|
||||
#include "CharacterRegistry.hpp"
|
||||
#include "GameClockSystem.hpp"
|
||||
#include "NpcTaxiSystem.hpp"
|
||||
#include "NpcTrafficSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include "CharacterSlotSystem.hpp"
|
||||
#include "RenderOriginSystem.hpp"
|
||||
#include "RoadSystem.hpp"
|
||||
@@ -77,6 +83,20 @@ static const uint32_t kScheduleSeedXor = 0xc2b2ae35u;
|
||||
/* 6.8.4a: the baseline week's seed discipline mirrors the others. */
|
||||
static const uint32_t kWeekScheduleSeedXor = 0x27d4eb2fu;
|
||||
|
||||
/** 6.8.5: the demo town's weekly realism rules — shop venues are
|
||||
* open 9-18 (the plan's worked example; closed-at-slot visits
|
||||
* fall through the curb/interest chain) and picks spread past 8
|
||||
* per location (one popular venue does not absorb the whole
|
||||
* town). Pure-layer defaults stay no-ops; this is the content
|
||||
* opt-in for the demo's baseline week. */
|
||||
static TownSchedule::WeekScheduleRules demoWeeklyRules()
|
||||
{
|
||||
TownSchedule::WeekScheduleRules r;
|
||||
r.openHoursByTag["shop"] = std::make_pair(9.0f, 18.0f);
|
||||
r.softCapacity = 8;
|
||||
return r;
|
||||
}
|
||||
|
||||
NpcPopulationSystem *NpcPopulationSystem::ms_instance = nullptr;
|
||||
|
||||
NpcPopulationSystem::NpcPopulationSystem(flecs::world &world)
|
||||
@@ -297,18 +317,57 @@ int NpcPopulationSystem::generateAreaPopulation(int areaIndex,
|
||||
sc.distanceMeters = c.distanceMeters;
|
||||
sc.usage = c.usage;
|
||||
sc.effects = c.effects;
|
||||
sc.capacity = c.capacity;
|
||||
return ScheduleScoring::score(sc, snap);
|
||||
};
|
||||
std::vector<std::string> weekWarns;
|
||||
std::map<std::string, int> usage;
|
||||
/* 6.8.10: the week-start stats snapshot. Only members
|
||||
* with EXPLICIT stat values are included — missing or
|
||||
* all-default members leave the week's shape exactly
|
||||
* the 6.8.3 baseline (defaults never perturb the
|
||||
* baseline). */
|
||||
std::unordered_map<uint64_t, TownSchedule::StatsSnapshot>
|
||||
weekStats;
|
||||
{
|
||||
CharacterStatStore &stats =
|
||||
CharacterStatStore::getInstance();
|
||||
for (const TownPopulation::PopMember &pm : pop.members) {
|
||||
TownSchedule::StatsSnapshot snap =
|
||||
stats.snapshot(pm.registryId);
|
||||
bool explicitAny = false;
|
||||
for (const auto &kv : snap)
|
||||
if (stats.has(pm.registryId,
|
||||
kv.first)) {
|
||||
explicitAny = true;
|
||||
break;
|
||||
}
|
||||
if (explicitAny)
|
||||
weekStats[pm.registryId] = snap;
|
||||
}
|
||||
ap.weekStats = weekStats;
|
||||
}
|
||||
std::vector<TownSchedule::WeekSchedule> weeks =
|
||||
TownSchedule::generateWeekSchedules(
|
||||
pop, ap.locations, effSeed ^ kWeekScheduleSeedXor,
|
||||
TownSchedule::WeekScheduleRules(), {}, hook,
|
||||
demoWeeklyRules(), weekStats, hook,
|
||||
&weekWarns, &usage);
|
||||
for (const TownSchedule::WeekSchedule &w : weeks)
|
||||
ap.weekSchedules[w.registryId] = w;
|
||||
ap.weekUsage = usage;
|
||||
/* 6.8.10: keep the population for mid-week revisions. */
|
||||
ap.population = pop;
|
||||
/* 6.8.7: the special-job rosters are an OUTPUT of the
|
||||
* weekly build (same determinism contract). */
|
||||
ap.rosters = TownRosters::deriveRosters(
|
||||
TownRosters::weekJobMembers(weeks),
|
||||
TownRosters::specialJobTags());
|
||||
/* 6.8.4b: the per-day DAILY-shaped views the runtime
|
||||
* consumes (motion/traffic keep their entryAt()/
|
||||
* replay paths; the tiling now comes from the week). */
|
||||
for (const TownSchedule::WeekSchedule &w : weeks)
|
||||
ap.weekDayViews[w.registryId] =
|
||||
TownSchedule::splitWeekToDays(w);
|
||||
for (const std::string &w : weekWarns)
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"NpcPopulationSystem[" + area.name +
|
||||
@@ -337,6 +396,19 @@ int NpcPopulationSystem::generateAreaPopulation(int areaIndex,
|
||||
newInterest;
|
||||
}
|
||||
}
|
||||
/* 6.8.10: revision history is part of the weekly store — it
|
||||
* must survive a weekly rebuild (a town graph change is a
|
||||
* fresh baseline, not a reason to forget the recorded
|
||||
* revisions). 6.8.14: the active emergencies survive too.
|
||||
* The weekly store otherwise regenerates as before
|
||||
* (population, locations, schedules, usage). */
|
||||
{
|
||||
auto old = m_areas.find(areaIndex);
|
||||
if (old != m_areas.end()) {
|
||||
ap.revisions = old->second.revisions;
|
||||
ap.emergencies = old->second.emergencies;
|
||||
}
|
||||
}
|
||||
m_areas[areaIndex] = ap;
|
||||
reg.setAutoSaveSuspended(false);
|
||||
|
||||
@@ -435,21 +507,36 @@ void NpcPopulationSystem::update()
|
||||
if (key == m_autoKey && terrain.id() == m_autoTerrain)
|
||||
return;
|
||||
|
||||
if (terrain.id() != m_autoTerrain)
|
||||
if (terrain.id() != m_autoTerrain) {
|
||||
/* 6.8.10: revision history is part of the weekly
|
||||
* store and must survive the clear + regenerate cycle
|
||||
* (the clear replaces the whole AreaPopulation
|
||||
* struct, so preserve per-area revisions before
|
||||
* calling clearAllPopulations). 6.8.14: the active
|
||||
* emergencies survive too (they are live state on
|
||||
* stable registry ids). */
|
||||
std::map<int, std::vector<AreaPopulation::WeekRevision> >
|
||||
savedRevisions;
|
||||
std::map<int,
|
||||
std::unordered_map<uint64_t,
|
||||
AreaPopulation::EmergencyState> >
|
||||
savedEmergencies;
|
||||
for (const auto &kv : m_areas) {
|
||||
savedRevisions[kv.first] = kv.second.revisions;
|
||||
savedEmergencies[kv.first] = kv.second.emergencies;
|
||||
}
|
||||
clearAllPopulations();
|
||||
|
||||
/* While the region store is still merging pages the areas can
|
||||
* exist without their buildings; retry about once a second
|
||||
* instead of latching an empty population or spamming the
|
||||
* log every frame. */
|
||||
if (m_autoRetry > 0) {
|
||||
--m_autoRetry;
|
||||
return;
|
||||
}
|
||||
int n = generateAllAreas(nullptr);
|
||||
if (n == 0) {
|
||||
m_autoRetry = 60;
|
||||
return;
|
||||
int n = generateAllAreas(nullptr);
|
||||
for (const auto &kv : savedRevisions)
|
||||
if (m_areas.count(kv.first))
|
||||
m_areas[kv.first].revisions = kv.second;
|
||||
for (const auto &kv : savedEmergencies)
|
||||
if (m_areas.count(kv.first))
|
||||
m_areas[kv.first].emergencies = kv.second;
|
||||
if (n == 0) {
|
||||
m_autoRetry = 60;
|
||||
return;
|
||||
}
|
||||
}
|
||||
m_autoKey = key;
|
||||
m_autoTerrain = terrain.id();
|
||||
@@ -677,3 +764,423 @@ NpcPopulationSystem::weekUsageOf(int areaIndex) const
|
||||
auto it = m_areas.find(areaIndex);
|
||||
return it == m_areas.end() ? nullptr : &it->second.weekUsage;
|
||||
}
|
||||
|
||||
const TownRosters::Rosters *
|
||||
NpcPopulationSystem::areaRosters(int areaIndex) const
|
||||
{
|
||||
auto it = m_areas.find(areaIndex);
|
||||
return it == m_areas.end() ? nullptr : &it->second.rosters;
|
||||
}
|
||||
|
||||
bool NpcPopulationSystem::reviseWeek(int areaIndex,
|
||||
const std::string &reason,
|
||||
uint32_t graphVersion)
|
||||
{ auto it = m_areas.find(areaIndex);
|
||||
if (it == m_areas.end() || it->second.weekSchedules.empty())
|
||||
return false;
|
||||
AreaPopulation &ap = it->second;
|
||||
|
||||
/* Recompute the effective seed (the area record is stable
|
||||
* data; the population/location contract matches the weekly
|
||||
* build's). */
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (!ts || !ts->getRoadSystem())
|
||||
return false;
|
||||
flecs::entity terrainEnt = ts->getRoadSystem()->getTerrainEntity();
|
||||
if (!terrainEnt.is_alive() ||
|
||||
!terrainEnt.has<TerrainComponent>())
|
||||
return false;
|
||||
const RoadGraph *graph = &terrainEnt.get<TerrainComponent>().roadGraph;
|
||||
if (areaIndex < 0 ||
|
||||
(size_t)areaIndex >= graph->townAreas.size())
|
||||
return false;
|
||||
const uint32_t effSeed = RoadNetworkGen::effectiveAreaSeed(
|
||||
*graph, graph->townAreas[areaIndex]);
|
||||
|
||||
/* The current stats snapshot (explicit only, like the weekly
|
||||
* build). */
|
||||
std::unordered_map<uint64_t, TownSchedule::StatsSnapshot>
|
||||
weekStats;
|
||||
{
|
||||
CharacterStatStore &stats = CharacterStatStore::getInstance();
|
||||
for (const TownPopulation::PopMember &pm : ap.population.members) {
|
||||
TownSchedule::StatsSnapshot snap =
|
||||
stats.snapshot(pm.registryId);
|
||||
bool explicitAny = false;
|
||||
for (const auto &kv : snap)
|
||||
if (stats.has(pm.registryId, kv.first)) {
|
||||
explicitAny = true;
|
||||
break;
|
||||
}
|
||||
if (explicitAny)
|
||||
weekStats[pm.registryId] = snap;
|
||||
}
|
||||
}
|
||||
|
||||
TownSchedule::ScoreHook hook =
|
||||
[](const TownSchedule::WeekCandidate &c,
|
||||
const TownSchedule::StatsSnapshot &snap) {
|
||||
ScheduleScoring::Candidate sc;
|
||||
sc.name = c.name;
|
||||
sc.tags = c.tags;
|
||||
sc.distanceMeters = c.distanceMeters;
|
||||
sc.usage = c.usage;
|
||||
sc.effects = c.effects;
|
||||
sc.capacity = c.capacity;
|
||||
return ScheduleScoring::score(sc, snap);
|
||||
};
|
||||
std::vector<std::string> warns;
|
||||
std::map<std::string, int> usage;
|
||||
std::vector<TownSchedule::WeekSchedule> fresh =
|
||||
TownSchedule::generateWeekSchedules(
|
||||
ap.population, ap.locations,
|
||||
effSeed ^ kWeekScheduleSeedXor, demoWeeklyRules(),
|
||||
weekStats, hook, &warns, &usage);
|
||||
if (fresh.empty())
|
||||
return false;
|
||||
|
||||
const double tabs = GameClockSystem::getInstance()
|
||||
->getAbsoluteHours();
|
||||
|
||||
/* 6.8.10 decision 1: the revision rewrites the REST of the
|
||||
* current week only. Past days and the in-flight entry are
|
||||
* preserved verbatim (in-flight goals complete before the
|
||||
* new schedule takes over; the revision never moves an
|
||||
* NPC's position). */
|
||||
std::unordered_map<uint64_t, TownSchedule::WeekSchedule> freshById;
|
||||
for (const TownSchedule::WeekSchedule &w : fresh)
|
||||
freshById[w.registryId] = w;
|
||||
for (auto &kv : ap.weekSchedules) {
|
||||
auto fi = freshById.find(kv.first);
|
||||
if (fi != freshById.end())
|
||||
kv.second = TownSchedule::spliceWeekAfter(
|
||||
kv.second, fi->second, tabs);
|
||||
}
|
||||
for (auto &kv : ap.weekDayViews)
|
||||
kv.second = TownSchedule::splitWeekToDays(
|
||||
ap.weekSchedules[kv.first]);
|
||||
ap.weekUsage = usage;
|
||||
ap.weekStats = weekStats;
|
||||
|
||||
AreaPopulation::WeekRevision rev;
|
||||
rev.reason = reason;
|
||||
rev.weekIndex = 0;
|
||||
rev.graphVersion = graphVersion;
|
||||
rev.revisionId = (uint32_t)ap.revisions.size();
|
||||
ap.revisions.push_back(rev);
|
||||
return true;
|
||||
}
|
||||
|
||||
const TownSchedule::NpcSchedule *
|
||||
NpcPopulationSystem::weekDayScheduleOf(uint64_t registryId,
|
||||
int day) const
|
||||
{ if (day < 0 || day >= 7)
|
||||
return nullptr;
|
||||
for (const auto &kv : m_areas) {
|
||||
std::unordered_map<
|
||||
uint64_t,
|
||||
std::array<TownSchedule::NpcSchedule, 7> >::
|
||||
const_iterator it =
|
||||
kv.second.weekDayViews.find(registryId);
|
||||
if (it != kv.second.weekDayViews.end())
|
||||
return &it->second[(size_t)day];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool NpcPopulationSystem::beginEmergency(uint64_t registryId,
|
||||
const std::string &reason)
|
||||
{
|
||||
const int areaIndex = areaIndexOf(registryId);
|
||||
if (areaIndex < 0)
|
||||
return false;
|
||||
AreaPopulation &ap = m_areas[areaIndex];
|
||||
if (ap.emergencies.count(registryId))
|
||||
return true; /* already active */
|
||||
|
||||
/* The emergency destination: the town's hospital Work
|
||||
* location (tag "hospital"); a town without one falls back
|
||||
* to the member's home rest, warned once. */
|
||||
std::string hospitalLoc;
|
||||
for (const TownLocations::NpcLocation &loc : ap.locations.locations) {
|
||||
if (loc.kind != TownLocations::LocationKind::Work)
|
||||
continue;
|
||||
for (const std::string &tag : loc.tags)
|
||||
if (tag == "hospital") {
|
||||
hospitalLoc = loc.name;
|
||||
break;
|
||||
}
|
||||
if (!hospitalLoc.empty())
|
||||
break;
|
||||
}
|
||||
if (hospitalLoc.empty()) {
|
||||
CharacterRegistry ® = CharacterRegistry::getSingleton();
|
||||
CharacterRegistry::CharacterRecord *rec =
|
||||
reg.findCharacter(registryId);
|
||||
auto it = rec ? rec->stringColumns.find("npcHome") :
|
||||
rec->stringColumns.end();
|
||||
if (it == rec->stringColumns.end() || it->second.empty())
|
||||
return false;
|
||||
hospitalLoc = "home:" + it->second;
|
||||
static bool warnedNoHospital = false;
|
||||
if (!warnedNoHospital) {
|
||||
warnedNoHospital = true;
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"NpcPopulationSystem: no hospital "
|
||||
"location in town; emergency falls back "
|
||||
"to home rest");
|
||||
}
|
||||
}
|
||||
|
||||
AreaPopulation::EmergencyState em;
|
||||
em.reason = reason;
|
||||
em.location = hospitalLoc;
|
||||
em.startAbs = GameClockSystem::getInstance()->getAbsoluteHours();
|
||||
/* The synthetic schedule: whole-hour entries at the emergency
|
||||
* location, every day (activity "hospital" reads clearly in
|
||||
* F7; the effects application treats it like any entry). */
|
||||
for (int h = 0; h < 24; ++h) {
|
||||
TownSchedule::ScheduleEntry e;
|
||||
e.startHour = (float)h;
|
||||
e.endHour = (float)(h + 1);
|
||||
e.location = hospitalLoc;
|
||||
e.activity = "hospital";
|
||||
em.schedule.entries.push_back(e);
|
||||
}
|
||||
ap.emergencies[registryId] = std::move(em);
|
||||
|
||||
/* The emergency is the highest-priority goal: supersede the
|
||||
* current one immediately (a health emergency does not wait
|
||||
* for the taxi ride to finish). */
|
||||
if (NpcTaxiSystem *taxi = NpcTaxiSystem::getInstance())
|
||||
taxi->cancelGoal(registryId);
|
||||
if (NpcTrafficSystem *traffic = NpcTrafficSystem::getInstance())
|
||||
traffic->despawnLiveCar(registryId);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NpcPopulationSystem::endEmergency(uint64_t registryId)
|
||||
{
|
||||
const int areaIndex = areaIndexOf(registryId);
|
||||
if (areaIndex < 0)
|
||||
return false;
|
||||
return m_areas[areaIndex].emergencies.erase(registryId) > 0;
|
||||
}
|
||||
|
||||
bool NpcPopulationSystem::hasActiveEmergency(uint64_t registryId) const
|
||||
{
|
||||
const int areaIndex = areaIndexOf(registryId);
|
||||
if (areaIndex < 0)
|
||||
return false;
|
||||
auto it = m_areas.find(areaIndex);
|
||||
if (it == m_areas.end())
|
||||
return false;
|
||||
return it->second.emergencies.count(registryId) > 0;
|
||||
}
|
||||
|
||||
const TownSchedule::NpcSchedule *
|
||||
NpcPopulationSystem::emergencyScheduleOf(uint64_t registryId) const
|
||||
{
|
||||
const int areaIndex = areaIndexOf(registryId);
|
||||
if (areaIndex < 0)
|
||||
return nullptr;
|
||||
auto it = m_areas.find(areaIndex);
|
||||
if (it == m_areas.end())
|
||||
return nullptr;
|
||||
auto em = it->second.emergencies.find(registryId);
|
||||
return em == it->second.emergencies.end() ?
|
||||
nullptr :
|
||||
&em->second.schedule;
|
||||
}
|
||||
|
||||
const TownSchedule::NpcSchedule *
|
||||
NpcPopulationSystem::effectiveDayScheduleOf(uint64_t registryId,
|
||||
int day) const
|
||||
{
|
||||
if (const TownSchedule::NpcSchedule *em =
|
||||
emergencyScheduleOf(registryId))
|
||||
return em;
|
||||
if (const TownSchedule::NpcSchedule *wday =
|
||||
weekDayScheduleOf(registryId, day))
|
||||
return wday;
|
||||
return scheduleOf(registryId);
|
||||
}
|
||||
|
||||
nlohmann::json NpcPopulationSystem::serializeWeekSchedules() const
|
||||
{
|
||||
nlohmann::json out = nlohmann::json::object();
|
||||
for (const auto &area : m_areas) {
|
||||
const AreaPopulation &ap = area.second;
|
||||
nlohmann::json areaJson = nlohmann::json::object();
|
||||
nlohmann::json weeks = nlohmann::json::array();
|
||||
for (const auto &kv : ap.weekSchedules) {
|
||||
const TownSchedule::WeekSchedule &w = kv.second;
|
||||
nlohmann::json wj;
|
||||
wj["registryId"] = w.registryId;
|
||||
wj["stableKey"] = w.stableKey;
|
||||
wj["weekIndex"] = w.weekIndex;
|
||||
nlohmann::json entries = nlohmann::json::array();
|
||||
for (const TownSchedule::WeekEntry &e : w.entries) {
|
||||
entries.push_back(
|
||||
{ { "day", e.day },
|
||||
{ "start", e.startHour },
|
||||
{ "end", e.endHour },
|
||||
{ "loc", e.location },
|
||||
{ "act", e.activity },
|
||||
{ "query", e.tagQuery } });
|
||||
}
|
||||
wj["entries"] = std::move(entries);
|
||||
auto si = ap.weekStats.find(w.registryId);
|
||||
if (si != ap.weekStats.end()) {
|
||||
nlohmann::json stats =
|
||||
nlohmann::json::object();
|
||||
for (const auto &kv : si->second)
|
||||
stats[kv.first] = kv.second;
|
||||
wj["stats"] = std::move(stats);
|
||||
}
|
||||
weeks.push_back(std::move(wj));
|
||||
}
|
||||
areaJson["weeks"] = std::move(weeks);
|
||||
nlohmann::json usage = nlohmann::json::object();
|
||||
for (const auto &kv : ap.weekUsage)
|
||||
usage[kv.first] = kv.second;
|
||||
areaJson["usage"] = std::move(usage);
|
||||
nlohmann::json revs = nlohmann::json::array();
|
||||
for (const auto &rev : ap.revisions)
|
||||
revs.push_back({ { "reason", rev.reason },
|
||||
{ "weekIndex", rev.weekIndex },
|
||||
{ "graphVersion",
|
||||
rev.graphVersion },
|
||||
{ "revisionId", rev.revisionId } });
|
||||
areaJson["revisions"] = std::move(revs);
|
||||
nlohmann::json ems = nlohmann::json::array();
|
||||
for (const auto &kv : ap.emergencies)
|
||||
ems.push_back({ { "registryId", kv.first },
|
||||
{ "reason", kv.second.reason },
|
||||
{ "location",
|
||||
kv.second.location },
|
||||
{ "startAbs",
|
||||
kv.second.startAbs } });
|
||||
areaJson["emergencies"] = std::move(ems);
|
||||
out[std::to_string(area.first)] = std::move(areaJson);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void NpcPopulationSystem::deserializeWeekSchedules(
|
||||
const nlohmann::json &j)
|
||||
{
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"deserializeWeekSchedules: areas in store " +
|
||||
std::to_string(m_areas.size()) + ", json areas " +
|
||||
std::to_string(j.size()));
|
||||
for (const auto &area : j.items()) {
|
||||
int areaIndex = std::stoi(area.key());
|
||||
auto it = m_areas.find(areaIndex);
|
||||
if (it == m_areas.end()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"deserializeWeekSchedules: skip area " +
|
||||
std::to_string(areaIndex) +
|
||||
" (not in store)");
|
||||
continue;
|
||||
}
|
||||
AreaPopulation &ap = it->second;
|
||||
const auto &areaJson = area.value();
|
||||
|
||||
ap.weekSchedules.clear();
|
||||
ap.weekDayViews.clear();
|
||||
ap.weekStats.clear();
|
||||
if (areaJson.contains("weeks"))
|
||||
for (const auto &wj : areaJson["weeks"]) {
|
||||
TownSchedule::WeekSchedule w;
|
||||
w.registryId = wj.value("registryId",
|
||||
(uint64_t)0);
|
||||
w.stableKey = wj.value("stableKey",
|
||||
std::string());
|
||||
w.weekIndex = wj.value("weekIndex", 0u);
|
||||
for (const auto &ej : wj["entries"]) {
|
||||
TownSchedule::WeekEntry e;
|
||||
e.day = ej.value("day", 0);
|
||||
e.startHour = ej.value("start", 0.0f);
|
||||
e.endHour = ej.value("end", 0.0f);
|
||||
e.location = ej.value("loc",
|
||||
std::string());
|
||||
e.activity = ej.value("act",
|
||||
std::string());
|
||||
e.tagQuery = ej.value("query",
|
||||
std::string());
|
||||
w.entries.push_back(e);
|
||||
}
|
||||
ap.weekSchedules[w.registryId] = w;
|
||||
ap.weekDayViews[w.registryId] =
|
||||
TownSchedule::splitWeekToDays(w);
|
||||
if (wj.contains("stats")) {
|
||||
TownSchedule::StatsSnapshot snap;
|
||||
for (const auto &el :
|
||||
wj["stats"].items())
|
||||
snap[el.key()] =
|
||||
el.value()
|
||||
.get<double>();
|
||||
ap.weekStats[w.registryId] = snap;
|
||||
}
|
||||
}
|
||||
|
||||
ap.weekUsage.clear();
|
||||
if (areaJson.contains("usage"))
|
||||
for (const auto &el : areaJson["usage"].items())
|
||||
ap.weekUsage[el.key()] =
|
||||
el.value().get<int>();
|
||||
|
||||
ap.revisions.clear();
|
||||
if (areaJson.contains("revisions"))
|
||||
for (const auto &rj : areaJson["revisions"]) {
|
||||
AreaPopulation::WeekRevision rev;
|
||||
rev.reason = rj.value("reason",
|
||||
std::string());
|
||||
rev.weekIndex = rj.value("weekIndex", 0u);
|
||||
rev.graphVersion = rj.value("graphVersion",
|
||||
0u);
|
||||
rev.revisionId = rj.value("revisionId",
|
||||
0u);
|
||||
ap.revisions.push_back(rev);
|
||||
}
|
||||
|
||||
/* 6.8.14: the active emergencies restore with their
|
||||
* synthetic schedules rebuilt from the location. */
|
||||
ap.emergencies.clear();
|
||||
if (areaJson.contains("emergencies"))
|
||||
for (const auto &ej : areaJson["emergencies"]) {
|
||||
uint64_t id = ej.value("registryId",
|
||||
(uint64_t)0);
|
||||
std::string locName =
|
||||
ej.value("location",
|
||||
std::string());
|
||||
if (id == 0 || locName.empty())
|
||||
continue;
|
||||
AreaPopulation::EmergencyState em;
|
||||
em.reason = ej.value("reason",
|
||||
std::string());
|
||||
em.location = locName;
|
||||
em.startAbs = ej.value("startAbs", 0.0);
|
||||
for (int h = 0; h < 24; ++h) {
|
||||
TownSchedule::ScheduleEntry e;
|
||||
e.startHour = (float)h;
|
||||
e.endHour = (float)(h + 1);
|
||||
e.location = locName;
|
||||
e.activity = "hospital";
|
||||
em.schedule.entries.push_back(e);
|
||||
}
|
||||
ap.emergencies[id] = std::move(em);
|
||||
}
|
||||
|
||||
/* The rosters are a deterministic output of the
|
||||
* restored weeks — recompute from the week data. */
|
||||
std::vector<TownSchedule::WeekSchedule> weeks;
|
||||
weeks.reserve(ap.weekSchedules.size());
|
||||
for (const auto &kv : ap.weekSchedules)
|
||||
weeks.push_back(kv.second);
|
||||
ap.rosters = TownRosters::deriveRosters(
|
||||
TownRosters::weekJobMembers(weeks),
|
||||
TownRosters::specialJobTags());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <set>
|
||||
@@ -8,10 +9,13 @@
|
||||
#include <vector>
|
||||
|
||||
#include <flecs.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "../roadlib/TownPopulation.hpp"
|
||||
#include "../roadlib/TownLocations.hpp"
|
||||
#include "../roadlib/TownSchedule.hpp"
|
||||
#include "../roadlib/TownRosters.hpp"
|
||||
#include "../roadlib/TownRostersWeeks.hpp"
|
||||
#include "../roadlib/PoiDatabase.hpp"
|
||||
|
||||
/**
|
||||
@@ -104,11 +108,61 @@ public:
|
||||
* the F7 overlay shows the week. */
|
||||
const TownSchedule::WeekSchedule *weekScheduleOf(
|
||||
uint64_t registryId) const;
|
||||
/** 6.8.4b: the DAILY-shaped view of the NPC's week day
|
||||
* (day-of-week 0..6), nullptr when the area has no weekly
|
||||
* build. Motion/traffic evaluate this object with the same
|
||||
* entryAt()/replay paths as the legacy daily schedule. */
|
||||
const TownSchedule::NpcSchedule *weekDayScheduleOf(
|
||||
uint64_t registryId, int day) const;
|
||||
/** The weekly build's location usage tally (decision 4), null
|
||||
* when the area has no weekly build. Read-only — an output
|
||||
* of the generation, not game state. */
|
||||
const std::map<std::string, int> *weekUsageOf(int areaIndex) const;
|
||||
|
||||
/** 6.8.7: the area's special-job rosters (special tag ->
|
||||
* registry-id-ordered members with home/work bindings),
|
||||
* nullptr when the area has no weekly build. An output of
|
||||
* the weekly build; the F7 overlay lists it and the Lua
|
||||
* binding exposes it. */
|
||||
const TownRosters::Rosters *areaRosters(int areaIndex) const;
|
||||
|
||||
/** 6.8.10: explicit mid-week revision — re-resolve the REST
|
||||
* of the area's week with the CURRENT stats snapshot,
|
||||
* spliced at the current absolute clock so in-flight goals
|
||||
* complete before the new schedule takes over. The revision
|
||||
* is recorded and saved with the game; the pure baseline
|
||||
* resumes from the current clock afterwards. Returns false
|
||||
* when the area has no weekly build. */
|
||||
bool reviseWeek(int areaIndex, const std::string &reason,
|
||||
uint32_t graphVersion);
|
||||
|
||||
/** 6.8.10: serialize the weekly store (weeks, bindings,
|
||||
* usage, the stats snapshot used and the revision markers)
|
||||
* for save/load; old saves without it regenerate from
|
||||
* defaults. */
|
||||
nlohmann::json serializeWeekSchedules() const;
|
||||
void deserializeWeekSchedules(const nlohmann::json &j);
|
||||
|
||||
/** 6.8.14: the emergency layer. A health (or other
|
||||
* stat-threshold) emergency is the HIGHEST-priority goal:
|
||||
* the member's effective schedule becomes the emergency
|
||||
* schedule (whole-hour entries at the hospital, or home
|
||||
* rest when the town has no hospital), the current goal is
|
||||
* cancelled/superseded immediately, and when the threshold
|
||||
* clears the pure baseline simply resumes from the clock.
|
||||
* Emergency state rides save/load. */
|
||||
bool beginEmergency(uint64_t registryId, const std::string &reason);
|
||||
bool endEmergency(uint64_t registryId);
|
||||
bool hasActiveEmergency(uint64_t registryId) const;
|
||||
/** The emergency schedule for a member, nullptr when none. */
|
||||
const TownSchedule::NpcSchedule *emergencyScheduleOf(
|
||||
uint64_t registryId) const;
|
||||
/** The day schedule a consumer should evaluate: the
|
||||
* emergency schedule first, else the weekly day view, else
|
||||
* the legacy daily schedule. */
|
||||
const TownSchedule::NpcSchedule *effectiveDayScheduleOf(
|
||||
uint64_t registryId, int day) const;
|
||||
|
||||
/**
|
||||
* Per-frame update. In game mode the population is generated
|
||||
* automatically once the active terrain's road graph carries
|
||||
@@ -138,9 +192,47 @@ private:
|
||||
* the daily schedules for now (6.8.4b flips them). */
|
||||
std::unordered_map<uint64_t, TownSchedule::WeekSchedule>
|
||||
weekSchedules;
|
||||
/* 6.8.4b: per-day DAILY-shaped views of the week (the
|
||||
* adapter the runtime consumes). Keyed by registry
|
||||
* id; index = day-of-week 0..6. */
|
||||
std::unordered_map<uint64_t,
|
||||
std::array<TownSchedule::NpcSchedule, 7> >
|
||||
weekDayViews;
|
||||
/* The weekly build's location -> pick tally (decision
|
||||
* 4; F7 week pane). */
|
||||
std::map<std::string, int> weekUsage;
|
||||
/* 6.8.10: the week-start stats snapshot the build was
|
||||
* shaped by (only members with explicit values). */
|
||||
std::unordered_map<uint64_t, TownSchedule::StatsSnapshot>
|
||||
weekStats;
|
||||
/** Explicit recorded revision of the REST of the
|
||||
* current week (6.8.10). Forward-looking, never
|
||||
* touches the in-flight goal. Saved with the game. */
|
||||
struct WeekRevision {
|
||||
std::string reason;
|
||||
uint32_t weekIndex = 0;
|
||||
uint32_t graphVersion = 0;
|
||||
uint32_t revisionId = 0;
|
||||
};
|
||||
std::vector<WeekRevision> revisions;
|
||||
/** 6.8.14: active EMERGENCIES per member (the
|
||||
* highest-priority goal overriding the baseline).
|
||||
* The synthetic schedule tiles [0, 24) at the
|
||||
* emergency location so the clock-pure evaluation
|
||||
* "moves" the NPC there symbolically. */
|
||||
struct EmergencyState {
|
||||
std::string reason;
|
||||
std::string location; /* LocationSet name */
|
||||
double startAbs = 0.0;
|
||||
TownSchedule::NpcSchedule schedule;
|
||||
};
|
||||
std::unordered_map<uint64_t, EmergencyState> emergencies;
|
||||
/* The population this area's schedules were built
|
||||
* from — kept for mid-week revisions. */
|
||||
TownPopulation::Population population;
|
||||
/* 6.8.7: the special-job rosters derived from this
|
||||
* area's weekly build (registry-id order). */
|
||||
TownRosters::Rosters rosters;
|
||||
/* Ids merged from authored components (markers/POIs) so
|
||||
* the periodic re-merge can replace exactly those —
|
||||
* runtime-added POIs appear within a second, no full
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "NpcDetailSystem.hpp"
|
||||
#include "NpcMotionSystem.hpp"
|
||||
#include "NpcPopulationSystem.hpp"
|
||||
#include "../roadlib/TownRosters.hpp"
|
||||
#include "NpcTaxiSystem.hpp"
|
||||
#include "NpcTrafficSystem.hpp"
|
||||
|
||||
@@ -219,6 +220,41 @@ void NpcScheduleDebugOverlay::render()
|
||||
ImGui::Text(" %-8s %.1f",
|
||||
kv.first.c_str(), kv.second);
|
||||
}
|
||||
/* Phase 6.8.14: the emergency layer. When the
|
||||
* selected NPC is in an emergency the schedule
|
||||
* table below shows the synthetic hospital/home
|
||||
* tiling; say so LOUDLY here, with the reason
|
||||
* and the live health (the discharge input). */
|
||||
if (pop->hasActiveEmergency(selId)) {
|
||||
const TownSchedule::NpcSchedule *em =
|
||||
pop->emergencyScheduleOf(selId);
|
||||
ImGui::PushStyleColor(
|
||||
ImGuiCol_Text,
|
||||
IM_COL32(220, 80, 80, 255));
|
||||
ImGui::TextUnformatted("EMERGENCY");
|
||||
ImGui::PopStyleColor();
|
||||
if (!em || em->entries.empty())
|
||||
ImGui::TextDisabled(
|
||||
" (schedule pending)");
|
||||
if (em && !em->entries.empty()) {
|
||||
ImGui::Text(" activity: %s",
|
||||
em->entries[0]
|
||||
.activity
|
||||
.c_str());
|
||||
ImGui::Text(" at: %s",
|
||||
em->entries[0]
|
||||
.location
|
||||
.c_str());
|
||||
}
|
||||
auto hit =
|
||||
CharacterStatStore::getInstance()
|
||||
.snapshot(selId);
|
||||
auto hv = hit.find("health");
|
||||
ImGui::Text(" health: %.1f",
|
||||
hv == hit.end() ?
|
||||
0.0 :
|
||||
hv->second);
|
||||
}
|
||||
/* Phase 4.7.5: symbolic motion state + position. */
|
||||
if (motion) {
|
||||
NpcMotionSystem::MotionInfo mi;
|
||||
@@ -528,6 +564,31 @@ void NpcScheduleDebugOverlay::render()
|
||||
.c_str(),
|
||||
top[k].second);
|
||||
}
|
||||
/* 6.8.7: the area's special-job
|
||||
* rosters compactly listed. */
|
||||
const TownRosters::Rosters
|
||||
*rosters =
|
||||
pop->areaRosters(
|
||||
areaIdxW);
|
||||
if (rosters &&
|
||||
!rosters->empty()) {
|
||||
ImGui::TextDisabled(
|
||||
"special rosters:");
|
||||
for (const auto &kv :
|
||||
*rosters)
|
||||
ImGui::TextDisabled(
|
||||
" %s: %d "
|
||||
"member%s",
|
||||
kv.first
|
||||
.c_str(),
|
||||
(int)kv.second
|
||||
.size(),
|
||||
kv.second
|
||||
.size() ==
|
||||
1 ?
|
||||
"" :
|
||||
"s");
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
@@ -140,6 +140,10 @@ bool NpcTaxiSystem::assignTaxiGoal(uint64_t registryId,
|
||||
if (traffic && (traffic->isDriving(registryId) ||
|
||||
traffic->isSeated(registryId)))
|
||||
return false;
|
||||
/* 6.8.14: an active emergency is the highest-priority goal —
|
||||
* scripted taxi goals are refused while it lasts. */
|
||||
if (pop->hasActiveEmergency(registryId))
|
||||
return false;
|
||||
int areaIdx = pop->areaIndexOf(registryId);
|
||||
if (areaIdx < 0 || !findLocation(areaIdx, destLocation))
|
||||
return false;
|
||||
|
||||
@@ -298,11 +298,17 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
|
||||
}
|
||||
std::vector<int> curbs =
|
||||
locs.indicesOfKind(TownLocations::LocationKind::Curb);
|
||||
float capSq = parkFallbackRadiusM > 0.0f ?
|
||||
parkFallbackRadiusM *
|
||||
parkFallbackRadiusM :
|
||||
FLT_MAX;
|
||||
float bestD = FLT_MAX;
|
||||
for (int i : bays) {
|
||||
const TownLocations::NpcLocation &p =
|
||||
locs.locations[i];
|
||||
float d = (p.position - loc->position).squaredLength();
|
||||
if (d > capSq)
|
||||
continue;
|
||||
if (d < bestD - 1e-4f) {
|
||||
bestD = d;
|
||||
out.loc = &p;
|
||||
@@ -315,6 +321,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
|
||||
locs.locations[i];
|
||||
float d = (p.position - loc->position)
|
||||
.squaredLength();
|
||||
if (d > capSq)
|
||||
continue;
|
||||
if (d < bestD - 1e-4f) {
|
||||
bestD = d;
|
||||
out.loc = &p;
|
||||
@@ -329,6 +337,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
|
||||
float d = (p.position -
|
||||
loc->position)
|
||||
.squaredLength();
|
||||
if (d > capSq)
|
||||
continue;
|
||||
if (d < bestD - 1e-4f) {
|
||||
bestD = d;
|
||||
out.loc = &p;
|
||||
@@ -341,7 +351,12 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
|
||||
|
||||
/* 6.6.3: nearest FREE Parking slot first ... overflow spots
|
||||
* ("parking-ovf") come later: they are the driveway-mouth
|
||||
* reserve, not designated spaces (6.6.6). */
|
||||
* reserve, not designated spaces (6.6.6). 6.8.5: the tiers
|
||||
* stay within the venue's fallback radius (a full frontage
|
||||
* falls to the venue's own curb, not another street's bay). */
|
||||
float capSq = parkFallbackRadiusM > 0.0f ?
|
||||
parkFallbackRadiusM * parkFallbackRadiusM :
|
||||
FLT_MAX;
|
||||
float bestD = FLT_MAX;
|
||||
for (int i :
|
||||
locs.indicesOfKind(TownLocations::LocationKind::Parking)) {
|
||||
@@ -354,6 +369,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
|
||||
if (!slotReachable(car.areaIdx, p, graph))
|
||||
continue;
|
||||
float d = (p.position - loc->position).squaredLength();
|
||||
if (d > capSq)
|
||||
continue;
|
||||
if (d < bestD - 1e-4f) {
|
||||
bestD = d;
|
||||
out.loc = &p;
|
||||
@@ -376,6 +393,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
|
||||
if (!slotReachable(car.areaIdx, p, graph))
|
||||
continue;
|
||||
float d = (p.position - loc->position).squaredLength();
|
||||
if (d > capSq)
|
||||
continue;
|
||||
if (d < bestD - 1e-4f) {
|
||||
bestD = d;
|
||||
out.loc = &p;
|
||||
@@ -393,6 +412,8 @@ NpcTrafficSystem::SlotPick NpcTrafficSystem::parkingFor(
|
||||
if (k >= curbChainMaxSpots)
|
||||
continue;
|
||||
float d = (p.position - loc->position).squaredLength();
|
||||
if (d > capSq)
|
||||
continue;
|
||||
if (d < bestD - 1e-4f) {
|
||||
bestD = d;
|
||||
out.loc = &p;
|
||||
@@ -450,8 +471,12 @@ void NpcTrafficSystem::rebuildClaims(double tabs, float tod,
|
||||
std::vector<ClaimEv> evs;
|
||||
for (auto &kv : m_cars) {
|
||||
TrafficCar &car = kv.second;
|
||||
/* 6.8.4b: the day-arc is the week's day-of-week tile
|
||||
* when the area has a weekly build; 6.8.14: an active
|
||||
* emergency supersedes it. */
|
||||
int weekday = (int)std::floor(dayStart / 24.0) % 7;
|
||||
const TownSchedule::NpcSchedule *sched =
|
||||
pop->scheduleOf(car.driverId);
|
||||
pop->effectiveDayScheduleOf(car.driverId, weekday);
|
||||
const TownLocations::LocationSet *locs =
|
||||
pop->areaLocations(car.areaIdx);
|
||||
if (!sched || !locs || car.homeLocation.empty() ||
|
||||
@@ -693,6 +718,16 @@ const NpcTrafficSystem::LegRoute *NpcTrafficSystem::legRoute(
|
||||
return ins.first->second.unusable ? nullptr : &ins.first->second;
|
||||
}
|
||||
|
||||
bool NpcTrafficSystem::despawnLiveCar(uint64_t driverId)
|
||||
{
|
||||
auto it = m_cars.find(driverId);
|
||||
if (it == m_cars.end() || !it->second.highDetail)
|
||||
return false;
|
||||
despawnHighDetail(it->second);
|
||||
it->second.dwell = 0.0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NpcTrafficSystem::setSuspended(TrafficCar &car, bool susp)
|
||||
{
|
||||
if (car.suspended == susp)
|
||||
@@ -709,8 +744,13 @@ void NpcTrafficSystem::evalSymbolic(TrafficCar &car, double tabs, float tod,
|
||||
NpcPopulationSystem *pop = NpcPopulationSystem::getInstance();
|
||||
NpcMotionSystem *motion = NpcMotionSystem::getInstance();
|
||||
CharacterRegistry ® = CharacterRegistry::getSingleton();
|
||||
/* 6.8.4b: the day-arc is the week's day-of-week tile when the
|
||||
* area has a weekly build; 6.8.14: an active emergency
|
||||
* supersedes it. */
|
||||
int weekday = (int)std::floor(tabs / 24.0) % 7;
|
||||
const TownSchedule::NpcSchedule *sched =
|
||||
pop ? pop->scheduleOf(car.driverId) : nullptr;
|
||||
pop ? pop->effectiveDayScheduleOf(car.driverId, weekday) :
|
||||
nullptr;
|
||||
const TownLocations::LocationSet *locs =
|
||||
pop ? pop->areaLocations(car.areaIdx) : nullptr;
|
||||
CharacterRegistry::CharacterRecord *rec =
|
||||
|
||||
@@ -158,6 +158,11 @@ public:
|
||||
{
|
||||
return m_cars.size();
|
||||
}
|
||||
/** 6.8.14: despawn a car's live tier (emergency supersede —
|
||||
* the symbolic layer stays the ground truth and the next
|
||||
* evaluation re-syncs the car). False when the car isn't
|
||||
* high-detail. */
|
||||
bool despawnLiveCar(uint64_t driverId);
|
||||
/** Cars currently in the high-detail tier (benchmark telemetry). */
|
||||
size_t activeVehicleCount() const
|
||||
{
|
||||
@@ -255,6 +260,13 @@ public:
|
||||
int maxActiveVehicles = 8;
|
||||
/** Driving speed (m/s); also the symbolic tier's advance rate. */
|
||||
float cruiseSpeed = 11.0f;
|
||||
/** 6.8.5: claim-time fallback radius — a visitor slot pick
|
||||
* (free bays / overflow / curb chain) prefers spots within
|
||||
* this distance of the venue; farther candidates are
|
||||
* rejected so a full frontage falls to the venue's own curb
|
||||
* instead of routing a 140-370 m detour to another street's
|
||||
* bay. 0 = unlimited (the pre-6.8.5 behavior). */
|
||||
float parkFallbackRadiusM = 80.0f;
|
||||
/** Walk-vs-drive threshold (meters, straight-line): longer legs
|
||||
* are driven when the car is with the driver (phase 6.A). */
|
||||
float maxWalkDistanceM = 300.0f;
|
||||
|
||||
@@ -2651,7 +2651,9 @@ nlohmann::json SceneSerializer::serializeCharacterSpawner(flecs::entity entity)
|
||||
auto &spawner = entity.get<CharacterSpawnerComponent>();
|
||||
nlohmann::json json;
|
||||
json["registryId"] = spawner.registryId;
|
||||
json["spawnDistance"] = std::sqrt(spawner.spawnDistanceSq);
|
||||
json["spawnDistance"] = spawner.spawnDistanceSq < 0.0f ?
|
||||
-1.0f :
|
||||
std::sqrt(spawner.spawnDistanceSq);
|
||||
json["despawnDistance"] = std::sqrt(spawner.despawnDistanceSq);
|
||||
return json;
|
||||
}
|
||||
@@ -2663,7 +2665,13 @@ void SceneSerializer::deserializeCharacterSpawner(flecs::entity entity,
|
||||
spawner.registryId = json.value("registryId", 0);
|
||||
float spawnDist = json.value("spawnDistance", 100.0f);
|
||||
float despawnDist = json.value("despawnDistance", 200.0f);
|
||||
spawner.spawnDistanceSq = spawnDist * spawnDist;
|
||||
/* The sentinel (-1) must round-trip exactly: high-detail
|
||||
* spawners use spawnDistanceSq = -1 to disable distance
|
||||
* gating; a naive sqrt(-1) writes JSON null and the reload
|
||||
* then throws type_error. */
|
||||
spawner.spawnDistanceSq = spawnDist < 0.0f ?
|
||||
spawnDist :
|
||||
spawnDist * spawnDist;
|
||||
spawner.despawnDistanceSq = despawnDist * despawnDist;
|
||||
entity.set<CharacterSpawnerComponent>(spawner);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot,
|
||||
wc.distanceMeters = candidate.distanceMeters;
|
||||
wc.usage = candidate.usage;
|
||||
wc.effects = candidate.effects;
|
||||
wc.capacity = candidate.capacity;
|
||||
TownScheduleScore::Weights ww;
|
||||
ww.usage = weights.usage;
|
||||
ww.distance = weights.distance;
|
||||
ww.jitter = weights.jitter;
|
||||
ww.capacity = weights.capacity;
|
||||
return TownScheduleScore::defaultScore(wc, snapshot, ww);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,15 +42,21 @@ struct Candidate {
|
||||
int usage = 0;
|
||||
/** Stat effects offered per in-game hour (e.g. money=+10). */
|
||||
StatsSnapshot effects;
|
||||
/** Soft capacity (6.8.5): picks past it sink the score; huge
|
||||
* default = unlimited. */
|
||||
double capacity = 1.0e9;
|
||||
};
|
||||
|
||||
struct Weights {
|
||||
double usage = 1.0;
|
||||
double distance = 0.01;
|
||||
double jitter = 0.001;
|
||||
/** Score penalty per pick above the soft capacity (6.8.5). */
|
||||
double capacity = 1.0;
|
||||
};
|
||||
|
||||
/** The pure default: -(usage*w + distance*w) + hashJitter*w. */
|
||||
/** The pure default: -(usage*w + distance*w + overflow*w) +
|
||||
* hashJitter*w. */
|
||||
double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot,
|
||||
const Weights &weights = Weights{});
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#include "StatEffectsApplier.hpp"
|
||||
|
||||
namespace StatEffectsApplier {
|
||||
|
||||
void applyLocationEffects(CharacterStatStore &store,
|
||||
uint64_t registryId,
|
||||
const TownLocations::NpcLocation &loc,
|
||||
float hours)
|
||||
{
|
||||
if (hours <= 0.0f || loc.statEffects.empty())
|
||||
return;
|
||||
for (const auto &kv : loc.statEffects) {
|
||||
if (kv.second != 0.0)
|
||||
store.add(registryId, kv.first,
|
||||
kv.second * (double)hours);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace StatEffectsApplier
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "CharacterStatStore.hpp"
|
||||
#include "../roadlib/TownLocations.hpp"
|
||||
|
||||
/**
|
||||
* Apply a location's declared stat effects on a COMPLETED schedule
|
||||
* entry (phase 6.8.13). Effects are data on the POI (NpcLocation
|
||||
* ::statEffects) and are applied per in-game hour the NPC spent at
|
||||
* the entry, exactly once per completed entry — the POI itself
|
||||
* never mutates stats.
|
||||
*
|
||||
* The home entry is idle rest: a home POI with a weaker
|
||||
* stress-recovery effect applies the same way (no activation and
|
||||
* no behavior trees are required). Job POIs pay money and raise
|
||||
* stress, leisure POIs relieve stress (often for a money cost).
|
||||
*/
|
||||
namespace StatEffectsApplier {
|
||||
|
||||
void applyLocationEffects(CharacterStatStore &store,
|
||||
uint64_t registryId,
|
||||
const TownLocations::NpcLocation &loc,
|
||||
float hours);
|
||||
|
||||
} // namespace StatEffectsApplier
|
||||
@@ -19,12 +19,15 @@
|
||||
#include "../components/RoadGraph.hpp"
|
||||
#include "../physics/physics.h"
|
||||
#include "../roadlib/RoadNetworkGen.hpp"
|
||||
#include "../components/Poi.hpp"
|
||||
#include "../components/RuntimeMarker.hpp"
|
||||
#include "../roadlib/RoadPath.hpp"
|
||||
|
||||
#include <OgreRTShaderSystem.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cfloat>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
@@ -1242,6 +1245,124 @@ void TownGenSystem::createPlanRooms(flecs::entity building,
|
||||
}
|
||||
}
|
||||
|
||||
void TownGenSystem::createPlanPois(flecs::entity building,
|
||||
const TownGen::TownBuilding &b,
|
||||
const CellGridComponent &grid)
|
||||
{
|
||||
const LayoutLib::BuildingPlan &plan = b.plan;
|
||||
if (plan.pois.empty())
|
||||
return;
|
||||
const std::vector<LayoutLib::PlanRoomBounds> bounds =
|
||||
LayoutLib::planRoomBounds(plan);
|
||||
|
||||
for (const LayoutLib::PlanPoi &pd : plan.pois) {
|
||||
/* The POI exists on the room instance whose floor
|
||||
* matches (the first instance when the decl left
|
||||
* :level at -1). */
|
||||
const LayoutLib::PlanRoomBounds *rb = nullptr;
|
||||
for (const LayoutLib::PlanRoomBounds &r : bounds)
|
||||
if (plan.roomNames[r.roomId] == pd.room &&
|
||||
(pd.level < 0 || pd.level == r.floor)) {
|
||||
rb = &r;
|
||||
break;
|
||||
}
|
||||
if (!rb) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"TownGenSystem: POI '" + pd.name +
|
||||
"' in room '" + pd.room +
|
||||
"' has no matching room instance (floor " +
|
||||
std::to_string(pd.level < 0 ? 0 : pd.level) +
|
||||
"); skipped");
|
||||
continue;
|
||||
}
|
||||
|
||||
/* The position: footprint fractions of the SHELL
|
||||
* (defaults to the owning room's rect center), snapped
|
||||
* into the room's rect, at the cell center of the
|
||||
* POI's floor. The building node's local space is
|
||||
* the cell grid (cellToWorld: x*cellSize, floors at
|
||||
* y*cellHeight). */
|
||||
int cx = pd.atX >= 0.0f ?
|
||||
std::clamp((int)std::lround(
|
||||
(double)pd.atX *
|
||||
plan.cellsX),
|
||||
rb->minX, rb->maxX) :
|
||||
(rb->minX + rb->maxX) / 2;
|
||||
int cz = pd.atZ >= 0.0f ?
|
||||
std::clamp((int)std::lround(
|
||||
(double)pd.atZ *
|
||||
plan.cellsZ),
|
||||
rb->minZ, rb->maxZ) :
|
||||
(rb->minZ + rb->maxZ) / 2;
|
||||
|
||||
flecs::entity poiEnt = m_world.entity();
|
||||
poiEnt.child_of(building);
|
||||
const std::string &roomName =
|
||||
plan.roomNames[rb->roomId];
|
||||
poiEnt.set<EntityNameComponent>(EntityNameComponent(
|
||||
"poi " + pd.name + " " + roomName +
|
||||
(rb->floor > 0 ?
|
||||
" f" + std::to_string(rb->floor) :
|
||||
"")));
|
||||
poiEnt.add<RuntimeMarkerComponent>();
|
||||
|
||||
/* The entity parents to the building (rebuild lifetime,
|
||||
* town streaming), but its scene node hangs at the ROOT
|
||||
* at the derived world-render position: the proximity
|
||||
* scan and the POI database merge read node positions
|
||||
* verbatim, and a node parented to the building would
|
||||
* report the local grid offset instead. */
|
||||
TransformComponent xform;
|
||||
xform.node = m_sceneMgr->getRootSceneNode()
|
||||
->createChildSceneNode();
|
||||
Ogre::Vector3 local((cx + 0.5f) * grid.cellSize,
|
||||
rb->floor * grid.cellHeight + 0.05f,
|
||||
(cz + 0.5f) * grid.cellSize);
|
||||
Ogre::Vector3 shellCenter(
|
||||
(grid.width - 1) * grid.cellSize * 0.5f,
|
||||
0.0f,
|
||||
(grid.depth - 1) * grid.cellSize * 0.5f);
|
||||
Ogre::Quaternion yaw(Ogre::Radian(b.yaw),
|
||||
Ogre::Vector3::UNIT_Y);
|
||||
Ogre::Vector3 renderPos =
|
||||
(b.center - yaw * shellCenter) + yaw * local;
|
||||
xform.position = renderPos;
|
||||
xform.rotation = Ogre::Quaternion::IDENTITY;
|
||||
xform.scale = Ogre::Vector3::UNIT_SCALE;
|
||||
TerrainSystem *ts2 = m_terrainSystem ? m_terrainSystem :
|
||||
TerrainSystem::getInstance();
|
||||
if (ts2) {
|
||||
xform.worldX = ts2->renderToWorldX(renderPos.x);
|
||||
xform.worldY = ts2->renderToWorldY(renderPos.y);
|
||||
xform.worldZ = ts2->renderToWorldZ(renderPos.z);
|
||||
xform.hasWorldPosition = true;
|
||||
}
|
||||
xform.applyToNode();
|
||||
poiEnt.set<TransformComponent>(xform);
|
||||
|
||||
PoiComponent pc;
|
||||
pc.poiId = "poi." + grid.gridUid + "." + roomName +
|
||||
(rb->floor > 0 ?
|
||||
"-f" + std::to_string(rb->floor) :
|
||||
"") +
|
||||
"." + pd.name;
|
||||
pc.kind = pd.kind;
|
||||
pc.tags = pd.tags;
|
||||
pc.entranceRole = pd.entranceRole;
|
||||
pc.openHour = pd.openHour;
|
||||
pc.closeHour = pd.closeHour;
|
||||
for (const auto &se : pd.statEffects)
|
||||
pc.statEffects[se.first] = se.second;
|
||||
pc.activationKind = pd.activation;
|
||||
pc.behaviorTree = pd.behaviorTree;
|
||||
pc.luaScript = pd.luaScript;
|
||||
pc.radius = pd.radius;
|
||||
pc.cooldownSeconds = pd.cooldownSeconds;
|
||||
pc.once = pd.once;
|
||||
poiEnt.set<PoiComponent>(pc);
|
||||
}
|
||||
}
|
||||
|
||||
void TownGenSystem::createBuilding(flecs::entity container,
|
||||
flecs::entity material, flecs::entity texture,
|
||||
const TownGen::TownBuilding &b,
|
||||
@@ -1303,6 +1424,12 @@ void TownGenSystem::createBuilding(flecs::entity container,
|
||||
if (genMode != "exteriorOnly")
|
||||
createPlanRooms(building, b, building.get<CellGridComponent>());
|
||||
|
||||
/* Phase 6.8.6: the plan's DSL-authored interior POIs become
|
||||
* runtime PoiComponent children (interior-aware grids only —
|
||||
* an exteriorOnly shell has no rooms to put them in). */
|
||||
if (genMode != "exteriorOnly")
|
||||
createPlanPois(building, b, building.get<CellGridComponent>());
|
||||
|
||||
/* Roofs from the layout plan; when the plan defines none (the
|
||||
* district defaults don't), fall back to the edge-side roofType
|
||||
* override or a flat slab over the whole footprint.
|
||||
|
||||
@@ -228,6 +228,16 @@ private:
|
||||
const TownGen::TownBuilding &b,
|
||||
const CellGridComponent &grid);
|
||||
|
||||
/** Materialize the plan's DSL-authored POIs (6.8.6) as runtime
|
||||
* PoiComponent children of the building (interior-aware grids
|
||||
* only). Ids are `poi.<gridUid>.<roomName>[-fN].<name>`, so a
|
||||
* rebuild keeps them; the mechanical side (E prompt, proximity
|
||||
* firing, the POI database merge) is the shared PoiComponent
|
||||
* machinery. */
|
||||
void createPlanPois(flecs::entity building,
|
||||
const TownGen::TownBuilding &b,
|
||||
const CellGridComponent &grid);
|
||||
|
||||
/** Full synchronous teardown of a generated entity and its
|
||||
* children (regen/clear used to bare-destruct, leaking physics
|
||||
* bodies, TriangleBuffer meshes and scene nodes — the "remains
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @file rosters_lua_test.cpp
|
||||
* @brief Headless smoke case for the phase 6.8.7 roster Lua binding.
|
||||
*
|
||||
* Links TownRosters (the pure derivation) + the Lua binding only —
|
||||
* no OGRE, no flecs. The provider pattern is exercised with a fake
|
||||
* area table built from a synthetic weekly build, mirroring what
|
||||
* NpcPopulationSystem exposes at runtime.
|
||||
*/
|
||||
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "../lua/LuaRostersApi.hpp"
|
||||
#include "../roadlib/TownRosters.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <lua.h>
|
||||
#include <lauxlib.h>
|
||||
#include <lualib.h>
|
||||
}
|
||||
|
||||
static int testCount = 0;
|
||||
static int passCount = 0;
|
||||
|
||||
#define TEST(name) \
|
||||
do { \
|
||||
testCount++; \
|
||||
printf(" TEST %d: %s ... ", testCount, name); \
|
||||
} while (0)
|
||||
|
||||
#define PASS() \
|
||||
do { \
|
||||
passCount++; \
|
||||
printf("PASS\n"); \
|
||||
} while (0)
|
||||
|
||||
#define FAIL(msg) \
|
||||
do { \
|
||||
printf("FAIL: %s\n", std::string(msg).c_str()); \
|
||||
return 1; \
|
||||
} while (0)
|
||||
|
||||
static TownRosters::Rosters g_fake;
|
||||
|
||||
static bool fakeProvide(int areaIndex, void *,
|
||||
TownRosters::Rosters &out)
|
||||
{
|
||||
if (areaIndex != 1 || g_fake.empty())
|
||||
return false;
|
||||
out = g_fake;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool runScript(lua_State *L, const char *script)
|
||||
{
|
||||
if (luaL_dostring(L, script) != LUA_OK) {
|
||||
const char *err = lua_tostring(L, -1);
|
||||
printf("LUA ERROR: %s\n", err ? err : "unknown");
|
||||
lua_pop(L, 1);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The fixture: hand-built member summaries standing in for a
|
||||
* weekly build (the pure derivation core is the same call the
|
||||
* runtime makes; the week-flatten adapter is probed in
|
||||
* road_geometry_overlap_test). */
|
||||
static void buildFake()
|
||||
{
|
||||
std::vector<TownRosters::MemberJob> members;
|
||||
|
||||
TownRosters::MemberJob officer;
|
||||
officer.registryId = 55101;
|
||||
officer.stableKey = "lua-roster-a";
|
||||
officer.homeLocation = "home:hpd-1";
|
||||
officer.workLocation = "work:hpd-1";
|
||||
officer.tagQuery = "job:police";
|
||||
members.push_back(officer);
|
||||
|
||||
TownRosters::MemberJob medic;
|
||||
medic.registryId = 55201;
|
||||
medic.stableKey = "lua-roster-b";
|
||||
medic.homeLocation = "home:amb-1";
|
||||
medic.workLocation = "work:amb-1";
|
||||
medic.tagQuery = "job:medic";
|
||||
members.push_back(medic);
|
||||
|
||||
TownRosters::MemberJob shopkeep;
|
||||
shopkeep.registryId = 55301;
|
||||
shopkeep.stableKey = "lua-roster-c";
|
||||
shopkeep.homeLocation = "home:shop-1";
|
||||
shopkeep.workLocation = "work:shop-1";
|
||||
shopkeep.tagQuery = "job:salesman";
|
||||
members.push_back(shopkeep);
|
||||
|
||||
TownRosters::MemberJob child;
|
||||
child.registryId = 55302;
|
||||
child.stableKey = "lua-roster-d";
|
||||
child.homeLocation = "home:shop-1";
|
||||
child.tagQuery = ""; /* no job */
|
||||
members.push_back(child);
|
||||
|
||||
g_fake = TownRosters::deriveRosters(members);
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
printf("rosters lua test\n");
|
||||
int failures = 0;
|
||||
|
||||
TEST("special-job tag vocabulary");
|
||||
{
|
||||
auto &tags = TownRosters::specialJobTags();
|
||||
bool police = false, medic = false;
|
||||
for (const std::string &t : tags)
|
||||
if (t == "police")
|
||||
police = true;
|
||||
else if (t == "medic")
|
||||
medic = true;
|
||||
if (!police || !medic)
|
||||
FAIL("special tag list incomplete");
|
||||
if (!TownRosters::isSpecialJobTag("firefighter"))
|
||||
FAIL("firefighter must be special");
|
||||
if (TownRosters::isSpecialJobTag("salesman"))
|
||||
FAIL("salesman must not be special");
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST("derive: registry id order + non-specials dropped");
|
||||
{
|
||||
buildFake();
|
||||
size_t members = 0;
|
||||
for (const auto &kv : g_fake)
|
||||
members += kv.second.size();
|
||||
if (members != 2)
|
||||
FAIL("fixture roster must hold 2 members "
|
||||
"(salesman + jobless are out)");
|
||||
if (g_fake.count("police") != 1 ||
|
||||
g_fake.count("medic") != 1)
|
||||
FAIL("roster tags missing");
|
||||
PASS();
|
||||
}
|
||||
|
||||
{
|
||||
/* The Lua state with the registered rosters binding. */
|
||||
lua_State *L = luaL_newstate();
|
||||
luaL_openlibs(L);
|
||||
lua_newtable(L);
|
||||
lua_setglobal(L, "ecs");
|
||||
editScene::setLuaRostersProvider(fakeProvide, nullptr);
|
||||
editScene::registerLuaRostersApi(L);
|
||||
|
||||
TEST("ecs.rosters.area returns the roster table");
|
||||
if (!runScript(
|
||||
L,
|
||||
"local r = ecs.rosters.area(1)\n"
|
||||
"assert(type(r) == 'table', 'area is nil')\n"
|
||||
"local total = 0\n"
|
||||
"local tagsSeen = {}\n"
|
||||
"for tag, members in pairs(r) do\n"
|
||||
" tagsSeen[tag] = #members\n"
|
||||
" total = total + #members\n"
|
||||
"end\n"
|
||||
"assert(total == 2, 'members '..total)\n"
|
||||
"assert(tagsSeen['police'] == 1, 'police')\n"
|
||||
"assert(tagsSeen['medic'] == 1, 'medic')\n"
|
||||
"local hit = 0\n"
|
||||
"for _, tag in ipairs(ecs.rosters.tags()) do\n"
|
||||
" if tagsSeen[tag] then hit = hit + 1 end\n"
|
||||
"end\n"
|
||||
"local special = ecs.rosters.is_special('police')\n"
|
||||
"assert(special == true, 'police not special')\n"
|
||||
"assert(ecs.rosters.is_special('salesman') == false)\n"
|
||||
"global_ok = true\n"))
|
||||
FAIL("roster script failed");
|
||||
lua_getglobal(L, "global_ok");
|
||||
if (!lua_toboolean(L, -1))
|
||||
FAIL("roster assertions did not run");
|
||||
lua_pop(L, 1);
|
||||
PASS();
|
||||
|
||||
TEST("unknown area -> nil");
|
||||
if (!runScript(L, "assert(ecs.rosters.area(9) == nil)"))
|
||||
FAIL("unknown area script failed");
|
||||
PASS();
|
||||
|
||||
TEST("member fields: id/home/work/key");
|
||||
if (!runScript(
|
||||
L,
|
||||
"local r = ecs.rosters.area(1)\n"
|
||||
"for tag, members in pairs(r) do\n"
|
||||
" for _, m in ipairs(members) do\n"
|
||||
" assert(type(m.id) == 'number', 'id')\n"
|
||||
" assert(m.id >= 55101, 'id band')\n"
|
||||
" assert(type(m.key) == 'string' and "
|
||||
"m.key ~= '', 'key')\n"
|
||||
" assert(string.sub(m.home, 1, 5) == "
|
||||
"'home:', 'home anchor')\n"
|
||||
" assert(string.sub(m.work, 1, 5) == "
|
||||
"'work:', 'work anchor')\n"
|
||||
" end\n"
|
||||
"end\n"
|
||||
"fields_ok = true\n"))
|
||||
FAIL("fields script failed");
|
||||
lua_getglobal(L, "fields_ok");
|
||||
if (!lua_toboolean(L, -1))
|
||||
FAIL("fields assertions did not run");
|
||||
lua_pop(L, 1);
|
||||
PASS();
|
||||
|
||||
TEST("cleared provider -> nil");
|
||||
editScene::setLuaRostersProvider(nullptr, nullptr);
|
||||
if (!runScript(L, "assert(ecs.rosters.area(1) == nil)"))
|
||||
FAIL("cleared-provider script failed");
|
||||
PASS();
|
||||
|
||||
lua_close(L);
|
||||
}
|
||||
|
||||
printf("%s (%d tests, %d passed)\n",
|
||||
failures == 0 ? "rosters lua test PASSED" :
|
||||
"rosters lua test FAILED",
|
||||
testCount, passCount);
|
||||
return failures == 0 && testCount == passCount ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user