From d5b9ae235e14801a9708e4b6da2c16bac8df16a3 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Wed, 28 Oct 2026 15:56:20 +0300 Subject: [PATCH] Phase 6.8: character stats foundation --- src/features/editScene/AGENTS.md | 41 ++ src/features/editScene/CMakeLists.txt | 27 + src/features/editScene/EditorApp.cpp | 2 + .../editScene/demos/demo-taxi/PLAN.md | 613 +++++++++++++++++- .../lua-examples/character_stats_example.lua | 54 ++ .../editScene/lua/LuaCharacterStatsApi.cpp | 148 +++++ .../editScene/lua/LuaCharacterStatsApi.hpp | 33 + .../editScene/systems/CharacterStatStore.cpp | 208 ++++++ .../editScene/systems/CharacterStatStore.hpp | 116 ++++ .../editScene/tests/character_stats_test.cpp | 319 +++++++++ 10 files changed, 1536 insertions(+), 25 deletions(-) create mode 100644 src/features/editScene/lua-examples/character_stats_example.lua create mode 100644 src/features/editScene/lua/LuaCharacterStatsApi.cpp create mode 100644 src/features/editScene/lua/LuaCharacterStatsApi.hpp create mode 100644 src/features/editScene/systems/CharacterStatStore.cpp create mode 100644 src/features/editScene/systems/CharacterStatStore.hpp create mode 100644 src/features/editScene/tests/character_stats_test.cpp diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index f5f5a21..eb6cdba 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -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 diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 49944f2..8d3f424 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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) # --------------------------------------------------------------------------- diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index d96b56f..4f16c3a 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -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); diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 250f9c7..507c703 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -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 diff --git a/src/features/editScene/lua-examples/character_stats_example.lua b/src/features/editScene/lua-examples/character_stats_example.lua new file mode 100644 index 0000000..55246c0 --- /dev/null +++ b/src/features/editScene/lua-examples/character_stats_example.lua @@ -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) diff --git a/src/features/editScene/lua/LuaCharacterStatsApi.cpp b/src/features/editScene/lua/LuaCharacterStatsApi.cpp new file mode 100644 index 0000000..b5f58f6 --- /dev/null +++ b/src/features/editScene/lua/LuaCharacterStatsApi.cpp @@ -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 diff --git a/src/features/editScene/lua/LuaCharacterStatsApi.hpp b/src/features/editScene/lua/LuaCharacterStatsApi.hpp new file mode 100644 index 0000000..014ee9e --- /dev/null +++ b/src/features/editScene/lua/LuaCharacterStatsApi.hpp @@ -0,0 +1,33 @@ +#ifndef EDITSCENE_LUACHARACTERSTATSAPI_HPP +#define EDITSCENE_LUACHARACTERSTATSAPI_HPP +#pragma once + +#include + +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 diff --git a/src/features/editScene/systems/CharacterStatStore.cpp b/src/features/editScene/systems/CharacterStatStore.cpp new file mode 100644 index 0000000..0daf3c5 --- /dev/null +++ b/src/features/editScene/systems/CharacterStatStore.cpp @@ -0,0 +1,208 @@ +#include "CharacterStatStore.hpp" +#include + +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 +CharacterStatStore::snapshot(uint64_t id) const +{ + std::unordered_map 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(); + } + } + 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); +} diff --git a/src/features/editScene/systems/CharacterStatStore.hpp b/src/features/editScene/systems/CharacterStatStore.hpp new file mode 100644 index 0000000..7de28dd --- /dev/null +++ b/src/features/editScene/systems/CharacterStatStore.hpp @@ -0,0 +1,116 @@ +#ifndef EDITSCENE_CHARACTERSTATSTORE_HPP +#define EDITSCENE_CHARACTERSTATSTORE_HPP +#pragma once + +#include +#include +#include +#include +#include + +/** + * 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 snapshot(uint64_t id) const; + + using Listener = std::function; + 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 m_definitions; + std::unordered_map > + m_values; + std::unordered_map 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 diff --git a/src/features/editScene/tests/character_stats_test.cpp b/src/features/editScene/tests/character_stats_test.cpp new file mode 100644 index 0000000..6d7bfa8 --- /dev/null +++ b/src/features/editScene/tests/character_stats_test.cpp @@ -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 --target character_stats_test + * Run: + * /src/features/editScene/character_stats_test + */ + +#include +#include + +#include "../systems/CharacterStatStore.hpp" +#include "../lua/LuaCharacterStatsApi.hpp" + +extern "C" { +#include +#include +#include +} + +static int testCount = 0; +static int passCount = 0; + +#define TEST(name) \ + do { \ + testCount++; \ + printf(" TEST %d: %s ... ", testCount, name); \ + } while (0) + +#define PASS() \ + do { \ + passCount++; \ + printf("PASS\n"); \ + } while (0) + +#define FAIL(msg) \ + do { \ + printf("FAIL: %s\n", std::string(msg).c_str()); \ + return 1; \ + } while (0) + +static 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; +}