Phase 6.8: character stats foundation

This commit is contained in:
2026-10-28 15:56:20 +03:00
parent c643d4388f
commit d5b9ae235e
10 changed files with 1536 additions and 25 deletions
+41
View File
@@ -3113,6 +3113,47 @@ Tests: `tests/global_state_test.cpp` (target `global_state_test`, CTest
`globalStateTest`; headless - C++/Lua round trips, defaults, serialization,
`clearToDefaults`, `renamePrefix`).
### Character stats (phase 6.8.9)
`CharacterStatStore` (`systems/CharacterStatStore.hpp/.cpp`, singleton
`getInstance()`) is the generic per-character stat container: named
numeric stats keyed by an OPAQUE character id (registry id; the store
never validates that a character record exists). It is deliberately
standalone — no CharacterRegistry dependency — so it links headlessly
and any system can use it. Stats are NOT engine systems: each stat is
an independent script module with its own rules and lifecycle
(`health`/`money`/`stress` are just the first three instances).
- `define(name, default, min, max)` declares a stat at runtime
(re-declared by scripts on load, NOT persisted);
`undefine(name)` removes the definition AND all stored values.
`get(id, name)` = explicit value, else the default, else 0;
`set`/`add` clamp to the range on every write; `has` = explicit
value present; `clear` = back to the default;
`clearCharacter(id)` = despawn/delete cleanup.
- `subscribe(listener)` / `unsubscribe(token)` fire on EFFECTIVE
changes only (changed set/add, clear of an existing value) — the
emergency-rescheduling trigger (6.8.14) subscribes here instead of
polling.
- `snapshot(id)` is the planner's week-start view: every defined
stat with its effective value (defaults included). Schedulers
read snapshots, never live values, so a week stays pure
(decision 6.8.8).
- Persistence: only explicit values are serialized
(`character_stats.json` in the CWD, autoSave on write,
`setAutoSaveEnabled(false)` in tests); values of not-yet-
re-declared stats survive. Save-game integration lands with the
schedule store (6.8.10).
Lua (`lua/LuaCharacterStatsApi.cpp`, registered in EditorApp next to
the global-state API): `ecs.stats.define{name,default,min,max}`,
`undefine`, `is_defined`, `get`, `set`, `add`, `has`, `clear`,
`clear_character`, `snapshot`. Example:
`lua-examples/character_stats_example.lua`. Tests:
`tests/character_stats_test.cpp` (target `character_stats_test`,
CTest `characterStatsTest`; links only the store + the Lua binding,
no OGRE/flecs — 9 cases).
### Scene Switching
`EditorApp::switchScene(path, opts)` (queued; executed at the top of the next
+27
View File
@@ -65,6 +65,7 @@ set(EDITSCENE_SOURCES
systems/ContainerStateRegistry.cpp
systems/ItemStateRegistry.cpp
systems/GlobalStateStore.cpp
systems/CharacterStatStore.cpp
systems/SaveLoadSystem.cpp
systems/SaveLoadDialog.cpp
systems/PlayerControllerSystem.cpp
@@ -231,6 +232,7 @@ set(EDITSCENE_SOURCES
lua/LuaCharacterApi.cpp
lua/LuaSaveLoadApi.cpp
lua/LuaGlobalStateApi.cpp
lua/LuaCharacterStatsApi.cpp
lua/LuaDoorApi.cpp
lua/LuaHudApi.cpp
lua/LuaFurnitureApi.cpp
@@ -835,6 +837,31 @@ add_test(NAME globalStateTest
COMMAND global_state_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Phase 6.8.9 character stat container (headless)
# ---------------------------------------------------------------------------
# Standalone store with opaque character ids; only the store and its Lua
# binding are linked (no OGRE, no flecs).
add_executable(character_stats_test
tests/character_stats_test.cpp
systems/CharacterStatStore.cpp
lua/LuaCharacterStatsApi.cpp
)
target_link_libraries(character_stats_test
lua
nlohmann_json::nlohmann_json
)
target_include_directories(character_stats_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
add_test(NAME characterStatsTest
COMMAND character_stats_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Phase 6.7 animation travel helpers (headless)
# ---------------------------------------------------------------------------
+2
View File
@@ -70,6 +70,7 @@
#include "lua/LuaHudApi.hpp"
#include "lua/LuaFurnitureApi.hpp"
#include "lua/LuaGlobalStateApi.hpp"
#include "lua/LuaCharacterStatsApi.hpp"
#include "lua/LuaSceneSwitchApi.hpp"
#include "systems/PlayerControllerSystem.hpp"
#include "systems/VehicleControllerSystem.hpp"
@@ -1017,6 +1018,7 @@ void EditorApp::setup()
editScene::registerLuaTerrainApi(L);
editScene::registerLuaSceneSwitchApi(L);
editScene::registerLuaGlobalStateApi(L);
editScene::registerLuaCharacterStatsApi(L);
editScene::registerLuaDoorApi(L);
editScene::registerLuaHudApi(L);
editScene::registerLuaFurnitureApi(L, &m_world);
+588 -25
View File
@@ -4535,32 +4535,595 @@ feet sliding.
beyond the existing cross-fade, and any AI/schedule behavior change:
travel is an ANIMATION-layer feature only.
### Phase 6.8 - DSL, schedule and POIs.
### Phase 6.8 — DSL macros, symbolic schedules and POIs
- it should be possible to declare macro blocks in DSL similar to Lisp macros to reuse parametrized DSL fragments in many places.
- Documentation and help window should be updated with new syntax.
- exterior scene:
- per-NPC schedule uses symbolic names, like home, job, leisure, etc.
- per-NPC schedule uses blocks of 1 in-game hour to set schedule, not random time pereiods. The scheduler should try to do realistic schedules and prefer shorter distances between locations, but not overuse same locations making some of them crowded and some empty.
- per NPC daily schedule should start and end at home if no special scripted circumstances occur. In later case the script creates the schedule for the NPC until gives up back to the system.
- these symbolic names are resolved to actual POIs at schedule build and update time, symbolic name should not be forgotten, but association of symbolic names with POIs is kept until re-schedule occurs.
- the schedule is built for in-game week
- once out of schedule, re-scheduling occurs.
- POIs should count their usage in the schedules of NPCs
- if POI is not home, when schedule is built, prefer POIs with less usage and less distance to NPC "home". That serves a purpose of making the town more interesting and spreading NPCs around town.
- POIs are declared with tags with DSL or node set up for interiors and node-based for exterior. Should easily handle large number of POIs in editor UX and performance.
- For job POIs, the specification of the job in tags is kept, so when re-scheduling, they keep their "profeession" but job location can be different.
- Currently NPCs do not prefer any job type or leisure type, but that will be added at one point. However they prefer to keep the same job type, using the same tags in search as last one.
- symbolic names from schedule are searched as POI tags
- home is declared as house where NPC sleeps in and starts game in, should be set from the start for that NPC.
- all other symbolic names are searched among POIs as tags and best ones are selected.
- all the symbolic names are resolved per-NPC. They change very rarely, but still can change.
- schedule stores symbolic names/tags for locations but caches NPC-specific name resolutions.
- for interior scene
- POIS declared in DSL can fully declare behavior tree for action and action itself. that we can declare behavior tree, action, actuator/POI in DSL the same way as with nodes/components directly, completely with GOAP/HTN data. Lua events and calls should be easily supported too. All these blocks will be heavily reused from lots of places, so it should be easy to reference them from other parts of DSL.
- for both interior and exterior - POIs which are have job tag, can also have job specification tags, like "shop, salesman", or "office, secretary" and these should be preferred when looking for other job. Also special kinds of job like "police", "ambulance",... should make NPCs listed there into special lists used by special game systems implemented later.
- for quicker search for POIs interior POIs should be indexed per building/per lot to be found quicker.
- something for research: it would be nice if i.e. shop employees would enter back door, but shop cutomers will enter front door; the POI for employee would be "shop, salesman" and for customer "shop, shopping". Some simple idea is needed...
**Status: PLANNED (not started).** Rewritten 2026-10-28: the original
bullet list mixed four independent features and contradicted the
clock-pure motion core; it is now split into workstreams with
explicit design decisions, task-sized pieces and done criteria.
#### What this phase is really about
Three independent workstreams plus one research item. They share
vocabulary but must not be implemented as one blob:
- **A. DSL reuse** — macro blocks for the layout DSL (engine-level,
no NPC involvement).
- **B. Town life** — weekly, symbolic per-NPC schedules driven by a
tagged POI/job model (exterior, pure functions).
- **C. Interior interaction authoring** — declaring POIs, actuators,
actions, behavior trees and Lua hooks from the DSL (needs A for
reuse, but NOT Phase 8: develop against hand-authored interiors).
- **D. Entrance roles** (research) — employees use the back door,
customers the front door.
#### Vocabulary (the old text conflated these)
- **Location** — a `TownLocations::NpcLocation` the schedule/motion
layers path to (Home/Work/Leisure/Interest/Curb/Parking). Lives
per town area, world position, deterministic name.
- **POI** — one record with two aspects (ground-up model in
6.8.11): planner DATA (tags, stat effects, entrance role,
opening hours — queryable with no entities spawned) and
mechanical ACTIVATION (E-press or on-approach, running a
behavior tree by default or Lua, page-managed runtime). A
location may reference a POI, but they are not the same thing:
"job:shop" is a location NPCs walk to; "work_counter" is a POI
they actuate when they arrive.
- **Job tag** — a skill/profession label on a location or building
(`shop`, `salesman`; `office`, `secretary`; `police`). NPCs keep
their tag set across re-scheduling; only the *place* may change.
- **Entrance role** — which door of a building a location's approach
leg targets (customer front, employee back, delivery side).
#### What already exists — do not rebuild it
- `TownSchedule` (roadlib): pure per-NPC schedules tiling [0, 24)
with `(location, activity)` entries, per-NPC mt19937 streams
(`seed ^ fnv1a(stableKey)`), `entryAt()`, departure anchored so the
NPC arrives when the next entry starts.
- `TownLocations` (roadlib): `LocationKind` (Work/Leisure are
*reserved*, nothing generates them yet), rendezvous-hashed
`pickLocationName`, `computeBindings` → registry stringColumns
`npcHome` / `npcWork` (Interest placeholder) / `npcInterest`.
- `NpcMotionSystem`: **clock-pure** evaluation — position and goal
are re-derived from `GameClockSystem::getAbsoluteHours()` every
frame; no latch, no sticky target; `setSuspended()` hands an NPC to
taxi/traffic and back. `NpcTrafficSystem` derives driving and
parking claims the same pure way.
- Layout DSL: `LayoutEval` library entries `(layout name [:tags ...]
[:score n] body...)`, `town_layouts.txt`, DSL Help window with
reference + worked examples, per-lot isolated RNG.
- Interactions: `ActuatorComponent` + actionDatabase behavior trees,
`luaTask` nodes, SmartObject/GOAP, furniture POI markers with
stable ids (`CellGridSystem::queryFurniturePois`).
- Debug: F7 `NpcScheduleDebugOverlay` (per-NPC 24 h schedule, motion
state, paths, traffic claims).
#### Design decisions (resolving the old text's open questions)
1. **Two layers: a pure baseline week + an explicit goal/revision
layer.** The baseline is a pure function regenerated on week
rollover or town-graph change; deviation is handled by the
existing suspension channel (taxi/traffic/scripted goals), never
by silently mutating the baseline. When a new goal genuinely
requires rewriting the REST of the current week, that is an
explicit, recorded **schedule revision** (see 6.8.10), not an
ad-hoc mutation. Stat-threshold EMERGENCIES (6.8.14) are the
highest-priority revision kind. Re-scheduling is
forward-looking: it never rewrites the past, and in-flight goals
(a taxi ride, a driving leg) complete before the new schedule
takes over — except emergencies, which cancel immediately. Clock scrubs,
pausing, save/load and scene switches reproduce the same week
bit-exactly *for the same inputs* (see decision 8 for the stats
caveat).
Re-scheduling's purpose is for NPC to have slightly different schedule next week. As stats like stress get involved and job POIs start add to stress and leisure POIs reduce stress that would make more sence. Also current schedule should be stored to game save file and live regardless of scene switches, so periodic re-scheduling should not create harm to consistency.
Fort example, NPC has too little money so he takes more jobs in the schedule with each job increasing his money balance, but each job increases his stress, so he have to take more leisure POIs which reduce stress, but spend money. Or sleep whole day on weekends and go nowhere, spending time at home which reduces stress but slower than leisure points. That is just an example of what is planned.
2. **Weekly = 7 daily tilings, one pure build.** A schedule is a
function of `(stableKey, weekIndex, townGraphVersion, rules,
statsSnapshot, revision)`. Day-of-week selects the daily
template (workday / weekend / custom); entries are quantized to
whole in-game hours, and the existing "arrive when the next
entry starts" anchoring stays. **Entries are intentions, not
deadlines:** traffic and interference make NPCs late, and a
fully-missed entry is simply skipped by the clock — never a
reason to re-schedule.
Yes, that is also relaxes it a bit for getting late for the scheduled hour due to interference and simulation, that might help tweaking the values for schedules to be more realistic. Of course, the schedule should not stay set in stone, as NPCs can get new goals which require rescheduling at any moment.
3. **Symbolic names are never forgotten.** Entries store
`(kind, tagQuery, resolvedLocationName)`. Resolution is a cached
per-NPC binding (same rendezvous-hash discipline as
`computeBindings`), recomputed only at the triggers in (1); the
tag query is what persists conceptually, the resolved name is a
cache keyed by `(weekIndex, graphVersion, revision)`, never the
source of truth. The weekly schedule store itself is
scene-independent and saved with the game (6.8.10), so the
resolved mappings ride along without a separate cache format.
Current schedule mappings can be stored in save file if that makes it work faster, but are subject to change if schedule goals changed.
4. **POI choice = usage + distance + deterministic jitter.** The
weekly build assigns NPCs in registry-id order and scores each
candidate location by current usage count (from this build),
distance from the NPC's home and a hash tie-break. No global
mutable counters across frames: usage is an output of the build,
not game state.
5. **Home is fixed; jobs are sticky by tag.** `home` stays the
sleeping building for the NPC's lifetime. A job change keeps the
NPC's job tags and only re-picks the location; profession changes
are a later, explicit gameplay decision, not a scheduler accident.
6. **Macros expand before evaluation.** Expansion is a pure
syntactic pass over the s-expression tree (parameters, no
runtime evaluation), so per-lot RNG draw order and determinism
are untouched. Depth/expansion caps and errors that name the
macro call chain are part of the feature, not afterthoughts.
7. **Missing POIs degrade, never crash.** A tag query with no
candidates falls back (work → home/Interest placeholder, leisure
→ curb/Interest) and logs one warning per area build.
8. **Stats (health/stress/money) are sampled, never read live.**
Stats are script-defined values in the generic per-character
container (6.8.9), with Lua-owned update rules. The weekly
generator consumes a **stats snapshot taken at week start** (or
at load), so a week never rewrites itself mid-flight and stat
changes affect NEXT week's schedule — which is exactly the
intended weekly variety. Scoring hooks (6.8.9) are read-only
and deterministic, so the guarantee is "same clock AND same
stats snapshot ⇒ same schedule" (probes pin both), instead of
the old "same clock ⇒ same schedule". The schedule → activities
→ stats → next schedule feedback loop needs clamps (stress
0..100, money >= 0 with a forced-home-rest floor) and choice
inertia, or poor/stressed NPCs will oscillate between job and
leisure every day.
#### Tasks
**Task 6.8.1 — DSL macro blocks (workstream A).**
- Add `(defmacro name (params...) body...)` (or `(defblock ...)`) to
the layout DSL: named, parametrized fragments stored in
`town_layouts.txt` next to `(layout ...)` entries; invocation
expands the body with arguments substituted, before evaluation.
Lexical scoping only, no runtime evaluation in expanders, recursion
and expansion-depth caps with a clear error naming the call chain.
- Library entries may reference macros and vice versa; expansion must
be deterministic and independent of which lot is being built.
- Done when: a macro-used layout and its hand-expanded form produce
byte-identical `BuildingPlan`s; recursion/bad-arity/unknown-macro
errors are reported with the call chain; RNG streams are unchanged
(density-1 default towns stay bit-identical).
- Verification: pure cases in `road_geometry_overlap_test`; the DSL
Help window and `LayoutLibrary::examples()` gain a macro section
with one worked example, and `editScene/AGENTS.md` documents the
syntax.
**Task 6.8.2 — POI and job tag model + index (workstream B, data).**
- Extend `TownLocations` ON TOP of the unified POI database
(6.8.11): Work and Leisure stop being reserved — locations gain
a `tags` set and an optional `entranceRole`; building generation
assigns tags from layout metadata (`:type`, layout `:tags`) and
per-lot/area overrides (shop → `shop`, plus a job tag such as
`salesman`). The LocationSet is a projection of the POI db, so
planner queries see one model.
- A commercial building yields BOTH kinds: a shop is a Work location
for its employees AND a Leisure location for its customers (same
building, different kind, tags and default entrance role). Do not
model it as a single kind or the two audiences fight over one
location.
- Per-area tag → location-indices index (and per-building/per-lot
buckets for interior markers later), so queries never scan the
whole set; keep `pickLocationName`'s rendezvous discipline for
candidate sets.
- Editor: tag display/editing on buildings/lots, filterable POI list
designed for thousands of entries (search + kind/tag filter, no
per-frame full rebuild).
- Done when: a generated town exposes Work/Leisure locations with
stable tags; queries by tag are indexed and deterministic; old
towns without tags keep the Interest fallback.
- Verification: `road_geometry_overlap_test` location/tag/index
probes; `--test-determinism` unchanged.
**Task 6.8.3 — Weekly symbolic schedule generator (workstream B, pure).**
- New `TownSchedule::generateWeekSchedules()`: 7 daily tilings per
NPC (workday/weekend templates), whole-hour entries, home at both
ends of every day unless a scripted override owns the NPC. The
signature carries a stats-snapshot parameter from the start
(defaults reproduce current behavior; 6.8.10 makes it live).
- Resolution per (1)-(5): job locations picked by job tags, leisure
by leisure tags, scored by usage + home distance + hash jitter;
per-NPC streams stay isolated so one NPC's edit cannot shift
another's week.
- Done when: entries are hour-aligned, every day starts/ends at
home, re-running the build is bit-identical, week rollover
re-resolves only what the rules allow, and usage actually spreads
(report max/mean location usage in the probe).
- Verification: pure generator probes (hour alignment, home
bookends, determinism, tag stickiness, usage-spread and distance
metrics, no-candidate fallback) in `road_geometry_overlap_test`.
**Task 6.8.4 — Runtime integration + migration (workstream B).**
- `NpcPopulationSystem` stores weekly schedules per area and the
resolved bindings; `NpcMotionSystem` reads the day-of-week tiling
through the same `entryAt()` path (clock-pure, no latch);
`NpcTrafficSystem` keeps deriving drive legs from the same
entries, so walking and driving never disagree.
- Indoor scheduling follows the entry contract (6.8.12): interior
destinations approach via the building's entry POI; the symbolic
tier completes interior entries entity-less there, the live tier
despawns/respawns at the door — no behavior change vs the current
`isIndoorKind` rules.
- 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).
- F7 overlay: week view per NPC (day selector), resolved location
names next to their tag queries, per-location usage counts.
- 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` all
pass; new week-rollover determinism probe.
**Task 6.8.5 — Schedule realism rules (workstream B, content).**
- Archetypes: children/elders keep non-work days; adults work on
workdays; shop workers follow their workplace's opening hours.
- Opening hours per location tag (e.g. shop 9–18): schedules never
place an NPC at a closed location; closing time pulls workers and
visitors home or to a leisure fallback.
- Optional soft capacity per location (beyond it the score pushes
NPCs to the next candidate) so one popular shop does not absorb
the whole town.
- Done when: no schedule entry places an NPC at a closed or
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.
**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.
**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.
**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.
**Task 6.8.9 — Character stats foundation: script-defined stats
(health/money/stress) (workstream B, foundation — do FIRST).**
**Status: step 1 DONE (2026-10-28) — the standalone container, the
`ecs.stats.*` Lua API and the headless tests are in.**
- ONE generic per-character stat container in the engine, NOT three
hardcoded systems: the standalone `CharacterStatStore`
(systems/CharacterStatStore.hpp/.cpp) keyed by an OPAQUE character
id (no registry dependency — values never require a character
record), with define/get/set/add/clear/undefine, clamp ranges,
change listeners and its own JSON persistence
(`character_stats.json`, only explicit values; definitions are
re-declared by scripts on load). Each stat is an independent
SCRIPT MODULE with its own rules and lifecycle — `health`,
`money`, `stress` are merely the first three instances; new stats
must cost zero engine work.
- Script API (Lua, `ecs.stats.*`): `define{name, default, min, max,
meta}` / `remove(name)` / `get/set/add(id, name, ...)` / change
events. Stat DEFINITIONS may also be declared as data in the
S-expression DSL (name/default/range/metadata); stat RULES
(job adds money+stress per hour, leisure reduces stress and costs
money, home rest reduces stress slower) are Lua code — do NOT grow
a second behavior runtime inside the DSL.
- Update discipline: values change via explicit events (per
completed schedule entry, script calls), never via free-running
per-frame decay in the scheduler; clamps apply on every write
(stress 0..100, money >= 0 with a forced-home-rest floor).
- Planner query hook: `scoreLocations(statsSnapshot, candidates,
usage, distances)` — a default pure C++ implementation
(usage+distance+jitter) with an optional Lua override per
kind/tagQuery. Candidates carry their POI tags and stat effect
vectors (6.8.11). Hooks are READ-ONLY and deterministic
(snapshot data only, no wall clock, seeded RNG passed in if
needed); the weekly generator stays pure given a snapshot.
- Health semantics are limited here to storage, rules and planner
influence (low health prefers rest/clinic locations); death and
incapacitation behavior is a later game decision.
- F7 overlay shows the per-NPC stat table plus the snapshot the
current week was built from.
- Done when: a stat can be defined, updated, queried and removed
entirely from Lua (and defined from DSL data), values persist
across save/load and scene switches, and the planner produces an
exact expected week for hardcoded stats with the default scoring.
- Verification: container/lifecycle/clamp/event/persistence cases in
`component_lua_test`; pure planner probes with hardcoded stats
(poor → more jobs, stressed → more leisure, low health → rest) in
`road_geometry_overlap_test`; multi-week feedback simulation keeps
stats in bounds with valid schedules (no daily oscillation).
**Steps:** (1) DONE 2026-10-28 — standalone `CharacterStatStore` +
`ecs.stats.*` (registered in EditorApp next to the global-state API)
+ `tests/character_stats_test.cpp` (target `character_stats_test`,
CTest `characterStatsTest`, 9/9: defaults/clamps, opaque ids,
undefine, effective-change listeners, snapshot, serialize/file round
trip, clearCharacter, full Lua round trip) and
`lua-examples/character_stats_example.lua`. (2) Next: the
health/money/stress Lua stat modules with per-entry update rules,
the save-game section and the F7 stat display. (3) Later: the
planner `scoreLocations` hook (C++ default + Lua override) and the
hardcoded-stats planner probes.
**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.
**Task 6.8.11 — Unified POI model: shared database + page-managed
activation (workstreams B+C, ground-up POI definition).**
- A POI is ONE record with TWO aspects:
- **Planner aspect** (data only, queryable with NO entities
spawned): stable id, anchor (world position / building+cell),
**domain (interior | exterior)**, kind, tags, entrance role,
opening hours, and **stat effects** (e.g. `money:+10/h`,
`stress:-5/h`) offered per completed schedule entry. Interior
POIs carry their building anchor (6.8.12).
- **Mechanical aspect** (runtime): activation spec — `none` /
`manual` (E prompt) / `onApproach` (radius, once/cooldown) /
`onArrive` (NPC finishing a schedule entry) — plus the behavior
to run: a **behavior tree by default**, a Lua script for special
cases. Runtime entities are created and destroyed **per
terrain page** (page load instantiates, page unload destroys);
fired flags and cooldowns are runtime-only, never in the db.
- ONE shared POI database (per-area, scene-independent) holding the
definitions, fed by every authoring path: generated town POIs
(curb/parking/home/work/leisure from 6.8.2), DSL-emitted POIs
(6.8.6 interior forms), and entity/component-authored exterior
POIs (a new `PoiComponent` carrying tags/effects/activation,
collected on scene load and streaming). The db is a UNIFIED
QUERY MODEL, not the source of truth: DSL text and scene JSON
stay authoritative, the db is rebuilt deterministically from
them (stable ids, source order) on load/graph version/page
events, never per frame.
- `TownLocations::LocationSet` becomes a PROJECTION of the POI db
(planner view), so schedules, motion, taxi and traffic keep one
location model — no third parallel registry. Queries by
tag/kind are indexed and id-ordered.
- Stat effects are DATA on the POI: the stat's Lua module (6.8.9)
consumes them on entry completion, and the planner's
`scoreLocations` hook sees the effect vectors (poor NPCs prefer
high-money POIs, stressed NPCs stress-relieving ones). The POI
never mutates stats itself.
- Runtime dedup rule: exactly ONE runtime representation per POI
id — scene-embedded POIs keep their entities (db only gains the
definition); db-only POIs get entities spawned on page load.
Activation uses the existing ActuatorSystem/BehaviorTreeSystem
(extended with the onApproach/onArrive trigger modes).
- Editor: a POI inspector over the db (id, anchor, tags, effects,
activation spec, source) with the filterable list from 6.8.2, so
DSL-authored and component-authored POIs look identical.
- Done when: the same query returns generated, DSL-authored and
component-authored POIs with stable ids; an onApproach POI fires
its behavior tree once on entry and respects its cooldown; page
unload destroys runtime entities but leaves the db entry and the
planner results unchanged.
- Verification: pure db build/query/id-stability/dedup probes in
`road_geometry_overlap_test`; headless activation probe
(approach fires BT, manual E path unchanged); page load/unload
round trip keeps planner output bit-identical.
**Task 6.8.12 — POI domains + the building entry contract
(workstream B, two-tier indoor scheduling).**
- Every POI is `domain = exterior | interior`; queries filter by
domain and interior POIs are grouped under their building anchor.
`isIndoorKind` and the spawn-eligibility rules read the domain.
- Every ENTERABLE building gets a generated EXTERIOR **entry POI**
at its doorstep (tag `entrance`, entrance role from 6.8.8), which
links the building to its interior POIs/locations. An interior
destination resolves its approach THROUGH the entry POI: the road
path and the distance score use the entry position, never the
interior cell (which may live in another scene's coordinates).
- **Symbolic tier (off camera):** reaching the entry POI means
"inside". Interior schedule entries complete entity-less at the
entry (stat effects only, no mechanical activation), so the whole
week runs with no interior scene and no spawned entities —
identical results to the live tier's schedule decisions.
- **Live tier (on camera):** the NPC walks to the entry POI and
despawns at the door (the existing <=2.5 m indoor-despawn rule,
now anchored on the entry POI instead of an implicit location
point); when the next entry moves the NPC outside it respawns at
that same entry. Same behavior as today, but an explicit,
inspectable transition — and Phase 8 later turns the entry POI
into a real interior scene switch with the interior POI as the
final destination, without changing the schedule model.
- Editor: the POI inspector shows the domain; interior POIs are
listed under their building.
- Done when: every enterable building exposes an entry POI;
interior queries resolve approaches via it; a symbolic week runs
with no interior scene; live NPCs despawn/respawn at the entry
exactly as the current indoor rules require.
- Verification: pure entry-generation + approach-resolution probes;
symbolic-week-no-interior-scene probe; live spawn/despawn-at-entry
probe; existing `--test-motion` / `--test-detail` / `--test-taxi`
pass 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).
**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.
#### Cross-cutting requirements
- **Determinism everywhere:** all new generators are pure functions
with per-NPC/per-lot isolated streams; no frame-time mutable
scheduling state. Schedules are pure for a FIXED stats snapshot —
probes pin the snapshot alongside the clock ("same clock AND same
stats ⇒ same schedule"). Every new pure function gets a headless
probe.
- **Performance budget:** weekly builds run per area on rollover or
graph change only (never per frame); tag queries are indexed;
overlay/list UI does not rebuild per frame.
- **Debuggability:** F7 is the inspection surface (schedules,
resolutions, usage, rosters); every fallback logs once with the
area and tag query, never per frame.
- **Docs:** DSL Help window, `editScene/AGENTS.md` sections for the
schedule/POI model and the macro system, and the flag/probe list
updated as each task lands.
#### Explicit non-goals (for later phases)
- Interior pathfinding between rooms/floors (Phase 7/8 territory),
interior HOME AI (cooking, cleaning, family interactions — home
entries are idle rest, 6.8.13), death (health=0 means
incapacitation → hospitalization, 6.8.14), ambulances and
escorted medical transport (later, on the 6.8.7 rosters),
profession changes and careers, needs/moods BEYOND the basic
health/stress/money stats of 6.8.9, a full economy (itemized
wages, prices, trading), police behavior systems (6.8.7 only
exposes rosters), crowd rendering/perf work beyond the indexed
queries above.
#### Delivery order
1. 6.8.9 stats foundation (generic container + script-defined
stats; independent, do FIRST or in parallel with 6.8.1).
2. 6.8.1 macros (unblocks DSL reuse everywhere).
3. 6.8.11 unified POI model → 6.8.12 domains + entry contract
(the shared database, activation contract and two-tier indoor
scheduling everything else queries).
4. 6.8.2 tag model + index on top of the POI db (planner
projection for B and C), with the 6.8.13 reference layouts
(home/job/leisure) as the content fixtures.
5. 6.8.3 weekly generator → 6.8.4 integration (playable town life).
6. 6.8.5 realism rules (content pass over the same pure generator).
7. 6.8.6 interior POI/action DSL (emits into the POI db; can run
parallel with B once 6.8.1 lands).
8. 6.8.7 rosters, 6.8.8 entrance roles (small, whenever convenient).
9. 6.8.10 stat-driven variety + persistence (after 6.8.4 so the
baseline week is already playable).
10. 6.8.14 emergency rescheduling + hospitalization (after the
stats and persistence layers exist; architecture can be
designed earlier).
### Phase 7 — CellGrid stair generation - before interiors
@@ -0,0 +1,54 @@
-- Character stats (phase 6.8.9): stats are created, updated, queried
-- and removed ENTIRELY from script. The engine only provides the
-- generic CharacterStatStore; every stat is an independent script
-- module with its own rules. health/money/stress are just the first
-- three instances -- a fourth stat costs zero engine work.
--
-- Ids are opaque character keys (registry ids); the store never
-- checks that a character record exists, so tests can hardcode any
-- id and get fully predictable values.
-- Declare the stats once on load (definitions are NOT persisted;
-- re-declare them every session). Explicit values persist in
-- character_stats.json across sessions.
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 }
local npc = 42
-- Money: earned at work, spent at leisure. Writes clamp to the
-- declared range (money never goes below 0).
ecs.stats.add(npc, "money", 25.5) --> 25.5
ecs.stats.add(npc, "money", -30.0) --> 0 (clamped to the floor)
-- Stress: work adds, leisure and home rest remove.
ecs.stats.add(npc, "stress", 40)
ecs.stats.add(npc, "stress", -15)
assert(ecs.stats.get(npc, "stress") == 25)
-- Health: 0..100, default 100. set() also clamps.
ecs.stats.set(npc, "health", 250)
assert(ecs.stats.get(npc, "health") == 100)
ecs.stats.add(npc, "health", -70)
assert(ecs.stats.get(npc, "health") == 30)
-- The planner reads a week-start SNAPSHOT (never live values):
-- every defined stat with its effective value, defaults included.
local snap = ecs.stats.snapshot(npc)
assert(snap.health == 30 and snap.stress == 25 and snap.money == 0)
-- has() tells an explicit value apart from the declared default;
-- clear() drops the explicit value so the default applies again.
assert(ecs.stats.has(npc, "health"))
ecs.stats.clear(npc, "health")
assert(not ecs.stats.has(npc, "health"))
assert(ecs.stats.get(npc, "health") == 100)
-- clear_character(id) drops every stat of one character (despawn or
-- delete cleanup); undefine(name) removes a stat definition AND all
-- of its stored values, across every character.
ecs.stats.clear_character(npc)
ecs.stats.undefine("stress")
assert(not ecs.stats.is_defined("stress"))
assert(ecs.stats.get(npc, "stress") == 0)
@@ -0,0 +1,148 @@
#include "LuaCharacterStatsApi.hpp"
#include "../systems/CharacterStatStore.hpp"
namespace editScene
{
static uint64_t checkId(lua_State *L, int index)
{
return (uint64_t)luaL_checkinteger(L, index);
}
static int luaStatsDefine(lua_State *L)
{
luaL_checktype(L, 1, LUA_TTABLE);
lua_getfield(L, 1, "name");
const char *name = luaL_checkstring(L, -1);
lua_pop(L, 1);
lua_getfield(L, 1, "default");
double def = luaL_optnumber(L, -1, 0.0);
lua_pop(L, 1);
lua_getfield(L, 1, "min");
double min = luaL_optnumber(L, -1, -1.0e300);
lua_pop(L, 1);
lua_getfield(L, 1, "max");
double max = luaL_optnumber(L, -1, 1.0e300);
lua_pop(L, 1);
lua_pushboolean(L, CharacterStatStore::getInstance().define(name, def,
min, max) ?
1 :
0);
return 1;
}
static int luaStatsUndefine(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
lua_pushboolean(
L, CharacterStatStore::getInstance().undefine(name) ? 1 : 0);
return 1;
}
static int luaStatsIsDefined(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
lua_pushboolean(
L, CharacterStatStore::getInstance().isDefined(name) ? 1 : 0);
return 1;
}
static int luaStatsGet(lua_State *L)
{
uint64_t id = checkId(L, 1);
const char *name = luaL_checkstring(L, 2);
lua_pushnumber(L, CharacterStatStore::getInstance().get(id, name));
return 1;
}
static int luaStatsSet(lua_State *L)
{
uint64_t id = checkId(L, 1);
const char *name = luaL_checkstring(L, 2);
double value = luaL_checknumber(L, 3);
CharacterStatStore::getInstance().set(id, name, value);
return 0;
}
static int luaStatsAdd(lua_State *L)
{
uint64_t id = checkId(L, 1);
const char *name = luaL_checkstring(L, 2);
double delta = luaL_checknumber(L, 3);
lua_pushnumber(L,
CharacterStatStore::getInstance().add(id, name, delta));
return 1;
}
static int luaStatsHas(lua_State *L)
{
uint64_t id = checkId(L, 1);
const char *name = luaL_checkstring(L, 2);
lua_pushboolean(L, CharacterStatStore::getInstance().has(id, name) ? 1 :
0);
return 1;
}
static int luaStatsClear(lua_State *L)
{
uint64_t id = checkId(L, 1);
const char *name = luaL_checkstring(L, 2);
lua_pushboolean(
L, CharacterStatStore::getInstance().clear(id, name) ? 1 : 0);
return 1;
}
static int luaStatsClearCharacter(lua_State *L)
{
uint64_t id = checkId(L, 1);
CharacterStatStore::getInstance().clearCharacter(id);
return 0;
}
static int luaStatsSnapshot(lua_State *L)
{
uint64_t id = checkId(L, 1);
auto snap = CharacterStatStore::getInstance().snapshot(id);
lua_newtable(L);
for (const auto &pair : snap) {
lua_pushnumber(L, pair.second);
lua_setfield(L, -2, pair.first.c_str());
}
return 1;
}
void registerLuaCharacterStatsApi(lua_State *L)
{
lua_getglobal(L, "ecs");
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
return;
}
lua_newtable(L);
lua_pushcfunction(L, luaStatsDefine);
lua_setfield(L, -2, "define");
lua_pushcfunction(L, luaStatsUndefine);
lua_setfield(L, -2, "undefine");
lua_pushcfunction(L, luaStatsIsDefined);
lua_setfield(L, -2, "is_defined");
lua_pushcfunction(L, luaStatsGet);
lua_setfield(L, -2, "get");
lua_pushcfunction(L, luaStatsSet);
lua_setfield(L, -2, "set");
lua_pushcfunction(L, luaStatsAdd);
lua_setfield(L, -2, "add");
lua_pushcfunction(L, luaStatsHas);
lua_setfield(L, -2, "has");
lua_pushcfunction(L, luaStatsClear);
lua_setfield(L, -2, "clear");
lua_pushcfunction(L, luaStatsClearCharacter);
lua_setfield(L, -2, "clear_character");
lua_pushcfunction(L, luaStatsSnapshot);
lua_setfield(L, -2, "snapshot");
lua_setfield(L, -2, "stats");
lua_pop(L, 1);
}
} // namespace editScene
@@ -0,0 +1,33 @@
#ifndef EDITSCENE_LUACHARACTERSTATSAPI_HPP
#define EDITSCENE_LUACHARACTERSTATSAPI_HPP
#pragma once
#include <lua.hpp>
namespace editScene
{
/**
* Lua binding for CharacterStatStore (phase 6.8.9): creates the
* "stats" table inside the global "ecs" table. Stats are defined,
* updated, queried and removed ENTIRELY from script:
*
* ecs.stats.define{ name = "health", default = 100, min = 0,
* max = 100 }
* ecs.stats.undefine("health") -- definition + all values
* ecs.stats.is_defined("health") --> bool
* ecs.stats.get(id, "health") --> number (value or default)
* ecs.stats.set(id, "health", 42) -- clamped to the range
* ecs.stats.add(id, "money", 10.5) --> new value
* ecs.stats.has(id, "health") --> explicit value stored?
* ecs.stats.clear(id, "health") -- back to the default
* ecs.stats.snapshot(id) --> { name = value, ... }
*
* Ids are opaque character keys (registry ids); the store never
* validates that a character record exists.
*/
void registerLuaCharacterStatsApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUACHARACTERSTATSAPI_HPP
@@ -0,0 +1,208 @@
#include "CharacterStatStore.hpp"
#include <fstream>
CharacterStatStore &CharacterStatStore::getInstance()
{
static CharacterStatStore instance;
return instance;
}
bool CharacterStatStore::define(const std::string &name, double defaultValue,
double minValue, double maxValue)
{
if (name.empty())
return false;
Definition def;
def.defaultValue = defaultValue;
def.minValue = minValue;
def.maxValue = maxValue;
m_definitions[name] = def;
return true;
}
bool CharacterStatStore::undefine(const std::string &name)
{
if (m_definitions.erase(name) == 0)
return false;
for (auto &pair : m_values)
pair.second.erase(name);
autoSave();
return true;
}
bool CharacterStatStore::isDefined(const std::string &name) const
{
return m_definitions.find(name) != m_definitions.end();
}
double CharacterStatStore::get(uint64_t id, const std::string &name) const
{
auto itChar = m_values.find(id);
if (itChar != m_values.end()) {
auto it = itChar->second.find(name);
if (it != itChar->second.end())
return it->second;
}
auto itDef = m_definitions.find(name);
if (itDef != m_definitions.end())
return itDef->second.defaultValue;
return 0.0;
}
bool CharacterStatStore::has(uint64_t id, const std::string &name) const
{
auto itChar = m_values.find(id);
if (itChar == m_values.end())
return false;
return itChar->second.find(name) != itChar->second.end();
}
void CharacterStatStore::set(uint64_t id, const std::string &name, double value)
{
double oldValue = get(id, name);
auto itDef = m_definitions.find(name);
if (itDef != m_definitions.end()) {
if (value < itDef->second.minValue)
value = itDef->second.minValue;
if (value > itDef->second.maxValue)
value = itDef->second.maxValue;
}
m_values[id][name] = value;
fire(id, name, oldValue);
autoSave();
}
double CharacterStatStore::add(uint64_t id, const std::string &name,
double delta)
{
double value = get(id, name) + delta;
set(id, name, value);
return get(id, name);
}
bool CharacterStatStore::clear(uint64_t id, const std::string &name)
{
auto itChar = m_values.find(id);
if (itChar == m_values.end())
return false;
auto it = itChar->second.find(name);
if (it == itChar->second.end())
return false;
double oldValue = it->second;
itChar->second.erase(it);
fire(id, name, oldValue);
autoSave();
return true;
}
void CharacterStatStore::clearCharacter(uint64_t id)
{
if (m_values.erase(id) == 0)
return;
autoSave();
}
std::unordered_map<std::string, double>
CharacterStatStore::snapshot(uint64_t id) const
{
std::unordered_map<std::string, double> out;
for (const auto &pair : m_definitions)
out[pair.first] = get(id, pair.first);
return out;
}
uint64_t CharacterStatStore::subscribe(Listener listener)
{
uint64_t token = m_nextListenerToken++;
m_listeners[token] = std::move(listener);
return token;
}
void CharacterStatStore::unsubscribe(uint64_t token)
{
m_listeners.erase(token);
}
void CharacterStatStore::fire(uint64_t id, const std::string &name,
double oldValue)
{
double newValue = get(id, name);
if (newValue == oldValue)
return;
for (const auto &pair : m_listeners)
pair.second(id, name, newValue);
}
nlohmann::json CharacterStatStore::serialize() const
{
nlohmann::json j;
j["version"] = 1;
nlohmann::json values = nlohmann::json::object();
for (const auto &pair : m_values) {
if (pair.second.empty())
continue;
values[std::to_string(pair.first)] = pair.second;
}
j["values"] = values;
return j;
}
void CharacterStatStore::deserialize(const nlohmann::json &j)
{
m_values.clear();
if (j.contains("values") && j["values"].is_object()) {
for (auto it = j["values"].begin(); it != j["values"].end();
++it) {
uint64_t id = std::stoull(it.key());
for (auto sit = it.value().begin();
sit != it.value().end(); ++sit)
m_values[id][sit.key()] =
sit.value().get<double>();
}
}
autoSave();
}
bool CharacterStatStore::saveToFile(const std::string &filepath)
{
try {
std::ofstream file(filepath);
if (!file.is_open()) {
m_lastError =
"Failed to open file for writing: " + filepath;
return false;
}
file << serialize().dump(2);
file.close();
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Save error: ") + e.what();
return false;
}
}
bool CharacterStatStore::loadFromFile(const std::string &filepath)
{
try {
std::ifstream file(filepath);
if (!file.is_open()) {
m_lastError =
"Failed to open file for reading: " + filepath;
return false;
}
nlohmann::json j;
file >> j;
file.close();
deserialize(j);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Load error: ") + e.what();
return false;
}
}
void CharacterStatStore::autoSave()
{
if (m_autoSaveEnabled && !m_autoSavePath.empty())
saveToFile(m_autoSavePath);
}
@@ -0,0 +1,116 @@
#ifndef EDITSCENE_CHARACTERSTATSTORE_HPP
#define EDITSCENE_CHARACTERSTATSTORE_HPP
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <nlohmann/json.hpp>
/**
* Generic per-character stat container (demo-taxi phase 6.8.9).
*
* A scene-independent singleton of named numeric stats keyed by an
* OPAQUE character id (registry id; the store does not know or care
* whether a character record exists). Stats are NOT hardcoded
* engine systems: each stat is an independent script module defined
* at runtime from Lua (ecs.stats.*, see lua/LuaCharacterStatsApi.cpp)
* or C++ with a default value and an optional clamp range. health /
* money / stress are just the first three instances.
*
* Semantics:
* - define() registers a definition (default + clamp range) at
* runtime; definitions are re-declared by scripts on load and are
* NOT persisted. Values set before a definition exists are kept
* and returned as-is (they clamp on the next write).
* - get() returns the explicitly stored value, else the definition
* default, else 0. set()/add() clamp to the definition range on
* every write. clear() drops the explicit value (the default
* applies again). undefine() removes the definition AND every
* stored value of that stat.
* - Change listeners fire only on an EFFECTIVE value change
* (set/add with a new value, clear of an existing value), never
* on re-declares or identical writes. Stat-change driven
* systems (emergency rescheduling, 6.8.14) subscribe here instead
* of polling.
* - snapshot(id) returns the planner's week-start view: every
* defined stat with its effective value (6.8.10 reads snapshots,
* never live values).
* - Persistence: only explicit values are serialized (definitions
* are re-declared by scripts on load). autoSave() mirrors to
* character_stats.json in the CWD (the project dir after F8);
* save-game integration lands with the schedule store (6.8.10).
*/
class CharacterStatStore {
public:
static CharacterStatStore &getInstance();
struct Definition {
double defaultValue = 0.0;
double minValue = -1.0e300;
double maxValue = 1.0e300;
};
/* Declare a stat (idempotent; re-declaring updates the
* definition, existing values are untouched until the next
* write). Returns false on an empty name. */
bool define(const std::string &name, double defaultValue = 0.0,
double minValue = -1.0e300, double maxValue = 1.0e300);
/* Remove the definition and ALL stored values of the stat. */
bool undefine(const std::string &name);
bool isDefined(const std::string &name) const;
/* Effective value: explicit value, else the definition
* default, else 0. */
double get(uint64_t id, const std::string &name) const;
/* True when an explicit value is stored for (id, name). */
bool has(uint64_t id, const std::string &name) const;
/* Store a value, clamped to the definition range. */
void set(uint64_t id, const std::string &name, double value);
/* get + delta, clamped; returns the new value. */
double add(uint64_t id, const std::string &name, double delta);
/* Drop the explicit value; true when one existed. */
bool clear(uint64_t id, const std::string &name);
/* Drop every stat of one character (despawn/delete cleanup). */
void clearCharacter(uint64_t id);
/* Planner view: every DEFINED stat with its effective value. */
std::unordered_map<std::string, double> snapshot(uint64_t id) const;
using Listener = std::function<void(
uint64_t id, const std::string &name, double newValue)>;
uint64_t subscribe(Listener listener);
void unsubscribe(uint64_t token);
/* Only explicit values, no definitions. */
nlohmann::json serialize() const;
void deserialize(const nlohmann::json &j);
bool saveToFile(const std::string &filepath);
bool loadFromFile(const std::string &filepath);
const std::string &getLastError() const
{
return m_lastError;
}
void autoSave();
void setAutoSaveEnabled(bool enabled)
{
m_autoSaveEnabled = enabled;
}
private:
CharacterStatStore() = default;
void fire(uint64_t id, const std::string &name, double oldValue);
std::unordered_map<std::string, Definition> m_definitions;
std::unordered_map<uint64_t, std::unordered_map<std::string, double> >
m_values;
std::unordered_map<uint64_t, Listener> m_listeners;
uint64_t m_nextListenerToken = 1;
std::string m_autoSavePath = "character_stats.json";
bool m_autoSaveEnabled = true;
std::string m_lastError;
};
#endif // EDITSCENE_CHARACTERSTATSTORE_HPP
@@ -0,0 +1,319 @@
/**
* @file character_stats_test.cpp
* @brief Headless tests for the phase 6.8.9 character stat container.
*
* Links only CharacterStatStore + the Lua stats API (no OGRE, no
* flecs): the store is a plain singleton with opaque character ids
* and the Lua API needs just a Lua state with an "ecs" table.
*
* Covered:
* - define/get/set/add/has/clear with defaults and clamp ranges
* - opaque ids (no character record required)
* - undefine removing the definition AND all values
* - change listeners firing only on effective changes
* - snapshot (planner view with defaults)
* - serialize/deserialize and file round trip
* - Lua API (ecs.stats.*) and C++ <-> Lua sharing
*
* Build with CMake:
* cmake --build <build-dir> --target character_stats_test
* Run:
* <build-dir>/src/features/editScene/character_stats_test
*/
#include <cstdio>
#include <string>
#include "../systems/CharacterStatStore.hpp"
#include "../lua/LuaCharacterStatsApi.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 CharacterStatStore &store()
{
return CharacterStatStore::getInstance();
}
static int testDefineSetGetClear()
{
TEST("define/get/set/has/clear with defaults and clamps");
store().define("health", 100.0, 0.0, 100.0);
if (store().get(1, "health") != 100.0)
FAIL("unset value must return the default");
if (store().has(1, "health"))
FAIL("default is not an explicit value");
store().set(1, "health", 42.0);
if (store().get(1, "health") != 42.0)
FAIL("set/get round trip failed");
if (!store().has(1, "health"))
FAIL("set must store an explicit value");
store().set(1, "health", 250.0);
if (store().get(1, "health") != 100.0)
FAIL("value above max must clamp to max");
store().set(1, "health", -5.0);
if (store().get(1, "health") != 0.0)
FAIL("value below min must clamp to min");
if (!store().clear(1, "health"))
FAIL("clear must report an existing value");
if (store().get(1, "health") != 100.0)
FAIL("clear must fall back to the default");
if (store().clear(1, "health"))
FAIL("second clear must report no value");
PASS();
return 0;
}
static int testAddAndOpaqueIds()
{
TEST("add with clamps and opaque character ids");
store().define("money", 0.0, 0.0, 1.0e9);
/* Ids are opaque keys: no character record is required. */
uint64_t id = (1ull << 40) + 7;
if (store().add(id, "money", 25.5) != 25.5)
FAIL("add must return the new value");
if (store().add(id, "money", -30.0) != 0.0)
FAIL("money below zero must clamp to the floor");
if (store().get(id, "money") != 0.0)
FAIL("clamped add must store the clamped value");
/* An undefined stat still stores and returns values. */
store().set(id, "mystery", 3.0);
if (store().get(id, "mystery") != 3.0)
FAIL("undefined stat must keep explicit values");
/* A different id is fully independent. */
if (store().get(id + 1, "money") != 0.0)
FAIL("stats must be independent per id");
PASS();
return 0;
}
static int testUndefine()
{
TEST("undefine removes the definition and all values");
store().define("stress", 10.0, 0.0, 100.0);
store().set(1, "stress", 50.0);
store().set(2, "stress", 60.0);
if (!store().undefine("stress"))
FAIL("undefine must report an existing definition");
if (store().isDefined("stress"))
FAIL("definition must be gone");
if (store().has(1, "stress") || store().has(2, "stress"))
FAIL("undefine must erase every stored value");
if (store().get(1, "stress") != 0.0)
FAIL("removed stat must read as 0");
if (store().undefine("stress"))
FAIL("second undefine must report no definition");
PASS();
return 0;
}
static int testListeners()
{
TEST("change listeners fire only on effective changes");
store().define("health", 100.0, 0.0, 100.0);
int calls = 0;
double lastValue = -1.0;
uint64_t token = store().subscribe(
[&](uint64_t id, const std::string &name, double value) {
if (id == 1 && name == "health") {
calls++;
lastValue = value;
}
});
store().set(1, "health", 80.0);
if (calls != 1 || lastValue != 80.0)
FAIL("a changed value must fire exactly once");
store().set(1, "health", 80.0);
if (calls != 1)
FAIL("an identical write must not fire");
store().add(1, "health", -30.0);
if (calls != 2 || lastValue != 50.0)
FAIL("add must fire with the new value");
store().clear(1, "health");
if (calls != 3 || lastValue != 100.0)
FAIL("clear must fire with the default");
store().unsubscribe(token);
store().set(1, "health", 10.0);
if (calls != 3)
FAIL("unsubscribed listener must not fire");
PASS();
return 0;
}
static int testSnapshot()
{
TEST("snapshot is the planner view with defaults");
store().define("health", 100.0, 0.0, 100.0);
store().define("money", 5.0, 0.0, 1.0e9);
store().set(9, "money", 12.0);
auto snap = store().snapshot(9);
if (snap.size() != 2)
FAIL("snapshot must contain every defined stat");
if (snap["health"] != 100.0)
FAIL("snapshot must include the default for unset stats");
if (snap["money"] != 12.0)
FAIL("snapshot must include explicit values");
PASS();
return 0;
}
static int testSerializeRoundTrip()
{
TEST("serialize/deserialize keeps explicit values only");
store().define("health", 100.0, 0.0, 100.0);
store().set(3, "health", 33.0);
store().set(4, "mystery", 7.0);
nlohmann::json j = store().serialize();
store().clear(3, "health");
store().clearCharacter(4);
if (store().get(3, "health") != 100.0)
FAIL("test setup: clear failed");
store().deserialize(j);
if (store().get(3, "health") != 33.0)
FAIL("explicit value must survive the round trip");
/* Values of not-yet-redeclared stats survive too. */
if (store().get(4, "mystery") != 7.0)
FAIL("undefined-stat values must survive");
PASS();
return 0;
}
static int testFileRoundTrip()
{
TEST("saveToFile/loadFromFile round trip");
store().set(5, "health", 66.0);
const char *path = "character_stats_test_tmp.json";
if (!store().saveToFile(path))
FAIL("saveToFile failed");
store().clear(5, "health");
if (!store().loadFromFile(path))
FAIL("loadFromFile failed");
if (store().get(5, "health") != 66.0)
FAIL("file round trip lost the value");
std::remove(path);
PASS();
return 0;
}
static int testClearCharacter()
{
TEST("clearCharacter drops all stats of one id only");
store().set(6, "health", 10.0);
store().set(6, "money", 20.0);
store().set(7, "health", 30.0);
store().clearCharacter(6);
if (store().has(6, "health") || store().has(6, "money"))
FAIL("clearCharacter must drop every stat of the id");
if (store().get(7, "health") != 30.0)
FAIL("other ids must be untouched");
PASS();
return 0;
}
static int testLuaApi()
{
TEST("Lua ecs.stats.* round trip and C++ sharing");
lua_State *L = luaL_newstate();
luaL_openlibs(L);
lua_newtable(L);
lua_setglobal(L, "ecs");
editScene::registerLuaCharacterStatsApi(L);
const char *script = R"(
assert(ecs.stats.define{ name = "health", default = 100,
min = 0, max = 100 })
assert(ecs.stats.is_defined("health"))
ecs.stats.set(11, "health", 250)
assert(ecs.stats.get(11, "health") == 100)
assert(ecs.stats.add(11, "health", -70) == 30)
assert(ecs.stats.has(11, "health"))
local snap = ecs.stats.snapshot(11)
assert(snap.health == 30)
assert(ecs.stats.clear(11, "health"))
assert(ecs.stats.get(11, "health") == 100)
assert(ecs.stats.undefine("health"))
assert(not ecs.stats.is_defined("health"))
)";
if (luaL_dostring(L, script) != LUA_OK)
{
std::string err = lua_tostring(L, -1);
lua_close(L);
FAIL("Lua script failed: " + err);
}
/* C++ and Lua share the same store. */
if (store().has(11, "health"))
FAIL("Lua undefine must clear C++-visible values");
lua_close(L);
PASS();
return 0;
}
int main()
{
printf("Character stats tests\n");
/* Never write character_stats.json during tests. */
store().setAutoSaveEnabled(false);
int failures = 0;
failures += testDefineSetGetClear();
failures += testAddAndOpaqueIds();
failures += testUndefine();
failures += testListeners();
failures += testSnapshot();
failures += testSerializeRoundTrip();
failures += testFileRoundTrip();
failures += testClearCharacter();
failures += testLuaApi();
printf("%d/%d tests passed\n", passCount, testCount);
return failures ? 1 : 0;
}