From d28fce7631c2bd35914bcece8ab7b5f1cece5670 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sun, 11 Oct 2026 22:23:43 +0300 Subject: [PATCH] Phase 4.7: in progress --- assets/blender/scripts/gen_vehicle.py | 33 +- assets/blender/vehicles/CMakeLists.txt | 6 +- src/features/editScene/AGENTS.md | 86 ++ src/features/editScene/CMakeLists.txt | 11 + src/features/editScene/EditorApp.cpp | 38 + src/features/editScene/EditorApp.hpp | 19 + .../editScene/ProceduralRoadGeometry.md | 12 + .../editScene/components/NpcLocation.hpp | 33 + .../components/NpcLocationModule.cpp | 20 + .../editScene/demos/demo-taxi/CMakeLists.txt | 12 + .../editScene/demos/demo-taxi/PLAN.md | 419 +++++++++- .../demos/demo-taxi/demo_scene_exterior.json | 30 +- .../4242424300000001/roads/2_2.json | 17 +- .../editScene/layoutlib/BuildingPlan.hpp | 12 +- .../editScene/layoutlib/LayoutEval.cpp | 97 ++- src/features/editScene/main.cpp | 33 + src/features/editScene/roadlib/TownGen.cpp | 2 + src/features/editScene/roadlib/TownGen.hpp | 7 + .../editScene/roadlib/TownLocations.cpp | 164 ++++ .../editScene/roadlib/TownLocations.hpp | 105 +++ .../editScene/roadlib/TownPopulation.cpp | 206 +++++ .../editScene/roadlib/TownPopulation.hpp | 136 +++ .../editScene/roadlib/TownSchedule.cpp | 134 +++ .../editScene/roadlib/TownSchedule.hpp | 80 ++ .../editScene/systems/CharacterRegistry.cpp | 69 +- .../editScene/systems/CharacterRegistry.hpp | 36 +- .../editScene/systems/EditorSunSystem.cpp | 18 +- .../editScene/systems/GameClockSystem.cpp | 71 ++ .../editScene/systems/GameClockSystem.hpp | 101 +++ .../editScene/systems/NpcPopulationSystem.cpp | 285 +++++++ .../editScene/systems/NpcPopulationSystem.hpp | 106 +++ .../systems/NpcScheduleDebugOverlay.cpp | 200 +++++ .../systems/NpcScheduleDebugOverlay.hpp | 37 + .../editScene/systems/SceneSerializer.cpp | 32 + .../editScene/systems/SceneSerializer.hpp | 3 + .../editScene/systems/TownGenSystem.cpp | 41 + .../editScene/systems/TownGenSystem.hpp | 10 + .../tests/road_geometry_overlap_test.cpp | 775 ++++++++++++++++++ .../editScene/ui/NpcLocationEditor.cpp | 51 ++ .../editScene/ui/NpcLocationEditor.hpp | 20 + src/features/editScene/ui/SunEditor.hpp | 4 + src/features/editScene/ui/TerrainEditor.hpp | 32 + 42 files changed, 3516 insertions(+), 87 deletions(-) create mode 100644 src/features/editScene/components/NpcLocation.hpp create mode 100644 src/features/editScene/components/NpcLocationModule.cpp create mode 100644 src/features/editScene/roadlib/TownLocations.cpp create mode 100644 src/features/editScene/roadlib/TownLocations.hpp create mode 100644 src/features/editScene/roadlib/TownPopulation.cpp create mode 100644 src/features/editScene/roadlib/TownPopulation.hpp create mode 100644 src/features/editScene/roadlib/TownSchedule.cpp create mode 100644 src/features/editScene/roadlib/TownSchedule.hpp create mode 100644 src/features/editScene/systems/GameClockSystem.cpp create mode 100644 src/features/editScene/systems/GameClockSystem.hpp create mode 100644 src/features/editScene/systems/NpcPopulationSystem.cpp create mode 100644 src/features/editScene/systems/NpcPopulationSystem.hpp create mode 100644 src/features/editScene/systems/NpcScheduleDebugOverlay.cpp create mode 100644 src/features/editScene/systems/NpcScheduleDebugOverlay.hpp create mode 100644 src/features/editScene/ui/NpcLocationEditor.cpp create mode 100644 src/features/editScene/ui/NpcLocationEditor.hpp diff --git a/assets/blender/scripts/gen_vehicle.py b/assets/blender/scripts/gen_vehicle.py index e996d76..f0d2446 100644 --- a/assets/blender/scripts/gen_vehicle.py +++ b/assets/blender/scripts/gen_vehicle.py @@ -32,7 +32,15 @@ # chassis frame). See assets/blender/vehicles/CAR_MODELING.md. # # Usage: -# blender .blend -b -Y -P gen_vehicle.py -- .glb .vehicle.json +# blender .blend -b -Y -P gen_vehicle.py -- .glb \ +# .vehicle.json [downforce] +# +# downforce (optional, default 25): aerodynamic downforce coefficient +# (N per (m/s)^2, straight down at the COM while moving) — the +# speed-dependent grip lever: at speed it multiplies the tire normal +# load (and thus the lateral impulse budget) without adding inertia, +# which is exactly where plain tires start understeering. Higher = +# tighter turns at speed. import os import sys @@ -58,6 +66,7 @@ except: glb_file = argv[0] json_file = argv[1] +downforce = float(argv[2]) if len(argv) > 2 else 25.0 outdir = os.path.dirname(glb_file) base = os.path.splitext(os.path.basename(bpy.data.filepath))[0] @@ -294,7 +303,9 @@ SUSP_MAX = 0.35 # Softer than the 1.8/0.7 starting point: gives the body some slack # over bumps instead of pogo-stiff wheels (which also cost grip). SUSP_FREQ = 1.3 -SUSP_DAMP = 0.6 +# Critical-ish damping: less and the body rocks for seconds after a +# bump (the "almost endlessly bouncy" report). +SUSP_DAMP = 1.0 SUSP_REST = SUSP_MAX - 9.81 / (2.0 * math.pi * SUSP_FREQ) ** 2 for w in sorted(wheel_info, key=lambda w: w["pos"].y): front = w["pos"].y < axle_mid_y @@ -320,6 +331,17 @@ for s in seat_info: "driver": s["driver"], }) +# Center of mass: a box collider puts the COM at the box center — for +# a sedan that is ~1 m above the tire contact patches (twice the real +# value) and the car trips onto its side in hard corners (the taxi, +# with its taller box, tips even easier). Shift the COM to just above +# wheel-hub height instead (Jolt OffsetCenterOfMassShape: only the +# physics COM moves — geometry, wheel attachments and the render node +# stay put). +collider_center_ogre_y = (smx.z + smn.z) / 2.0 +hub_ogre_y = sum(w["pos"].z for w in wheel_info) / len(wheel_info) +com_offset_y = round(hub_ogre_y + 0.1 - collider_center_ogre_y, 4) + driver_offset = ogre_vec3(driver_seats[0]["pos"]) children = [] @@ -410,6 +432,13 @@ vehicle = { # soft 1.3 Hz springs a full-lock turn otherwise rolls the car past # 45 deg onto two wheels. "antiRollBarStiffness": 2000.0, + "centerOfMassOffset": {"x": 0.0, "y": com_offset_y, "z": 0.0}, + # Aerodynamic downforce (N per (m/s)^2, straight down at the COM + # while moving): presses the car onto its suspension at speed — + # less bounce, and a bigger lateral impulse budget exactly where + # plain tires start understeering (turn radius stops growing with + # speed). Per-model value from the CMake invocation. + "downforceCoefficient": downforce, } if steering: vehicle["steeringWheelMeshName"] = base + "-steering-wheel.mesh" diff --git a/assets/blender/vehicles/CMakeLists.txt b/assets/blender/vehicles/CMakeLists.txt index 79dba90..702b24b 100644 --- a/assets/blender/vehicles/CMakeLists.txt +++ b/assets/blender/vehicles/CMakeLists.txt @@ -28,6 +28,10 @@ endforeach() # generated scene-entity JSON fragment (.vehicle.json, see # assets/blender/scripts/gen_vehicle.py and vehicles/CAR_MODELING.md). set(VEHICLES_GEN_SRC sedan-v2.blend sedan-v2-taxi.blend) +# Per-model downforce coefficient (third gen_vehicle.py argument): +# heavier/taller handling gets more speed-grip. +set(DOWNFORCE_sedan-v2 35) +set(DOWNFORCE_sedan-v2-taxi 45) foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC}) get_filename_component(FILE_NAME ${VEHICLE_FILE} NAME_WE) @@ -39,7 +43,7 @@ foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC}) COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/gen_vehicle.py - -- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE} + -- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE} ${DOWNFORCE_${FILE_NAME}} COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.glb -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake COMMAND touch ${VEHICLE_OUTPUT_FILE} diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 00efd7b..73ca02a 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -520,6 +520,92 @@ bool isSpawnerLocked(flecs::entity spawner) const; Spawned characters have `EditorMarkerComponent` removed and are removed from `EditorUISystem` caches so they don't appear in editor lists. +### NpcPopulationSystem (demo-taxi phase 4.7.2) + +Deterministic town populations in the `CharacterRegistry`. The pure generator +`roadlib/TownPopulation::generatePopulation()` (TownResult + PopulationRules + +seed, headless-testable — probe `runTownPopulationProbe` in +road_geometry_overlap_test) creates one household per building with +attribute-driven members (sex/age band/ageYears/outfitLevel) and seeded +directed relationships (spouse/parent/child/sibling); every building draws +from its own RNG stream (`popSeed ^ fnv1a(address)`, popSeed = +`area.seed ^ 0x9e3779b9`) and gets its own registry id band +(`idBase + building * HOUSEHOLD_ID_CAP(64) + member`, `idBase = +NPC_ID_BASE(1<<40) + areaIndex * AREA_POP_CAP(1<<20)`; household group ids at +`idBase + AREA_POP_CAP/2 + household`), so editing one lot never shifts +another lot's household or ids. `NpcPopulationSystem` (singleton +`getInstance()`, owned by EditorApp) re-runs the area's PURE town generation +(`TownGenSystem::runAreaGeneration`, no entities) so the population always +matches the generated/streamed buildings, and applies it as runtime-only +registry entries (`npcKey`/`npcHome`/`npcArea` string columns, symbolic +position at the home building); regenerating an area replaces exactly its +recorded ids. Registry additions for this: `GroupRecord::persistent` +(runtime groups are skipped by the serializer, as are relationships with +runtime/dangling endpoints), `ensureRuntimeCharacter`/`ensureRuntimeGroup` +(caller-chosen ids >= 1<<40), and `setAutoSaveSuspended` for bulk application. +Editor: Town Generator panel "Generate Population"; CLI: +`--generate-town-population` (implies `--generate-town-areas`, prints the NPC +count headlessly). + +### TownLocations + NpcLocationComponent (demo-taxi phase 4.7.3) + +The symbolic location model. `roadlib/TownLocations::buildLocations()` +(pure, headless — probe `runTownLocationsProbe` in +road_geometry_overlap_test) builds a per-town `LocationSet`: one Home per +building (at the building center), one Curb per pickup POI and one Parking +per parking POI (`TownGen::TownPoi` has a `kind` field — Pickup/Parking — no +name-suffix parsing), plus an Interest location per building whose hash roll +(`seed ^ fnv1a(address)`, no shared stream) passes the interest ratio; +Work/Leisure kinds exist but nothing generates them yet. +`NpcPopulationSystem` keeps the set per area (`areaLocations(areaIndex)`) and +writes deterministic per-NPC bindings into registry stringColumns: `npcWork` +(from the Interest locations — the workplace placeholder until zoning +exists), `npcInterest` (Interest+Curb), both falling back to the home +location, which is `"home:" + npcHome`. Authored locations use +`NpcLocationComponent` (kind as int mirroring `LocationKind`, name, radius; +serialized as `npcLocation`, editor widget in the Game group); merging +authored markers into the generated set is deferred to the motion phase. + +Layout support: the DSL `room` verb has a `:type ` option and +`BuildingPlan::roomTypes` parallels `roomNames`; room names/ids/types now +register in BOTH shell modes (in filled-shell mode this is metadata only, no +geometry change). Filled-shell room rects use the rooms-first half-open +`lround(frac * cells)` snapping so fraction-neighboring rooms always share a +boundary index and `idoor` works at any shell size. The default layout +declares a typed living room + bedroom on floor 0 with an internal door (the +bed invariant for phase 8 interiors); exterior geometry and the TownGen RNG +draw order are unchanged. + +### TownSchedule + GameClockSystem (demo-taxi phase 4.7.4) + +`roadlib/TownSchedule::generateSchedules()` (pure — probe +`runTownScheduleProbe`) builds one schedule per NPC tiling [0, 24) with +(location, activity) entries: sleep/home at the home location, a work block +for adults at the work binding, an optional errand block at the interest +binding (children/elders take a daytime interest visit instead of work). +Per-NPC mt19937 streams seeded `seed ^ fnv1a(stableKey)` keep lot edits +isolated. The location bindings live in the shared pure +`TownLocations::computeBindings()`; the candidate picks are rendezvous-hashed +(highest-random-weight over candidate names), so a location appearing or +vanishing rebinds only the NPCs that referenced it. `NpcPopulationSystem` +stores the schedules per area — `scheduleOf(registryId)` + +`TownSchedule::entryAt(schedule, timeOfDay)` give the current goal. + +Verification: `NpcScheduleDebugOverlay` (game mode, toggled with **F7**) shows +the clock (pause/speed/time-of-day controls), the population NPCs grouped by +town area with the schedule entry active at the current time, and the +selected NPC's full 24h schedule + home/work/interest bindings. + +`GameClockSystem` (EditorApp-owned singleton `getInstance()`) is the one +queryable clock: day counter + time-of-day hours, pauseable, speed in +game-hours per real second, `setTime(day, hours)`. With `drivesSun` (the +default) it adopts the first SunComponent's `timeOfDay`/`timeSpeed` on sight +and writes the clock time to every sun each frame; an external edit (the sun +panel sliders, which deliberately do NOT markDirty — dirty means a full +light/sphere rebuild) is detected by divergence from the last written value +and re-adopted. `EditorSunSystem` skips its own advance while driven. +Demos without a GameClockSystem behave exactly as before. + ### EditorSkyboxSystem / EditorSunSystem The procedural sky (`Skybox/Dynamic` material) is rendered as a fullscreen diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index a9ff15d..aceefc4 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -32,6 +32,9 @@ set(EDITSCENE_SOURCES systems/RoadRegionStore.cpp systems/RoadSystem.cpp systems/TownGenSystem.cpp + systems/NpcPopulationSystem.cpp + systems/GameClockSystem.cpp + systems/NpcScheduleDebugOverlay.cpp systems/LightSystem.cpp systems/CameraSystem.cpp systems/LodSystem.cpp @@ -92,6 +95,8 @@ set(EDITSCENE_SOURCES components/PushableModule.cpp ui/TargetZoneEditor.cpp components/TargetZoneModule.cpp + ui/NpcLocationEditor.cpp + components/NpcLocationModule.cpp systems/ZoneSystem.cpp systems/GameHudSystem.cpp systems/EventBus.cpp @@ -851,6 +856,12 @@ add_library(RoadGeometryLib STATIC roadlib/RoadNetworkGen.hpp roadlib/TownGen.cpp roadlib/TownGen.hpp + roadlib/TownPopulation.cpp + roadlib/TownPopulation.hpp + roadlib/TownLocations.cpp + roadlib/TownLocations.hpp + roadlib/TownSchedule.cpp + roadlib/TownSchedule.hpp ) target_include_directories(RoadGeometryLib PUBLIC diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 217800f..b58a0ab 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -26,6 +26,9 @@ #include "systems/ProceduralMeshSystem.hpp" #include "systems/CharacterSlotSystem.hpp" #include "systems/CharacterSpawnerSystem.hpp" +#include "systems/NpcPopulationSystem.hpp" +#include "systems/GameClockSystem.hpp" +#include "systems/NpcScheduleDebugOverlay.hpp" #include "components/CharacterSpawner.hpp" #include "systems/TerrainPrefabSpawnerSystem.hpp" #include "components/TerrainPrefabSpawner.hpp" @@ -110,6 +113,7 @@ #include "components/Actuator.hpp" #include "components/Pushable.hpp" #include "components/TargetZone.hpp" +#include "components/NpcLocation.hpp" #include "components/GoapPlanner.hpp" #include "components/GoapRunner.hpp" #include "components/PathFollowing.hpp" @@ -196,6 +200,10 @@ void ImGuiRenderListener::preViewportUpdate( gameHud->update(m_deltaTime); gameHud->render(); } + NpcScheduleDebugOverlay *npcDbg = + m_editorApp->getNpcScheduleDebugOverlay(); + if (npcDbg) + npcDbg->render(); } /* Scene-switch loading cover: fullscreen black overlay for the @@ -426,6 +434,7 @@ void EditorApp::destroyEditorSystems() m_animationTreeSystem.reset(); m_characterSlotSystem.reset(); m_characterSpawnerSystem.reset(); + m_npcPopulationSystem.reset(); /* Despawn prefab instances before terrain/physics teardown. */ m_terrainPrefabSpawnerSystem.reset(); m_proceduralMeshSystem.reset(); @@ -560,6 +569,10 @@ void EditorApp::setup() /* F12 game HUD overlay (ImGui, Lua-driven). */ m_gameHudSystem = std::make_unique(this); + /* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */ + m_npcScheduleDebugOverlay = + std::make_unique(this); + // Setup buoyancy system (requires physics system) // Get the physics wrapper from the physics system m_buoyancySystem = std::make_unique( @@ -657,6 +670,17 @@ void EditorApp::setup() m_uiSystem.get()); m_characterSpawnerSystem->initialize(); + /* Town population (demo-taxi phase 4.7.2): deterministic + * per-area NPC households in the CharacterRegistry. */ + m_npcPopulationSystem = + std::make_unique(m_world); + + /* Game clock (demo-taxi phase 4.7.4): the queryable + * day + time-of-day source for the schedule layer; + * drives the sun so visuals and schedules agree. */ + m_gameClockSystem = + std::make_unique(m_world); + // Setup TerrainPrefabSpawner system (M6): needs the camera // system for distance checks and physics for body cleanup on // despawn; must exist before the terrain system updates. @@ -2073,6 +2097,9 @@ void EditorApp::setupECS() // Register Target Zone component (F11) m_world.component(); + // Register NPC Location marker component (demo-taxi phase 4.7.3) + m_world.component(); + // Register Event Handler component m_world.component(); @@ -2512,6 +2539,9 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) } /* --- Rendering support systems --- */ + /* The game clock runs first: it drives the sun's timeOfDay. */ + if (m_gameClockSystem) + m_gameClockSystem->update(fixedEvt.timeSinceLastFrame); if (m_sunSystem) { Ogre::Camera *cam = nullptr; if (m_sceneMgr->hasCamera("PlayerCamera")) @@ -2750,6 +2780,14 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt) return true; } + /* F7 toggles the NPC schedule debug overlay (demo-taxi + * phase 4.7.4). */ + if (evt.keysym.sym == OgreBites::SDLK_F7 && !evt.repeat) { + if (m_npcScheduleDebugOverlay) + m_npcScheduleDebugOverlay->toggle(); + return true; + } + /* Ignore repeated keydowns for movement keys. Releasing Shift * while W/A/S/D is held can generate a repeated keydown with a * different symbol after the physical release, which would diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index e4a8ec1..c3e2cfb 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -17,6 +17,7 @@ class EditorUISystem; class VehicleSystem; class ZoneSystem; class GameHudSystem; +class NpcScheduleDebugOverlay; class EditorCamera; class EditorPhysicsSystem; class EditorLightSystem; @@ -28,6 +29,8 @@ class ProceduralMaterialSystem; class ProceduralMeshSystem; class CharacterSlotSystem; class CharacterSpawnerSystem; +class NpcPopulationSystem; +class GameClockSystem; class TerrainPrefabSpawnerSystem; class AnimationTreeSystem; class HairPhysicsSystem; @@ -329,6 +332,14 @@ public: { return m_characterSpawnerSystem.get(); } + NpcPopulationSystem *getNpcPopulationSystem() const + { + return m_npcPopulationSystem.get(); + } + GameClockSystem *getGameClockSystem() const + { + return m_gameClockSystem.get(); + } PlayerControllerSystem *getPlayerControllerSystem() const { return m_playerControllerSystem.get(); @@ -353,6 +364,10 @@ public: { return m_gameHudSystem.get(); } + NpcScheduleDebugOverlay *getNpcScheduleDebugOverlay() const + { + return m_npcScheduleDebugOverlay.get(); + } /* Shared Lua state (tests/tools; scripts access it via the ecs API). */ editScene::LuaState *getLuaState() { @@ -448,6 +463,8 @@ private: std::unique_ptr m_proceduralMeshSystem; std::unique_ptr m_characterSlotSystem; std::unique_ptr m_characterSpawnerSystem; + std::unique_ptr m_npcPopulationSystem; + std::unique_ptr m_gameClockSystem; std::unique_ptr m_terrainPrefabSpawnerSystem; std::unique_ptr m_animationTreeSystem; std::unique_ptr m_hairPhysicsSystem; @@ -476,6 +493,8 @@ private: std::unique_ptr m_vehicleSystem; std::unique_ptr m_zoneSystem; std::unique_ptr m_gameHudSystem; + /* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */ + std::unique_ptr m_npcScheduleDebugOverlay; /* F10 enter/exit + drive input for vehicles (game mode). */ std::unique_ptr m_vehicleControllerSystem; diff --git a/src/features/editScene/ProceduralRoadGeometry.md b/src/features/editScene/ProceduralRoadGeometry.md index 356fbec..515f511 100644 --- a/src/features/editScene/ProceduralRoadGeometry.md +++ b/src/features/editScene/ProceduralRoadGeometry.md @@ -1913,6 +1913,18 @@ Per-room window suppression: `(room name (rect ...) :windows none)` flags the room (bathroom/storage), and both window passes (perimeter walk and union-boundary) skip its cells — declare rooms before the `windows` verb; probe-verified in both shell modes. +Room types (phase 4.7.3): `(room name (rect ...) :type bedroom)` +tags the room in `BuildingPlan::roomTypes` (parallel to +`roomNames`), and room names/ids/types now register in BOTH shell +modes — in filled-shell mode this is metadata only (roomIds filled, +no geometry change) so the location/interior generators can read +rooms from any layout. Filled-shell room rects use the rooms-first +half-open `lround(frac * cells)` snapping, so fraction-neighboring +rooms always share a boundary index and `idoor` works at any shell +size (odd cell counts included). The default layout declares a +typed living room + bedroom on floor 0 with an internal door (the +bed invariant); exterior geometry and the TownGen RNG draw order are +unchanged. Regen/clear teardown is synchronous (`TownGenSystem::destroyEntityDeep` — children first, then physics body, TriangleBuffer Ogre entity + mesh, renderable, settlement material/texture, scene node last); the diff --git a/src/features/editScene/components/NpcLocation.hpp b/src/features/editScene/components/NpcLocation.hpp new file mode 100644 index 0000000..fd94de9 --- /dev/null +++ b/src/features/editScene/components/NpcLocation.hpp @@ -0,0 +1,33 @@ +#ifndef EDITSCENE_NPC_LOCATION_HPP +#define EDITSCENE_NPC_LOCATION_HPP +#pragma once + +#include + +/** + * NPC location marker component (demo-taxi phase 4.7.3). + * + * Authors a symbolic location by hand: an entity with this component + * marks a named place NPCs can bind to and travel between (their + * TransformComponent position is the location position). Generated + * town locations come from roadlib/TownLocations; authored markers + * are for places the generator cannot know (a bus stop, a taxi rank, + * a landmark) and are merged into the location set by the population + * / motion systems. + * + * `kind` mirrors TownLocations::LocationKind as an int so the + * component stays free of the roadlib dependency: + * 0 = home, 1 = work, 2 = leisure, 3 = interest, 4 = curb, + * 5 = parking. + */ +struct NpcLocationComponent { + /* TownLocations::LocationKind as int (see above). */ + int kind = 3; /* Interest */ + /* Unique location name (the binding key stored on NPCs). */ + Ogre::String name; + /* Arrival radius in meters (how close is "at" the location). */ + float radius = 2.0f; + bool enabled = true; +}; + +#endif // EDITSCENE_NPC_LOCATION_HPP diff --git a/src/features/editScene/components/NpcLocationModule.cpp b/src/features/editScene/components/NpcLocationModule.cpp new file mode 100644 index 0000000..18f1422 --- /dev/null +++ b/src/features/editScene/components/NpcLocationModule.cpp @@ -0,0 +1,20 @@ +#include "NpcLocation.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/NpcLocationEditor.hpp" + +REGISTER_COMPONENT_GROUP("NpcLocation", "Game", NpcLocationComponent, + NpcLocationEditor) +{ + registry.registerComponent( + "NpcLocation", "Game", std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/demos/demo-taxi/CMakeLists.txt b/src/features/editScene/demos/demo-taxi/CMakeLists.txt index 22800ce..daae1cb 100644 --- a/src/features/editScene/demos/demo-taxi/CMakeLists.txt +++ b/src/features/editScene/demos/demo-taxi/CMakeLists.txt @@ -85,6 +85,18 @@ foreach(DEMO_ROAD_REGION_FILE ${DEMO_ROAD_REGION_FILES}) COPYONLY) endforeach() +# Sync-back helper for the source-authoritative region files above: +# edits made while running from the build directory (lot params, layout +# DSL, road tweaks saved by the editor) live only in the staging copy +# until they are copied back, and the next configure overwrites them. +# Run `cmake --build build-vscode --target sync_road_regions` after +# editing roads/lot params in the editor, then commit the source files. +add_custom_target(sync_road_regions + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/roads" + "${CMAKE_CURRENT_SOURCE_DIR}/heightmaps/4242424300000001/roads" + COMMENT "Syncing road region files back to the source tree") + add_executable(demoTaxi demo_main.cpp ${DEMO_SOURCES} diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 6b5cdfe..5003519 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -1573,7 +1573,7 @@ lots assigned by centroid — no building store needed); town areas on when streaming). Implementation order: §21.6 (edge params -> DSL -> splitting -> areas -> per-page streaming). -### Phase 4.6 — Room-composed buildings + per-building regeneration +### Phase 4.6 — Room-composed buildings + per-building regeneration (COMPLETE 2026-10-10, verified in the editor) **Rooms-first layout DSL (additive, complements shell+cut).** The shell declares the overall building rect, rooms fill it by relative @@ -1667,10 +1667,11 @@ button next to the edge-side Layout field (shared core gained live DSL validation (`TerrainEditor::checkLayoutSource()` — representative bindings, scratch RNG; red error / yellow warnings / green "OK: NxM cells, F floor(s)" under the field), a "Pop Out" button -opening a floating 16 KB editor bound to the edge by node ids +opening a floating editor bound to the edge by node ids (`renderLayoutPopout()`, with its own validation, template insert and -Regenerate), and the Town Generator panel lists the last run's -warnings (`TownGenSystem::getLastWarnings()`). New probe in +Regenerate; its initial fixed 16 KB buffer was replaced by direct +`RoadLotParams::layout` editing on 2026-10-10, see below), and the +Town Generator panel lists the last run's warnings (`TownGenSystem::getLastWarnings()`). New probe in `runTownGenProbe()`: a layout edit on one edge side leaves every other building bit-identical and `paramsEdge`/`paramsSide` are recorded for all 12 buildings of the probe town. @@ -1739,28 +1740,396 @@ Status (2026-10-10, density modest variant + editor/DSL fixes): The CellGrid shell was unaffected — it binds the material by NAME, which Ogre resolves once the material exists. -### Phase 4.7 NPC motion -- it is about general abstract NPC motion simulation in exterior scene, to add life to the scene and still be believable. -- the complicates comes from the goal of the motion to be beliavable so it should have a reason. -- some of features needed for this are either implemented or partially implemented on NPC level and should be found, if any and reused or modified or discarded i unfeasible within a reason. -- There is GOAP implementation in the tree, but if HTN is superior, it should be implemented. -- the function is centered around NPC having a target to get to. -- should be controlled by dedicated system -- NPCs are deterministic and are generated by the town using per-lot settings -- House bed count should be equal or more to NPC count. The bed should be registered to NPC (or bed side for bigger/composite beds) (after we start handling interiors) -- NPCs are defined as families/groups with members having relationships set -- NPCs name/sex/stats/needs growths/etc. values are set there too. Whatever not set is generated. Rules should be per-town. -- NPCs are generated via character registry by creating dynamic entries but using pre-defined registry character can be used. -- Town influences NPC schedule by setting their goal. -- NPC motion system tracks NPC motion by symbolic locations like home/work/leisure/interest, etc. which are defined per NPC and generated if not defined. -- NPC can be at the location running action/smart object or between them. -- If NPC is in between, they join traffic (phase 5 or 6) -- The motion simulation can be low detail or high detail. Low detail simulation just changes NPC world position and applys effects, no NPC spawned. High detail simulation spawns NPC and or the vehicle and provides full simulation. -- Traffic connects locations to each other. The path between locations is generated using AStar, using navmesh or other algorithms. -- Traffic is used only for exterior. Interior uses completely different systems based on GOAP or HTN and behavior trees. NPC motion system uses goals and delivers NPCs to simulate live town being believable but without complex computations. Basically it simulates NPC presence and motion at location at time so if player gets to that location and NPC is moving to the place they see NPC walking or driving. NPCs should realistically leave buildings, enter vehicles or walk, park and leave vehicles ane enter buildings. Entering building means NPC despawn from exterior scene by activating the front door. Not all targets are inside buildings, exterior locations should be supporterd too. It should be easy to author locations (interior/exterior). -- Taxi mission should have NPCs near player to have special goal of using the taxi and waiting to be picked up. However out of the mission, NPCs should be able to call the taxi and transit by it. +### Phase 4.7 — NPC motion (believable exterior life) + +**Goal.** A general, abstract NPC motion simulation for the exterior +scene: the town feels alive and stays believable when observed. +Believability is the hard part — every NPC's motion must have a +*reason* (a goal and a place it connects), not random wandering. The +simulation must stay cheap with a whole town population: full detail +only exists near the player, everything else is symbolic. + +**Design principles.** + +- Every NPC is goal-centered: at any moment an NPC is either AT a + symbolic location (running an action/smart object there) or IN + TRANSIT between two locations. No free-floating NPCs. +- Two detail tiers. LOW: the NPC is not spawned — the simulation just + advances its world position along a path and applies effects (needs + decay, schedule progress). HIGH: the NPC (and later its vehicle, + phase 5) is spawned and fully simulated. Crossing INTO high detail + mid-transit spawns the NPC on the path; crossing out mid-transit + despawns back to the symbolic position — both must look seamless. +- Deterministic: population, schedules and positions at time T are a + pure function of the town seed and T (same guarantee style as + TownGen's per-lot streams), so save/load and regen agree. +- Exterior only. Interiors run on completely different systems + (GOAP/HTN + behavior trees, phase 8+); this phase only has to make + enter/exit believable (walk to the front door, activate it, despawn + from the exterior — and the reverse on leaving). + +**Existing infrastructure — reuse, modify or discard (4.7.1 audit).** + +- `CharacterRegistry` (already implemented): records with + name/sex/age/stats/skills/needs/pools, `GroupRecord` for families, + the relationship graph, tags and custom columns, and runtime-only + (non-persisted) entries — the NPC registry requirement is mostly + covered; generated towns create dynamic entries, authored + (persistent) registry characters can be placed per lot explicitly. +- `CharacterIdentityComponent` links a spawned entity to its registry + record; the record's `version` counter already triggers respawns. +- `CharacterSpawnerSystem`: distance-based spawn/despawn — currently + per-spawner distances; this phase adds a GLOBAL configurable + spawn/despawn distance for simulated NPCs with anti-flapping + (despawn distance > spawn distance hysteresis + a minimum dwell + time), reusing the existing lock/force-spawn mechanics. +- Root-motion locomotion and path steering for spawned NPCs are + already implemented (same movement style as the player character: + `AnimationTreeComponent::useRootMotion` + navmesh steering); + low-detail NPCs instead move by direct position updates along the + path. +- `NavMeshSystem` (Recast/Detour) for path queries; the road graph + (nodes/edges/sidewalks, `TownPoi`s) as the coarse A* layer. +- `ActuatorSystem` smart objects for at-location actions; `DoorSystem` + for front-door activation on enter/exit. +- Legacy `src/aitoolkit` GOAP and the legacy CharacterAIModule are + read-only references — harvest ideas, do not modify. The GOAP-vs-HTN + decision is for interior AI (phase 8); this phase's schedule/goal + layer is deliberately simpler (deterministic schedule + current + goal), so HTN is only pulled in if the audit shows goals need real + planning. + +**4.7.1 audit outcome (2026-10-10).** + +Reuse as-is: + +- `CharacterRegistry` (`systems/CharacterRegistry.hpp`) — the data + model already covers this phase: `CharacterRecord` (names, + stats/skills/needs/pools, `age` category string + `ageYears`, tags, + custom int/float/string columns, inline appearance — + `inlineSex`/`inlineOutfitLevel`/`inlineSlotSelections`/ + `inlineShapeKeyWeights`, i.e. the look is already attribute-driven), + `GroupRecord` households, runtime-only entries (`persistent=false`, + IDs allocated from `1<<32` up), Markov name generators, the + `version` respawn counter, and `position`/`rotation` write-back on + despawn — the natural symbolic-position store. +- `Relationship` — ALREADY directed (source → target) with + open-ended float `stats` and keyword `tags`, so the 4.7.2 + relationship model needs NO schema change, only generation code + seeding `spouse`/`sibling`/`child`/`acquainted`/`friend`. +- PathFollowingSystem + NavMeshSystem + root motion — the + high-detail walk stack is complete (navmesh path corridor, door + auto-open, walk/run animation states); road/sidewalk chunks carry + `NavMeshGeometrySource`, so generated towns are navmesh-covered. +- CharacterSpawnerSystem — O(spawners) distance gate with + lock/force-spawn API; the spawner transform override is the seam + for mid-transit spawn-at-position. +- Current GOAP (`GoapPlannerSystem`/`GoapRunnerSystem` in + src/features/editScene) stays for high-detail at-location actions + of SPAWNED NPCs only — per-entity A* planning is too heavy for a + whole-town population, so the exterior schedule layer is + deterministic instead of planned. + +Extend: + +- CharacterSpawnerSystem: GLOBAL spawn/despawn distance mode for + population NPCs (overrides per-spawner distances), dwell-time + anti-flapping, and a per-frame spawn budget (prefab instantiation + + CharacterSlotSystem mesh build is the expensive part — a burst + hitches). +- PathFollowingSystem::handleDoorAhead: town front doors are + `lockedByDefault` and scene-switch doors are skipped, so NPCs + currently cannot enter any building. Needs per-NPC door + authorization (residents pass their own door) and an enter/exit + hook (despawn-to-symbolic on entering, spawn-from-symbolic on + leaving). +- TownGenSystem: keep the `TownResult` alive per area/page instead of + discarding it after building creation — the population and location + generators consume it. `TownPoi` gains a kind field + (curb pickup / parking) instead of name-suffix parsing. +- Clock: `SunComponent::timeOfDay` advances by wall-clock deltaTime + with no day counter and no central accessor — the schedule layer + needs a queryable game clock (day + timeOfDay, pauseable, fixed-step + friendly). +- BuildingPlan/DSL: room TYPE metadata (bedroom/...) on top of + roomIds/roomNames; default layouts gain a bedroom (the 4.7.3 + bed invariant). + +New: + +- roadlib `TownPopulation` (pure, headless): TownResult + per-area + population rules + seed → households, attribute-driven members, + seeded relationships, home bindings. Deterministic registry IDs + derived from (area seed, address, member index). +- Location model: symbolic locations (home/work/leisure/curb/ + parking) built from TownResult; a location marker component for + authored locations. +- `NpcPopulationSystem` (ECS side): applies TownPopulation to the + CharacterRegistry as runtime-only entries, owns the per-NPC + location bindings. +- `NpcMotionSystem` (4.7.5): AT_LOCATION/IN_TRANSIT symbolic state; + positions a pure function of (schedule, T); coarse A* over the + RoadGraph (an adjacency index + edge-length cost do not exist yet). + +Read-only references: + +- Legacy `src/aitoolkit` GOAP + `src/gamedata` TownAI — harvest + ideas only (BitEvolutionBuilder-style time-driven need decay, + utility scoring). +- HTN: a full HTN planner framework lives OUTSIDE this repo at + `../../htn_planner` (HTNFramework + domains + editor). It + requires C++20 and must be converted to C++11 before it can be + adopted here; the GOAP-vs-HTN decision is therefore deferred to + the interior AI phase (8) — 4.7's schedule/goal layer needs no + planner. + +**Sub-phases.** + +- **4.7.1 Reuse audit + planner decision.** COMPLETE 2026-10-10 — + outcome recorded above. +- **4.7.2 Town population generation.** IN PROGRESS (core landed + 2026-10-10): `roadlib/TownPopulation` (pure, headless) generates one + household per building — per-building RNG stream seeded + `popSeed ^ fnv1a(address)` (popSeed = `area.seed ^ 0x9e3779b9`), so + editing one lot never shifts another lot's household; deterministic + registry ids from per-building bands (`idBase + building * 64 + + member`, `idBase = NPC_ID_BASE(1<<40) + areaIndex * AREA_POP_CAP(1<<20)`, + household groups at `idBase + AREA_POP_CAP/2 + household`). Members + are attribute-driven (sex, age band + ageYears, outfitLevel) and + households seed the directed spouse/parent/child/sibling + relationships. `NpcPopulationSystem` (singleton, + `TerrainSystem::getTownGenSystem()`-style) applies the population to + the CharacterRegistry as runtime-only entries (`npcKey`/`npcHome`/ + `npcArea` string columns, symbolic position at the home building); + regeneration replaces exactly the area's recorded ids. Registry + additions: runtime `GroupRecord`s (`persistent` flag), + `ensureRuntimeCharacter`/`ensureRuntimeGroup` (caller-chosen ids >= + 1<<40), an auto-save suspension guard for bulk application, and the + serializer skipping non-persistent groups and relationships with + runtime/dangling endpoints. Editor: Town Generator panel "Generate + Population" button; CLI: `--generate-town-population` (implies + `--generate-town-areas`). Probe: `runTownPopulationProbe` in + road_geometry_overlap_test (determinism, per-building isolation, + composition/relationship/id invariants). Still to do: per-town + RULES living on the town area definition (currently hardcoded + defaults), per-lot household steering from RoadLotParams, and the + authored-lot attribute-rule UI. + TownGen-time (same per-lot + RNG discipline): each built lot gets a household — a `GroupRecord` + family whose members are runtime-only registry entries; per-town + generation RULES (age/sex distribution, names, stats/needs defaults) + live with the town area definition; anything not set by the rules is + generated deterministically. Per-lot settings (RoadLotParams/layout) + steer household size and character; predefined registry characters + can be pinned to a lot instead of generating. Later, once interiors + exist, the invariant is beds >= residents with a concrete bed (or + bed side for composite beds) registered per NPC. + + Attribute-driven generation for authored lots: instead of fixed + registry entries, lot params can specify generation RULES for + dynamic characters — sex, age band (child/adult/...), clothing and + further attributes as they are added. Most town NPCs will be + dynamic characters built from such attributes, not hand-authored + registry records; this doubles as the proving ground for the NPC + look system (every new look attribute must become expressible as a + generation rule). + + Relationship model (data now, dynamics later): relationships are + DIRECTED — A's record of B is separate from B's record of A — and + carry float stats plus keyword tags, both dynamically extensible + (systems and Lua scripts define new stats/keywords as they come + online). They are not limited to the household GroupRecord — + out-of-group ties exist but are more vague. Seed keywords: + `spouse`, `sibling`, `child`, `acquainted`, `friend`; more are + added by various systems. Inter-character relationship dynamics is + a very complex later topic — this phase only establishes the data + structure so later systems have something to hang on. +- **4.7.3 Location model.** COMPLETE 2026-10-11 (core) — status: + `roadlib/TownLocations::buildLocations()` (pure, headless; probe + `runTownLocationsProbe` in road_geometry_overlap_test) builds the + per-town `LocationSet`: one Home per building (at the building + center), one Curb per pickup POI, one Parking per parking POI + (`TownPoi` gained a `kind` field, replacing name-suffix parsing) + and an Interest location per building whose hash roll passes the + interest ratio (`seed ^ fnv1a(address)` per building — no shared + stream, lot edits stay isolated). Work/Leisure kinds exist but no + zoning generates them yet. `NpcPopulationSystem` stores the set + per area (`areaLocations()`) and writes deterministic per-NPC + bindings into the registry stringColumns: `npcWork` (picked from + the Interest locations — the workplace placeholder until zoning + exists), `npcInterest` (Interest+Curb), both falling back to the + home location; the home location is `"home:" + npcHome`. Authored + locations: `NpcLocationComponent` (kind/name/radius, serialized as + `npcLocation`, editor widget under Game) — merging authored markers + into the generated set is deferred to the motion phase. Room + types: the DSL `room` verb gained `:type `, `BuildingPlan` + gained `roomTypes` (parallel to `roomNames`), and room + names/ids/types now register in BOTH shell modes (filled-shell + registration is metadata-only, no geometry change); the legacy + room-rect snapping switched to the rooms-first half-open + `lround(frac * cells)` convention so fraction-neighboring rooms + always share a boundary index and `idoor` works at any shell size. + The default layout declares a typed living room + bedroom on floor + 0 with an internal door (the bed invariant); exterior geometry and + the TownGen RNG streams are unchanged (verified: identical + exterior mesh vertex counts, same building/household counts). + Original text follows. + + Symbolic locations — home / work / + leisure / interest / curb — each interior (a building, entered via + its front door) or exterior (a POI, a bus-stop-like marker, a + parking rect). Locations are generated from the town (buildings, + POIs, parking) and must be easy to author manually too (a location + marker component + editor placement). Per-NPC location bindings + (home, work, ...) are generated when not authored. + + Bedroom/bed invariant: the population model assumes every home can + sleep its household. Beds materialize only with interior furniture + placement (phase 8+), but the layouts must guarantee the rooms for + them NOW: the default building layout gains at least one bedroom, + sized so every household member has a bed once interiors land. + Room types and furniture-selection keywords therefore become part + of the layout data in this phase (extending the existing + `BuildingPlan::roomIds`/`roomNames`). Rough furniture placement + already exists in Cell Grid for node-based rooms; porting it to + DSL layouts should be easy and is refined later with the interiors + work — for now treat "bedrooms with beds exist" as a given concept + when designing the location bindings. +- **4.7.4 Schedules and goals.** COMPLETE 2026-10-11 (core) — + status: `roadlib/TownSchedule::generateSchedules()` (pure; probe + `runTownScheduleProbe`) builds one schedule per NPC tiling [0, 24) + with (location, activity) entries — sleep/home at the home + location, a work block for adults at the work binding, an optional + errand block (probability in `ScheduleRules`) at the interest + binding; children/elders take a daytime interest visit instead of + work. Per-NPC mt19937 streams seeded `seed ^ fnv1a(stableKey)` + keep lot edits isolated; location bindings moved into the shared + pure `TownLocations::computeBindings()` (used by both the + population system and the schedule generator) and the picks are + rendezvous-hashed, so a location appearing/vanishing rebinds only + the NPCs that referenced it. `NpcPopulationSystem` stores the + schedules per area (`scheduleOf(registryId)`); regeneration via + "Generate Population" / `generateAreaPopulation` is the + deterministic script-forced regen path. Clock: `GameClockSystem` + (EditorApp-owned singleton) is the queryable clock — day counter + + time-of-day, pauseable, speed in game-hours per real second — and + drives every `SunComponent` (adopting the sun's configured + time/speed on sight and re-adopting on divergence, so sun panel + slider edits — deliberately not dirty-flagged — still apply; + `EditorSunSystem` skips its own advance while driven). + Goal injection (the taxi call) arrives with 4.7.7; the goal is + `TownSchedule::entryAt(schedule, clock)` until then. Verification + aid (added by request): a game-mode NPC schedule debug overlay + (`NpcScheduleDebugOverlay`, toggled with **F7**, game mode only) — + clock readout with pause/speed/time scrubber, per-town-area NPC + list with each NPC's current activity/location (schedule entry at + the clock time), and the full 24h schedule + location bindings of + the selected NPC. Original + text follows. + + Per-town rules assign each NPC a + time-of-day schedule over its locations; the town can inject goals + (events, the taxi call below). The NPC's current goal = (location, + arrival window, activity). Schedules are generated once with the + town (deterministically, from the town seed), but scripts must be + able to force a regeneration when events demand it — regeneration + stays deterministic as well (same town state + event log → same + schedule). +- **4.7.5 Motion core (low detail).** `NpcMotionSystem` (the + dedicated controlling system): per NPC a state machine + AT_LOCATION / IN_TRANSIT; transit moves the registry position along + an A* path (road graph coarse layer, navmesh fine layer near the + player) at walk/drive speed; arrivals/departures update the symbolic + state and apply effects. Cheap for hundreds of NPCs: no entities, + no animation, just position + state. Open research point: whether + transit paths between locations can be PRE-CACHED — caching all + location pairs is likely infeasible RAM-wise for a whole town, so + investigate the options first (on-demand coarse A* with a bounded + shared LRU cache, per-location path skeletons, caching only for + high-detail NPCs) before committing to a design. +- **4.7.6 High detail tier.** Within the global spawn distance (with + hysteresis + dwell) the NPC spawns via CharacterSpawnerSystem and + walks the remaining path with root motion; leaving buildings uses + the front door (despawn on activation); at a location the NPC runs + the matching smart object/idle action. Transitions mid-path must + not pop (spawn at the exact symbolic position/heading). +- **4.7.7 Taxi integration.** Mission NPCs near the player get the + special goal "taxi": walk to the curb POI, wait for pickup, board, + ride (phase 5 vehicle), alight at the destination location. + Outside missions the same mechanism lets NPCs call the taxi and + transit by it — the player taxi is just one carrier. +- **4.7.8 Debug + verification.** Editor overlay: locations, NPC + symbolic states, paths, current goals; time-of-day scrubber. + Headless probes: population determinism (same town seed → identical + registry diff, including attribute-rule-generated dynamic characters + and the seeded relationship records), schedule determinism (same T → + same positions), + spawn/despawn hysteresis (no flapping at the boundary), path + validity (transit paths stay on roads/navmesh), and the taxi-goal + flow in low and high detail. + +**Boundaries.** NPC-DRIVEN vehicles and vehicle traffic are phase 5; +foot crowd ambience is phase 6; interior behavior is phase 8. This +phase delivers the exterior simulation and the seams those phases plug +into. + +**Manual verification (as of 4.7.3).** Headless, from the project +root: + +```sh +cmake --build build-vscode --target editSceneEditor \ + road_geometry_overlap_test cellgrid_door_test \ + component_lua_test -j4 +cd build-vscode/src/features/editScene +./road_geometry_overlap_test # incl. runTownPopulationProbe / + # runTownLocationsProbe / + # runTownScheduleProbe, exit 0 +./cellgrid_door_test # 13/13 +./component_lua_test # 66/66 +./editSceneEditor --project demos/demo-taxi \ + --editor demo_scene_exterior.json --generate-town-population +# expect: "generate-town: 9 buildings", +# "NpcPopulationSystem[Area 1]: 33 NPCs in 9 households" and an +# unchanged exterior (identical CellGrid extWall/extWindows vertex +# counts against a pre-change run) +``` + +In the editor (`--project demos/demo-taxi --editor +demo_scene_exterior.json`): + +1. **Population:** Town Generator panel → "Generate Population" — the + log prints the per-area NPC/household counts. Open the character + registry: runtime NPC entries carry `npcKey`/`npcHome`/`npcArea`/ + `npcWork`/`npcInterest` string columns and spouse/parent/child/ + sibling relationship tags; household groups appear as + `
household`. Save and confirm + `character_registry.json` gains NONE of them (runtime-only). +2. **Regeneration stability:** press "Generate Population" twice — + the NPC count stays identical (exact replacement, no leaks). +3. **Rooms/types:** Tools → Layout/DSL editor on a lot's edge params + — the default layout now declares `living` + `bedroom` rooms with + an internal door; regenerating a single building changes no + exterior. `(room bath (rect ...) :windows none :type bathroom)` + must parse; `idoor` between fraction-neighboring rooms works at + odd shell sizes. +4. **Authored locations:** add `NpcLocation` (Game group) to an + entity in the property panel, set kind/name/radius, save the scene + and reload — the component round-trips (`npcLocation` in the scene + JSON). +5. **Schedules + clock (4.7.4):** run the demo in game mode and + press **F7** — the NPC schedule overlay shows the clock (day, + time-of-day, speed), lets you pause/scrub time, and lists every + town NPC with the schedule entry active at the current time + (activity + location); selecting an NPC shows its full 24h + schedule and home/work/interest bindings. Scrubbing through the + day should move NPCs between home/work/errand entries; the sun + follows the same clock. Editing `timeOfDay`/`timeSpeed` in the + editor's sun panel still works (re-adopted by divergence). + Headless coverage: `runTownScheduleProbe` (tiling, bindings, + determinism, per-NPC isolation). + ### Phase 5 — NPC vehicle traffic -- the NPC owns the vehicle, the vehicles are registered to the NPC as driver. Other group member can get to the vehicle as passengers if they go to the same location, or the driver can drive them to their different destinations. +- the NPC owns the vehicle, the vehicles are registered to the NPC as driver the same way as NPCs are added in town. Other group member can get to the vehicle as passengers if they go to the same location, or the driver can drive them to their different destinations. - the NPC can choose by distance if vehicle is used or going by feet (phase 8). - the vehicle is used when NPC has one parked near their current location. - NPC position is simulated in varying levels of detail diff --git a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json index 967758f..f705126 100644 --- a/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json +++ b/src/features/editScene/demos/demo-taxi/demo_scene_exterior.json @@ -849,15 +849,15 @@ }, "transform": { "position": { - "x": 3997.240966796875, - "y": 10.873899459838867, - "z": 3960.30029296875 + "x": 3996.921875, + "y": 10.871692657470703, + "z": 3960.160400390625 }, "rotation": { - "w": 0.9887836575508118, - "x": -0.05630514770746231, - "y": -0.13825948536396027, - "z": -0.004587905947118998 + "w": 0.9876378178596497, + "x": -0.05634903535246849, + "y": -0.14618928730487823, + "z": -0.005017466377466917 }, "scale": { "x": 1.0, @@ -1254,15 +1254,15 @@ }, "transform": { "position": { - "x": 4014.4697265625, - "y": 10.973102569580078, - "z": 3965.343017578125 + "x": 4014.4599609375, + "y": 10.973583221435547, + "z": 3965.41259765625 }, "rotation": { - "w": 0.7457117438316345, - "x": -0.057677898555994034, - "y": 0.6619836091995239, - "z": 0.04863201826810837 + "w": 0.7366631627082825, + "x": -0.057442210614681244, + "y": 0.672011137008667, + "z": 0.04928449168801308 }, "scale": { "x": 1.0, @@ -1608,7 +1608,7 @@ 0.800000011920929 ], "sunSphereSize": 5.0, - "timeOfDay": 23.656871795654297, + "timeOfDay": 2.1072239875793457, "timeSpeed": 0.12999999523162842 }, "transform": { diff --git a/src/features/editScene/demos/demo-taxi/heightmaps/4242424300000001/roads/2_2.json b/src/features/editScene/demos/demo-taxi/heightmaps/4242424300000001/roads/2_2.json index 7dd031b..572e6f2 100644 --- a/src/features/editScene/demos/demo-taxi/heightmaps/4242424300000001/roads/2_2.json +++ b/src/features/editScene/demos/demo-taxi/heightmaps/4242424300000001/roads/2_2.json @@ -4,6 +4,19 @@ "lanesAtoB": 2, "lanesBtoA": 2, "lanesPerDirectionOverride": 0, + "lotLeft": { + "buildable": 0, + "densityScale": -1.0, + "district": -1, + "layout": "(let ((w 20) (d 25))\n(shell w d :floors 1 :empty)\n(floor 0\n (room corridor1 (rect 0 0 1 0.2))\n (room livingroom1 (rect 0 0.2 0.35 1))\n (room bedroom1 (rect 0.65 0.2 1 1))\n (connect corridor1 livingroom1)\n (connect corridor1 bedroom1)\n (door back -7)\n (windows all))\n(roof normal 0 0 w 5)\n(roof normal 0 5 7 d)\n(roof normal 13 5 w d))\n", + "maxFloors": 0, + "maxFootprint": 0.0, + "maxFrontage": 0.0, + "minFloors": 0, + "minFootprint": 0.0, + "roofType": -1, + "setback": -1.0 + }, "nodeA": 1, "nodeB": 2, "roadLevelA": 0.0, @@ -586,7 +599,7 @@ }, { "id": 8, - "verticalOffset": 0.16122817993164063, + "verticalOffset": 0.16122817993164062, "x": 3690.21337890625, "y": 8.126683235168457, "z": 3682.84912109375 @@ -828,4 +841,4 @@ 2 ], "version": "1.0" -} \ No newline at end of file +} diff --git a/src/features/editScene/layoutlib/BuildingPlan.hpp b/src/features/editScene/layoutlib/BuildingPlan.hpp index d8bdcbc..d5e79b0 100644 --- a/src/features/editScene/layoutlib/BuildingPlan.hpp +++ b/src/features/editScene/layoutlib/BuildingPlan.hpp @@ -107,13 +107,17 @@ struct BuildingPlan { std::vector cells; /** * Room assignment per cell (same indexing as cells, -1 = none) - * plus the room name table (roomIds index into it). Filled only - * by rooms-first layouts (`shell ... :empty`, phase 4.6); empty - * for shell+cut layouts. Phase 8's interior generator reads the - * rooms from here. + * plus the room name/type tables (roomIds index into them). + * Rooms-first layouts (`shell ... :empty`, phase 4.6) always fill + * them; shell+cut layouts fill them too when they declare rooms + * (metadata only there — roomIds has no geometry effect in that + * mode). roomTypes carries the optional `:type` tag per room + * ("" = untyped; e.g. "living"/"bedroom", phase 4.7.3). Phase + * 8's interior generator reads the rooms from here. */ std::vector roomIds; std::vector roomNames; + std::vector roomTypes; std::vector roofs; std::vector parkings; std::vector placements; diff --git a/src/features/editScene/layoutlib/LayoutEval.cpp b/src/features/editScene/layoutlib/LayoutEval.cpp index 920d79d..e5bedae 100644 --- a/src/features/editScene/layoutlib/LayoutEval.cpp +++ b/src/features/editScene/layoutlib/LayoutEval.cpp @@ -80,11 +80,20 @@ const char *LayoutLibrary::defaultTemplateSource() * district preset (and edge-side overrides) flow in through the * min/max bindings, so footprint/floors still come from the * merged district params and the rand draws keep the legacy - * TownGen draw order (width, depth, floors). */ + * TownGen draw order (width, depth, floors). + * + * Floor 0 declares a living room and a bedroom (phase 4.7.3 bed + * invariant: every default home has a bedroom for the interior + * generator to place beds in). Rooms/idoor draw no RNG, and in + * exteriorOnly mode internal walls/doors produce no geometry, so + * the town exterior is unchanged. */ return "(shell (rand-range min-fp max-fp)\n" " (rand-range min-fp max-fp)\n" " :floors (rand-int min-floors max-floors))\n" - "(floor 0 (door front center))\n" + "(floor 0 (room living (rect 0 0 0.6 1) :type living)\n" + " (room bedroom (rect 0.6 0 1 1) :type bedroom)\n" + " (idoor living bedroom)\n" + " (door front center))\n" "(windows all)\n"; } @@ -98,8 +107,8 @@ const std::vector &LayoutLibrary::examples() "(roof gable :height 2)\n" }, { "Two rooms with an interior door", "(shell 12 8)\n" - "(floor 0 (room living (rect 0 0 0.5 1))\n" - " (room kitchen (rect 0.5 0 1 1))\n" + "(floor 0 (room living (rect 0 0 0.5 1) :type living)\n" + " (room kitchen (rect 0.5 0 1 1) :type kitchen)\n" " (idoor living kitchen)\n" " (door front center)\n" " (windows all))\n" }, @@ -1117,25 +1126,34 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) return fail(form, "room options come in :keyword value " "pairs"); bool noWindows = false; + std::string roomType; for (size_t i = 3; i < form.list.size(); i += 2) { if (!form.list[i].isSymbol()) return fail(form.list[i], "room option must be a " ":keyword"); const std::string &key = form.list[i].text; - if (key != ":windows") + if (key == ":windows") { + if (!form.list[i + 1].isSymbol()) + return fail(form.list[i + 1], + ":windows needs none or all"); + const std::string &val = form.list[i + 1].text; + if (val == "none") + noWindows = true; + else if (val == "all" || val == "default") + noWindows = false; + else + return fail(form.list[i + 1], + ":windows must be none or all"); + } else if (key == ":type") { + if (!form.list[i + 1].isSymbol()) + return fail(form.list[i + 1], + ":type needs a room type name"); + roomType = form.list[i + 1].text; + } else { return fail(form.list[i], "unknown room option '" + - key + "' (only :windows)"); - if (!form.list[i + 1].isSymbol()) - return fail(form.list[i + 1], ":windows needs " - "none or all"); - const std::string &val = form.list[i + 1].text; - if (val == "none") - noWindows = true; - else if (val == "all" || val == "default") - noWindows = false; - else - return fail(form.list[i + 1], ":windows must be " - "none or all"); + key + + "' (only :windows, :type)"); + } } const SExpr &rect = form.list[2]; if (!rect.isList() || rect.list.size() != 5 || @@ -1147,6 +1165,20 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) !argNumber(rect.list[3], x1) || !argNumber(rect.list[4], z1)) return false; + /* Register the room name (+ optional :type) into the plan's + * room tables; re-declaring a name (e.g. on another floor) + * reuses its id and its first type wins. */ + auto registerRoom = [this, &form, &roomType]() -> int { + auto nit = m_roomNameIds.find(form.list[1].text); + if (nit != m_roomNameIds.end()) + return nit->second; + int id = (int)m_plan->roomNames.size(); + m_plan->roomNames.push_back(form.list[1].text); + m_plan->roomTypes.push_back(roomType); + m_roomNameIds.emplace(form.list[1].text, id); + return id; + }; + if (m_emptyShell) { /* Fractions must stay inside the shell rect — no * silent clamping in rooms-first mode. */ @@ -1169,13 +1201,7 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) return fail(rect, "room '" + form.list[1].text + "' escapes the shell rect"); - auto nit = m_roomNameIds.find(form.list[1].text); - if (nit == m_roomNameIds.end()) { - int id = (int)m_plan->roomNames.size(); - m_plan->roomNames.push_back(form.list[1].text); - nit = m_roomNameIds.emplace(form.list[1].text, id).first; - } - const int roomId = nit->second; + const int roomId = registerRoom(); rr.noWindows = noWindows; m_rooms[m_floor][form.list[1].text] = rr; @@ -1218,19 +1244,32 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) if (x1 <= x0 || z1 <= z0) return fail(rect, "room rect is empty"); + /* Same half-open snapping as rooms-first mode (rect edges at + * lround(frac * cells), room covers [x0, x1)): neighboring rooms + * sharing a fraction always meet at the same boundary index, so + * idoor works for any shell size. */ RoomRect rr; - rr.x0 = std::max(0, (int)std::floor(x0 * m_plan->cellsX)); - rr.z0 = std::max(0, (int)std::floor(z0 * m_plan->cellsZ)); + rr.x0 = std::max(0, (int)std::lround(x0 * m_plan->cellsX)); + rr.z0 = std::max(0, (int)std::lround(z0 * m_plan->cellsZ)); rr.x1 = std::min(m_plan->cellsX - 1, - (int)std::ceil(x1 * m_plan->cellsX) - 1); + (int)std::lround(x1 * m_plan->cellsX) - 1); rr.z1 = std::min(m_plan->cellsZ - 1, - (int)std::ceil(z1 * m_plan->cellsZ) - 1); + (int)std::lround(z1 * m_plan->cellsZ) - 1); if (rr.x1 < rr.x0 || rr.z1 < rr.z0) return fail(rect, "room rect maps to no cells"); rr.noWindows = noWindows; m_rooms[m_floor][form.list[1].text] = rr; + /* Metadata only in filled-shell mode: record the room cells so + * consumers (location/interior generators) can read room + * names/types for legacy layouts too. No geometry effect. */ + const int roomId = registerRoom(); + if (m_plan->roomIds.empty()) + m_plan->roomIds.assign((size_t)m_plan->cellsX * + m_plan->cellsZ * m_plan->floors, + -1); + for (int z = rr.z0; z <= rr.z1; ++z) { for (int x = rr.x0; x <= rr.x1; ++x) { uint64_t &c = m_plan->at(x, m_floor, z); @@ -1242,6 +1281,8 @@ bool LayoutEval::verbRoomRaw(const SExpr &form) c |= PlanFlags::IntWallZNeg; if (z == rr.z1 && rr.z1 < m_plan->cellsZ - 1) c |= PlanFlags::IntWallZPos; + m_plan->roomIds[((size_t)m_floor * m_plan->cellsZ + z) * + m_plan->cellsX + x] = roomId; } } return true; diff --git a/src/features/editScene/main.cpp b/src/features/editScene/main.cpp index c1d2d31..9dbffcc 100644 --- a/src/features/editScene/main.cpp +++ b/src/features/editScene/main.cpp @@ -8,6 +8,7 @@ #include "systems/TerrainTests.hpp" #include "systems/TerrainSystem.hpp" #include "systems/TownGenSystem.hpp" +#include "systems/NpcPopulationSystem.hpp" #include "roadlib/TownGen.hpp" #include "OgreRoot.h" @@ -41,6 +42,11 @@ struct GenerateTownFrameListener : public Ogre::FrameListener { int regenSide = -1; int regenWait = 0; bool useAreas = false; + /* --generate-town-population: after town generation, generate the + * deterministic per-area NPC populations into the registry. */ + bool population = false; + bool populationDone = false; + EditorApp *app = nullptr; /* TOWNGEN_REPEAT_GENERATE=1: run the generation a second time a * few frames after the first — the headless equivalent of * pressing "Generate All Areas" twice (the settlement material @@ -91,6 +97,7 @@ struct GenerateTownFrameListener : public Ogre::FrameListener { result = -4; return true; } + runPopulation(); result = 0; root->queueEndRendering(); return true; @@ -117,6 +124,7 @@ struct GenerateTownFrameListener : public Ogre::FrameListener { result = -4; return true; } + runPopulation(); result = 0; root->queueEndRendering(); return true; @@ -130,6 +138,21 @@ struct GenerateTownFrameListener : public Ogre::FrameListener { } return true; } + void runPopulation() + { + if (!population || populationDone) + return; + populationDone = true; + NpcPopulationSystem *popSys = + app ? app->getNpcPopulationSystem() : nullptr; + if (!popSys) + return; + std::string error; + int n = popSys->generateAllAreas(&error); + std::cout << "generate-town-population: " << n << " NPCs" + << (error.empty() ? "" : " (" + error + ")") + << std::endl; + } }; /** Frame-driven terrain test runner. @@ -181,6 +204,7 @@ int main(int argc, char *argv[]) int genTownRegenEdge = -1; int genTownRegenSide = 0; bool genTownUseAreas = false; + bool genTownPopulation = false; Ogre::String sceneFile; Ogre::String projectDir; for (int i = 1; i < argc; i++) { @@ -225,6 +249,13 @@ int main(int argc, char *argv[]) /* Mirrors the "Generate All Areas" button. */ generateTown = true; genTownUseAreas = true; + } else if (arg == "--generate-town-population") { + /* Implies --generate-town-areas; afterwards + * generate the per-area NPC populations into the + * CharacterRegistry (phase 4.7.2). */ + generateTown = true; + genTownUseAreas = true; + genTownPopulation = true; } else if (arg.find("--regen-lot-params=") == 0) { /* --regen-lot-params=,: after * --generate-town, regenerate the buildings of @@ -350,6 +381,8 @@ int main(int argc, char *argv[]) genTownListener.regenEdge = genTownRegenEdge; genTownListener.regenSide = genTownRegenSide; genTownListener.useAreas = genTownUseAreas; + genTownListener.population = genTownPopulation; + genTownListener.app = &app; if (generateTown) app.getRoot()->addFrameListener(&genTownListener); app.getRoot()->startRendering(); diff --git a/src/features/editScene/roadlib/TownGen.cpp b/src/features/editScene/roadlib/TownGen.cpp index dfcb28f..68c8518 100644 --- a/src/features/editScene/roadlib/TownGen.cpp +++ b/src/features/editScene/roadlib/TownGen.cpp @@ -705,6 +705,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, TownPoi poi; poi.name = b.address + " pickup"; + poi.kind = TownPoi::Pickup; poi.position = center2d - v * (depth * 0.5f + 3.0f); poi.position.y = sampleY(sampleHeight, poi.position.x, @@ -724,6 +725,7 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, built.plan.parkings[pi]; TownPoi ppPoi; ppPoi.name = built.address + " parking"; + ppPoi.kind = TownPoi::Parking; if (built.plan.parkings.size() > 1) ppPoi.name += " " + std::to_string(pi + 1); ppPoi.position = frontMid + diff --git a/src/features/editScene/roadlib/TownGen.hpp b/src/features/editScene/roadlib/TownGen.hpp index c3351d0..2ea6a60 100644 --- a/src/features/editScene/roadlib/TownGen.hpp +++ b/src/features/editScene/roadlib/TownGen.hpp @@ -119,9 +119,16 @@ struct TownBuilding { /** Named curb point in front of a building's door (pickup/dropoff, * pedestrian origin/destination). */ struct TownPoi { + /** POI role (phase 4.7.3): a curb pickup/dropoff point or a + * parking pad. Replaces name-suffix parsing. */ + enum Kind { + Pickup = 0, + Parking + }; std::string name; /* "
pickup" */ Ogre::Vector3 position = Ogre::Vector3::ZERO; int building = -1; /* index into buildings */ + Kind kind = Pickup; }; struct TownResult { diff --git a/src/features/editScene/roadlib/TownLocations.cpp b/src/features/editScene/roadlib/TownLocations.cpp new file mode 100644 index 0000000..39b8b60 --- /dev/null +++ b/src/features/editScene/roadlib/TownLocations.cpp @@ -0,0 +1,164 @@ +#include "TownLocations.hpp" + +namespace TownLocations { + +static uint32_t fnv1a(const std::string &s) +{ + uint32_t h = 2166136261u; + for (char c : s) { + h ^= (uint8_t)c; + h *= 16777619u; + } + return h; +} + +const char *locationKindName(LocationKind kind) +{ + switch (kind) { + case LocationKind::Home: return "home"; + case LocationKind::Work: return "work"; + case LocationKind::Leisure: return "leisure"; + case LocationKind::Interest: return "interest"; + case LocationKind::Curb: return "curb"; + case LocationKind::Parking: return "parking"; + default: return "?"; + } +} + +const NpcLocation *LocationSet::findByName(const std::string &name) const +{ + for (const NpcLocation &loc : locations) + if (loc.name == name) + return &loc; + return nullptr; +} + +int LocationSet::homeOfBuilding(int building) const +{ + for (const NpcLocation &loc : locations) + if (loc.kind == LocationKind::Home && loc.building == building) + return loc.id; + return -1; +} + +std::vector LocationSet::indicesOfKind(LocationKind kind) const +{ + std::vector out; + for (const NpcLocation &loc : locations) + if (loc.kind == kind) + out.push_back(loc.id); + return out; +} + +std::string pickLocationName(const LocationSet &locs, + const std::vector &candidates, + const std::string &stableKey, + const char *purpose, + const std::string &fallback) +{ + if (candidates.empty()) + return fallback; + /* Rendezvous (highest-random-weight) hashing: the pick is the + * candidate maximizing hash(stableKey, purpose, candidate), so + * a candidate appearing or vanishing rebinds only the NPCs that + * referenced it — everyone else keeps their location. */ + uint32_t h0 = fnv1a(stableKey) ^ fnv1a(purpose); + int best = -1; + uint32_t bestH = 0; + for (int c : candidates) { + uint32_t h = (h0 ^ fnv1a(locs.locations[c].name)) * + 16777619u; + if (best < 0 || h > bestH) { + best = c; + bestH = h; + } + } + return locs.locations[best].name; +} + +NpcBindings computeBindings(const LocationSet &locs, + const std::string &stableKey, + const std::string &homeAddress) +{ + NpcBindings b; + b.home = "home:" + homeAddress; + const std::vector interest = + locs.indicesOfKind(LocationKind::Interest); + std::vector visit = interest; + { + std::vector curbs = + locs.indicesOfKind(LocationKind::Curb); + visit.insert(visit.end(), curbs.begin(), curbs.end()); + } + b.work = pickLocationName(locs, interest, stableKey, "work", b.home); + b.interest = + pickLocationName(locs, visit, stableKey, "interest", b.home); + return b; +} + +LocationSet buildLocations(const TownGen::TownResult &town, uint32_t seed, + float interestRatio) +{ + LocationSet set; + + /* Building index -> its pickup POI (interest locations sit at + * the curb so pathing to them needs no building entry). */ + std::vector pickupOf(town.buildings.size(), -1); + for (size_t i = 0; i < town.pois.size(); ++i) { + const TownGen::TownPoi &poi = town.pois[i]; + if (poi.kind == TownGen::TownPoi::Pickup && poi.building >= 0 && + (size_t)poi.building < pickupOf.size() && + pickupOf[poi.building] < 0) + pickupOf[poi.building] = (int)i; + } + + auto add = [&set](NpcLocation &loc) { + loc.id = (int)set.locations.size(); + set.locations.push_back(loc); + }; + + for (size_t bi = 0; bi < town.buildings.size(); ++bi) { + const TownGen::TownBuilding &b = town.buildings[bi]; + + NpcLocation home; + home.kind = LocationKind::Home; + home.name = std::string("home:") + b.address; + home.address = b.address; + home.position = b.center; + home.building = (int)bi; + add(home); + + /* Deterministic per-building interest roll — no shared + * stream, so one lot's edit never shifts another lot's + * locations. */ + uint32_t h = fnv1a(b.address) ^ (seed * 16777619u); + float roll = (h % 1000) / 1000.0f; + if (roll < interestRatio) { + NpcLocation interest; + interest.kind = LocationKind::Interest; + interest.name = std::string("interest:") + b.address; + interest.address = b.address; + interest.position = pickupOf[bi] >= 0 ? + town.pois[pickupOf[bi]].position : b.center; + interest.building = (int)bi; + add(interest); + } + } + + for (const TownGen::TownPoi &poi : town.pois) { + NpcLocation loc; + loc.kind = poi.kind == TownGen::TownPoi::Parking ? + LocationKind::Parking : LocationKind::Curb; + loc.name = poi.name; + loc.building = poi.building; + if (poi.building >= 0 && + (size_t)poi.building < town.buildings.size()) + loc.address = town.buildings[poi.building].address; + loc.position = poi.position; + add(loc); + } + + return set; +} + +} /* namespace TownLocations */ diff --git a/src/features/editScene/roadlib/TownLocations.hpp b/src/features/editScene/roadlib/TownLocations.hpp new file mode 100644 index 0000000..9862010 --- /dev/null +++ b/src/features/editScene/roadlib/TownLocations.hpp @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include + +#include "TownGen.hpp" + +/** + * Symbolic location model (demo-taxi phase 4.7.3). + * + * Pure functions of a TownGen::TownResult plus a seed — no ECS, fully + * exercisable headlessly. Locations are the named places NPCs bind + * to and travel between: home (a building), curb (a pickup POI), + * parking (a parking POI) and interest (a deterministic subset of + * buildings doubling as shops/landmarks/workplaces until zoning + * exists). Authored locations (the NpcLocationComponent marker) are + * merged in by the ECS side, not here. + * + * Determinism (the TownGen per-lot discipline): there is no shared + * RNG stream — every per-building roll is a hash of + * `seed ^ fnv1a(address)`, so editing one lot can never shift another + * lot's locations. + */ +namespace TownLocations { + +enum class LocationKind { + Home = 0, + Work, /* reserved: no work zoning yet; bindings pick + * from Interest instead */ + Leisure, /* reserved for authored markers */ + Interest, + Curb, + Parking, + COUNT +}; + +const char *locationKindName(LocationKind kind); + +struct NpcLocation { + /** Index into LocationSet::locations. */ + int id = -1; + LocationKind kind = LocationKind::Home; + /** Unique within the set: ":
" (POI locations + * reuse the POI name). */ + std::string name; + /** Owning building's address ("" for standalone markers). */ + std::string address; + /** Render/world XZ of the caller's coordinate space. */ + Ogre::Vector3 position = Ogre::Vector3::ZERO; + /** Index into TownResult::buildings, -1 when standalone. */ + int building = -1; +}; + +struct LocationSet { + std::vector locations; + + /** Null when not found. */ + const NpcLocation *findByName(const std::string &name) const; + /** Home location index of a building, -1 when none. */ + int homeOfBuilding(int building) const; + /** Indices of all locations of a kind, in location order. */ + std::vector indicesOfKind(LocationKind kind) const; +}; + +/** + * Per-NPC location bindings (phase 4.7.4): the deterministic picks the + * population system stores in the registry stringColumns and the + * schedule generator consumes. `home` is "home:" + homeAddress; + * `work` picks from the Interest locations (the workplace placeholder + * until zoning exists), `interest` from Interest+Curb; both fall back + * to `home` when the set has no candidates. The pick is a hash of + * (stableKey, purpose), so a regeneration with the same town re-picks + * the same bindings. + */ +struct NpcBindings { + std::string home; + std::string work; + std::string interest; +}; + +/** Deterministic pick from candidate location indices: rendezvous + * (highest-random-weight) hashing, so a candidate appearing or + * vanishing rebinds only the NPCs that referenced it. */ +std::string pickLocationName(const LocationSet &locs, + const std::vector &candidates, + const std::string &stableKey, + const char *purpose, + const std::string &fallback); + +NpcBindings computeBindings(const LocationSet &locs, + const std::string &stableKey, + const std::string &homeAddress); + +/** + * Build the location set of a town: one Home per building (position + * = building center at floor level), one Curb per pickup POI, one + * Parking per parking POI, and an Interest location for every + * building whose hash roll passes `interestRatio` (position = its + * pickup POI when one exists, else the building center). + */ +LocationSet buildLocations(const TownGen::TownResult &town, + uint32_t seed, float interestRatio = 0.15f); + +} /* namespace TownLocations */ diff --git a/src/features/editScene/roadlib/TownPopulation.cpp b/src/features/editScene/roadlib/TownPopulation.cpp new file mode 100644 index 0000000..dd9d69e --- /dev/null +++ b/src/features/editScene/roadlib/TownPopulation.cpp @@ -0,0 +1,206 @@ +#include "TownPopulation.hpp" + +#include + +namespace TownPopulation { + +/* ------------------------------------------------------------------ */ +/* Built-in default name pools (used when a rules pool is empty) */ +/* ------------------------------------------------------------------ */ + +static const char *kMaleFirst[] = { + "James", "John", "Robert", "Michael", "William", "David", + "Richard", "Joseph", "Thomas", "Charles", "Daniel", "Matthew", + "Anthony", "Mark", "Paul", "Steven", "Andrew", "Kenneth", + "George", "Joshua", "Kevin", "Brian", "Edward", "Ronald", +}; + +static const char *kFemaleFirst[] = { + "Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Barbara", + "Susan", "Jessica", "Sarah", "Karen", "Nancy", "Lisa", + "Margaret", "Betty", "Sandra", "Ashley", "Dorothy", "Kimberly", + "Emily", "Michelle", "Amanda", "Melissa", "Deborah", "Laura", +}; + +static const char *kLastNames[] = { + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", + "Miller", "Davis", "Wilson", "Anderson", "Taylor", "Thomas", + "Moore", "Martin", "Jackson", "Thompson", "White", "Harris", + "Clark", "Lewis", "Walker", "Hall", "Young", "King", +}; + +static std::vector toVec(const char *const *arr, size_t n) +{ + std::vector v; + v.reserve(n); + for (size_t i = 0; i < n; ++i) + v.push_back(arr[i]); + return v; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +static uint32_t fnv1a(const std::string &s) +{ + uint32_t h = 2166136261u; + for (char c : s) { + h ^= (uint8_t)c; + h *= 16777619u; + } + return h; +} + +static int randInt(std::mt19937 &rng, int lo, int hi) +{ + if (hi <= lo) + return lo; + std::uniform_int_distribution d(lo, hi); + return d(rng); +} + +static const std::string &pick(const std::vector &pool, + std::mt19937 &rng) +{ + return pool[(size_t)randInt(rng, 0, (int)pool.size() - 1)]; +} + +/* ------------------------------------------------------------------ */ +/* Generation */ +/* ------------------------------------------------------------------ */ + +Population generatePopulation(const TownGen::TownResult &town, + uint32_t popSeed, + const PopulationRules &rules, + uint64_t idBase) +{ + Population pop; + + std::vector maleFirst = rules.maleFirstNames.empty() ? + toVec(kMaleFirst, + sizeof(kMaleFirst) / sizeof(kMaleFirst[0])) : + rules.maleFirstNames; + std::vector femaleFirst = + rules.femaleFirstNames.empty() ? + toVec(kFemaleFirst, + sizeof(kFemaleFirst) / sizeof(kFemaleFirst[0])) : + rules.femaleFirstNames; + std::vector lastNames = rules.lastNames.empty() ? + toVec(kLastNames, sizeof(kLastNames) / sizeof(kLastNames[0])) : + rules.lastNames; + + std::mt19937 rng; + std::uniform_real_distribution unit(0.0f, 1.0f); + + for (size_t bi = 0; bi < town.buildings.size(); ++bi) { + const TownGen::TownBuilding &b = town.buildings[bi]; + + /* Per-building stream: one lot's household can never + * shift another lot's draws. */ + rng.seed(popSeed ^ fnv1a(b.address)); + + Household hh; + hh.building = (int)bi; + hh.address = b.address; + hh.lastName = pick(lastNames, rng); + + int size = randInt(rng, rules.minHouseholdSize, + rules.maxHouseholdSize); + /* Keep the per-building id band from overflowing into the + * next building's band. */ + if (size > (int)HOUSEHOLD_ID_CAP - 1) + size = (int)HOUSEHOLD_ID_CAP - 1; + bool couple = size > 1 && unit(rng) < rules.coupleProbability; + int adults = couple ? 2 : 1; + int children = size - adults; + + /* Fixed draw order per member: sex, elder roll (adults + * only), ageYears, outfitLevel, firstName. */ + auto addMember = [&](bool child) -> int { + PopMember m; + m.household = (int)pop.households.size(); + m.sex = unit(rng) < 0.5f ? "male" : "female"; + if (child) { + m.age = "child"; + m.ageYears = randInt(rng, rules.childMinYears, + rules.childMaxYears); + } else { + bool elder = unit(rng) < rules.elderProbability; + m.age = elder ? "elder" : "adult"; + m.ageYears = elder ? + randInt(rng, rules.elderMinYears, + rules.elderMaxYears) : + randInt(rng, rules.adultMinYears, + rules.adultMaxYears); + } + m.outfitLevel = randInt(rng, rules.minOutfitLevel, + rules.maxOutfitLevel); + const std::vector &pool = + m.sex == "male" ? maleFirst : femaleFirst; + m.firstName = pick(pool, rng); + /* Re-draw first-name collisions within the + * household (bounded, then accept). */ + for (int tries = 0; tries < 4; ++tries) { + bool clash = false; + for (int oi : hh.members) { + if (pop.members[oi].firstName == + m.firstName) { + clash = true; + break; + } + } + if (!clash) + break; + m.firstName = pick(pool, rng); + } + m.lastName = hh.lastName; + m.stableKey = b.address + "#" + + std::to_string(hh.members.size()); + m.registryId = idBase + + (uint64_t)bi * HOUSEHOLD_ID_CAP + + (uint64_t)hh.members.size(); + hh.members.push_back((int)pop.members.size()); + pop.members.push_back(m); + return (int)pop.members.size() - 1; + }; + + std::vector adultIdx, childIdx; + for (int i = 0; i < adults; ++i) + adultIdx.push_back(addMember(false)); + for (int i = 0; i < children; ++i) + childIdx.push_back(addMember(true)); + + /* Relationship seeds (directed; tag on A -> B states + * what A is to B). */ + auto rel = [&](int from, int to, const char *tag) { + PopRelationship r; + r.from = from; + r.to = to; + r.tag = tag; + hh.relationships.push_back(r); + }; + if (adultIdx.size() == 2) { + rel(0, 1, "spouse"); + rel(1, 0, "spouse"); + } + for (size_t ci = 0; ci < childIdx.size(); ++ci) { + int cl = adults + (int)ci; + for (size_t ai = 0; ai < adultIdx.size(); ++ai) { + rel(cl, (int)ai, "child"); + rel((int)ai, cl, "parent"); + } + for (size_t si = 0; si < ci; ++si) { + int sl = adults + (int)si; + rel(cl, sl, "sibling"); + rel(sl, cl, "sibling"); + } + } + + pop.households.push_back(hh); + } + + return pop; +} + +} /* namespace TownPopulation */ diff --git a/src/features/editScene/roadlib/TownPopulation.hpp b/src/features/editScene/roadlib/TownPopulation.hpp new file mode 100644 index 0000000..bbceb7c --- /dev/null +++ b/src/features/editScene/roadlib/TownPopulation.hpp @@ -0,0 +1,136 @@ +#pragma once + +#include +#include +#include + +#include "TownGen.hpp" + +/** + * Town population generation (demo-taxi phase 4.7.2). + * + * Pure functions of a TownGen::TownResult plus PopulationRules and a + * seed — no ECS, no registry dependency, fully exercisable + * headlessly. The ECS-side population system applies the result to + * the CharacterRegistry as runtime-only entries. + * + * Determinism (the TownGen per-lot discipline): every building's + * household draws from its OWN mt19937 stream seeded + * `popSeed ^ fnv1a(address)`, so editing one lot can never shift + * another lot's household. Registry IDs come from the building's + * own HOUSEHOLD_ID_CAP-sized band, so a household-size change on one + * lot never shifts another lot's ids either. The caller derives + * idBase as NPC_ID_BASE + areaIndex * AREA_POP_CAP, keeping areas + * collision-free without any hashing. Household sizes are clamped + * to HOUSEHOLD_ID_CAP - 1. + */ +namespace TownPopulation { + +/** + * Population NPCs live in the registry's runtime id space + * (>= 1 << 32) but far above the sequential runtime allocator's + * reach: idBase for area i is NPC_ID_BASE + i * AREA_POP_CAP. + * Within an area each BUILDING gets a HOUSEHOLD_ID_CAP-sized id + * band (idBase + buildingIndex * HOUSEHOLD_ID_CAP + memberIndex), so + * a household-size change on one lot never shifts another lot's + * ids. + */ +static const uint64_t NPC_ID_BASE = 1ull << 40; +static const uint64_t AREA_POP_CAP = 1ull << 20; +static const uint64_t HOUSEHOLD_ID_CAP = 64; + +/** Per-area population generation rules (live with the town area). */ +struct PopulationRules { + /** Household size range (members per residential building). */ + int minHouseholdSize = 1; + int maxHouseholdSize = 5; + /** Chance a household with room for two has two adults. */ + float coupleProbability = 0.7f; + /** Chance an adult is an elder. */ + float elderProbability = 0.2f; + /** Age-in-years ranges per band. */ + int childMinYears = 1; + int childMaxYears = 17; + int adultMinYears = 18; + int adultMaxYears = 64; + int elderMinYears = 65; + int elderMaxYears = 90; + /** Outfit level range (CharacterRecord::inlineOutfitLevel). */ + int minOutfitLevel = 0; + int maxOutfitLevel = 2; + /** Name pools; an empty pool falls back to the built-in + * defaults. The ECS side can feed Markov-trained pools from + * the CharacterRegistry instead. */ + std::vector maleFirstNames; + std::vector femaleFirstNames; + std::vector lastNames; +}; + +/** One generated NPC (attribute-driven; the look system consumes + * sex/age/outfitLevel — more attributes slot in here as they are + * added). */ +struct PopMember { + /** Deterministic runtime-registry id: idBase + buildingIndex * + * HOUSEHOLD_ID_CAP + household-local member index. */ + uint64_t registryId = 0; + /** "
#" — stable across regenerations of + * the same town. */ + std::string stableKey; + /** Index into Population::households. */ + int household = -1; + std::string firstName; + std::string lastName; + /** "male" | "female" (CharacterRecord::inlineSex). */ + std::string sex; + /** "child" | "adult" | "elder" (CharacterRecord::age). */ + std::string age; + int ageYears = 0; + int outfitLevel = 0; +}; + +/** + * A directed household relationship seed. Tag convention: the tag + * on A -> B states what A is to B ("child" on child -> parent, + * "parent" on parent -> child, "spouse"/"sibling" both directions). + * Households seed spouse/sibling/child/parent only; acquainted, + * friend and beyond are left to later systems. + */ +struct PopRelationship { + /** Household-local member indices (into Household::members). */ + int from = -1; + int to = -1; + std::string tag; +}; + +struct Household { + /** Index into TownResult::buildings (the home). */ + int building = -1; + std::string address; + /** Shared family name. */ + std::string lastName; + /** Indices into Population::members; adults first. */ + std::vector members; + std::vector relationships; +}; + +struct Population { + std::vector members; + std::vector households; +}; + +/** + * Generate one household per building of the town. Draw order per + * building (fixed, determinism-critical): family last name, + * household size, couple roll, then per adult (sex, elder roll, + * ageYears, outfitLevel, firstName), then per child (sex, ageYears, + * outfitLevel, firstName). First names re-draw on collision within + * the household. Household size is clamped to HOUSEHOLD_ID_CAP - 1 + * so the per-building id band cannot overflow into the next + * building's band. + */ +Population generatePopulation(const TownGen::TownResult &town, + uint32_t popSeed, + const PopulationRules &rules, + uint64_t idBase = NPC_ID_BASE); + +} /* namespace TownPopulation */ diff --git a/src/features/editScene/roadlib/TownSchedule.cpp b/src/features/editScene/roadlib/TownSchedule.cpp new file mode 100644 index 0000000..fe200c7 --- /dev/null +++ b/src/features/editScene/roadlib/TownSchedule.cpp @@ -0,0 +1,134 @@ +#include "TownSchedule.hpp" + +#include +#include +#include + +namespace TownSchedule { + +static uint32_t fnv1a(const std::string &s) +{ + uint32_t h = 2166136261u; + for (char c : s) { + h ^= (uint8_t)c; + h *= 16777619u; + } + return h; +} + +static float randRange(std::mt19937 &rng, float lo, float hi) +{ + std::uniform_real_distribution d(lo, hi); + return d(rng); +} + +static float clampf(float v, float lo, float hi) +{ + return std::max(lo, std::min(hi, v)); +} + +std::vector generateSchedules( + const TownPopulation::Population &pop, + const TownLocations::LocationSet &locations, uint32_t seed, + const ScheduleRules &rules) +{ + std::vector out; + out.reserve(pop.members.size()); + + for (const TownPopulation::PopMember &pm : pop.members) { + std::mt19937 rng(seed ^ fnv1a(pm.stableKey)); + const std::string &homeAddr = + pop.households[pm.household].address; + TownLocations::NpcBindings bind = + TownLocations::computeBindings(locations, pm.stableKey, + homeAddr); + + /* Fixed draw order: wake jitter, (adults: work start + * jitter), errand roll, errand length jitter. */ + float wake = clampf(rules.wakeHour + + randRange(rng, -rules.wakeSpread, + rules.wakeSpread), + 4.0f, 10.0f); + const bool adult = pm.age == "adult"; + float workStart = 0.0f; + if (adult) + workStart = clampf(rules.workStartHour + + randRange(rng, + -rules.workStartSpread, + rules.workStartSpread), + wake + 0.5f, 12.0f); + bool errand = randRange(rng, 0.0f, 1.0f) < + rules.errandProbability; + float errandLen = std::max( + 0.5f, rules.errandHours + + randRange(rng, -0.5f, 0.5f)); + + NpcSchedule sched; + sched.registryId = pm.registryId; + sched.stableKey = pm.stableKey; + + /* Build the day with a monotonic cursor so the entries + * always tile [0, 24) in order; degenerate segments are + * skipped. */ + float t = 0.0f; + auto push = [&t](NpcSchedule &s, float end, + const std::string &loc, const char *act) { + end = clampf(end, t, 24.0f); + if (end > t + 1.0e-4f) { + ScheduleEntry e; + e.startHour = t; + e.endHour = end; + e.location = loc; + e.activity = act; + s.entries.push_back(e); + t = end; + } + }; + + push(sched, wake, bind.home, "sleep"); + if (adult) { + float workEnd = workStart + rules.workHours; + push(sched, workStart, bind.home, "home"); + push(sched, workEnd, bind.work, "work"); + if (errand) { + push(sched, workEnd + errandLen, + bind.interest, "errand"); + } + } else { + float outHour = pm.age == "child" ? + rules.childOutHour : rules.elderOutHour; + push(sched, outHour, bind.home, "home"); + if (errand) + push(sched, outHour + errandLen, + bind.interest, "errand"); + } + push(sched, rules.sleepHour, bind.home, "home"); + push(sched, 24.0f, bind.home, "sleep"); + if (sched.entries.empty()) { + /* Pathological rules — guarantee coverage. */ + ScheduleEntry e; + e.startHour = 0.0f; + e.endHour = 24.0f; + e.location = bind.home; + e.activity = "home"; + sched.entries.push_back(e); + } + + out.push_back(std::move(sched)); + } + return out; +} + +const ScheduleEntry *entryAt(const NpcSchedule &schedule, + float timeOfDayHours) +{ + if (schedule.entries.empty()) + return nullptr; + float t = timeOfDayHours - 24.0f * std::floor(timeOfDayHours / 24.0f); + for (const ScheduleEntry &e : schedule.entries) + if (t >= e.startHour && t < e.endHour) + return &e; + return &schedule.entries.back(); +} + +} /* namespace TownSchedule */ diff --git a/src/features/editScene/roadlib/TownSchedule.hpp b/src/features/editScene/roadlib/TownSchedule.hpp new file mode 100644 index 0000000..0a6fa0c --- /dev/null +++ b/src/features/editScene/roadlib/TownSchedule.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#include "TownLocations.hpp" +#include "TownPopulation.hpp" + +/** + * NPC time-of-day schedules (demo-taxi phase 4.7.4). + * + * Pure functions of the population, the location set, rules and a + * seed — no ECS, fully exercisable headlessly. Every NPC gets a + * schedule that tiles [0, 24) with (location, activity) entries; the + * motion core (4.7.5) turns the active entry into a goal. + * + * Determinism (the TownGen per-lot discipline): every NPC draws from + * its OWN mt19937 stream seeded `seed ^ fnv1a(stableKey)`, so editing + * one lot can never shift another lot's schedules. Scripts force a + * regeneration by re-running the population generation — same town + * state, same schedules. + */ +namespace TownSchedule { + +/** Per-town schedule generation rules (live with the town area). */ +struct ScheduleRules { + /** Wake time ~ wakeHour +/- wakeSpread (clamped to [4, 10]). */ + float wakeHour = 7.0f; + float wakeSpread = 1.0f; + /** Work start ~ workStartHour +/- workStartSpread (adults). */ + float workStartHour = 9.0f; + float workStartSpread = 0.5f; + /** Work block length in hours (adults). */ + float workHours = 8.0f; + /** Chance of a daily interest/errand visit. */ + float errandProbability = 0.6f; + /** Errand block length in hours. */ + float errandHours = 1.5f; + /** Interest-visit start for children / elders. */ + float childOutHour = 15.0f; + float elderOutHour = 10.0f; + /** Evening return to sleep. */ + float sleepHour = 22.0f; +}; + +struct ScheduleEntry { + float startHour = 0.0f; /* [0, 24) */ + float endHour = 0.0f; /* (startHour, 24] */ + /** LocationSet location name (TownLocations::NpcBindings). */ + std::string location; + /** "sleep" | "home" | "work" | "errand". */ + std::string activity; +}; + +struct NpcSchedule { + uint64_t registryId = 0; + std::string stableKey; + /** Sorted by startHour, tiling [0, 24) without gaps. */ + std::vector entries; +}; + +/** + * Generate one schedule per population member. Per-NPC draw order + * (fixed, determinism-critical): wake jitter, then band-specific + * draws (adults: work start jitter; all: errand roll, errand length + * jitter). Work/errand locations come from + * TownLocations::computeBindings. + */ +std::vector generateSchedules( + const TownPopulation::Population &pop, + const TownLocations::LocationSet &locations, uint32_t seed, + const ScheduleRules &rules); + +/** The active entry at a time of day in hours [0, 24); nullptr only + * for an empty schedule. */ +const ScheduleEntry *entryAt(const NpcSchedule &schedule, + float timeOfDayHours); + +} /* namespace TownSchedule */ diff --git a/src/features/editScene/systems/CharacterRegistry.cpp b/src/features/editScene/systems/CharacterRegistry.cpp index 80dce6f..6082a7f 100644 --- a/src/features/editScene/systems/CharacterRegistry.cpp +++ b/src/features/editScene/systems/CharacterRegistry.cpp @@ -57,6 +57,8 @@ void CharacterRegistry::initialize() void CharacterRegistry::autoSave() { + if (m_autoSaveSuspended) + return; if (!m_autoSavePath.empty()) saveToFile(m_autoSavePath); } @@ -942,6 +944,27 @@ uint64_t CharacterRegistry::createCharacter(const std::string &firstName, return id; } +uint64_t CharacterRegistry::ensureRuntimeCharacter( + uint64_t id, const std::string &firstName, const std::string &lastName) +{ + /* Refuse ids inside the sequential allocators' ranges: persistent + * ids below 1<<32, sequential runtime ids below the population + * band base (1<<40). */ + if (id < (1ull << 40)) + return 0; + auto it = m_characters.find(id); + if (it != m_characters.end()) + return id; + + CharacterRecord rec; + rec.id = id; + rec.firstName = firstName; + rec.lastName = lastName; + rec.persistent = false; + m_characters[id] = rec; + return id; +} + void CharacterRegistry::deleteCharacter(uint64_t id) { auto it = m_characters.find(id); @@ -971,14 +994,34 @@ CharacterRegistry::findCharacter(uint64_t id) const /* Groups */ /* ===================================================================== */ -uint64_t CharacterRegistry::createGroup(const std::string &name) +uint64_t CharacterRegistry::createGroup(const std::string &name, + bool persistent) { - uint64_t id = m_nextId++; + uint64_t id = persistent ? m_nextId++ : m_nextRuntimeId++; GroupRecord rec; rec.id = id; rec.name = name; + rec.persistent = persistent; + m_groups[id] = rec; + if (persistent) + autoSave(); + return id; +} + +uint64_t CharacterRegistry::ensureRuntimeGroup(uint64_t id, + const std::string &name) +{ + if (id < (1ull << 40)) + return 0; + auto it = m_groups.find(id); + if (it != m_groups.end()) + return id; + + GroupRecord rec; + rec.id = id; + rec.name = name; + rec.persistent = false; m_groups[id] = rec; - autoSave(); return id; } @@ -1346,9 +1389,12 @@ nlohmann::json CharacterRegistry::serialize() const for (const auto &pair : m_groups) { const GroupRecord &g = pair.second; + if (!g.persistent) + continue; nlohmann::json rec; rec["id"] = g.id; rec["name"] = g.name; + rec["persistent"] = g.persistent; rec["memberIds"] = g.memberIds; for (const auto &kv : g.intColumns) rec["intColumns"][kv.first] = kv.second; @@ -1360,6 +1406,22 @@ nlohmann::json CharacterRegistry::serialize() const } for (const auto &r : m_relationships) { + /* Relationships touching runtime-only or dangling + * endpoints are runtime data themselves — never + * persisted. */ + auto endpointPersistent = [this](uint64_t id, bool isGroup) { + if (isGroup) { + auto it = m_groups.find(id); + return it != m_groups.end() && + it->second.persistent; + } + auto it = m_characters.find(id); + return it != m_characters.end() && + it->second.persistent; + }; + if (!endpointPersistent(r.sourceId, r.sourceIsGroup) || + !endpointPersistent(r.targetId, r.targetIsGroup)) + continue; nlohmann::json rec; rec["sourceId"] = r.sourceId; rec["sourceIsGroup"] = r.sourceIsGroup; @@ -1559,6 +1621,7 @@ void CharacterRegistry::deserialize(const nlohmann::json &j) GroupRecord g; g.id = rec.value("id", 0); g.name = rec.value("name", ""); + g.persistent = rec.value("persistent", true); if (rec.contains("memberIds")) for (const auto &m : rec["memberIds"]) g.memberIds.push_back( diff --git a/src/features/editScene/systems/CharacterRegistry.hpp b/src/features/editScene/systems/CharacterRegistry.hpp index 4a7ad02..14cc564 100644 --- a/src/features/editScene/systems/CharacterRegistry.hpp +++ b/src/features/editScene/systems/CharacterRegistry.hpp @@ -110,6 +110,11 @@ public: std::string name; std::vector memberIds; + /* Runtime-only groups are NOT saved to + * character_registry.json (generated households, phase + * 4.7.2). */ + bool persistent = true; + /* Extensible custom columns */ std::unordered_map intColumns; std::unordered_map floatColumns; @@ -168,6 +173,18 @@ public: const std::string &lastName, const std::string &templatePath = "", bool persistent = true); + /** + * Create a runtime-only character with a CALLER-CHOSEN id (the + * deterministic id bands of generated town populations, phase + * 4.7.2). No prefab file, no auto-save; an existing character + * with the same id is left untouched and returned. Returns 0 + * when the id collides with the persistent/sequential runtime + * allocators' ranges (< 1<<32, or below the population band + * base). + */ + uint64_t ensureRuntimeCharacter(uint64_t id, + const std::string &firstName, + const std::string &lastName); void deleteCharacter(uint64_t id); CharacterRecord *findCharacter(uint64_t id); const CharacterRecord *findCharacter(uint64_t id) const; @@ -180,7 +197,10 @@ public: /* ------------------------------------------------------------------ */ /* Groups / Factions */ /* ------------------------------------------------------------------ */ - uint64_t createGroup(const std::string &name); + uint64_t createGroup(const std::string &name, bool persistent = true); + /** Same as ensureRuntimeCharacter but for groups (deterministic + * household group ids of generated populations). */ + uint64_t ensureRuntimeGroup(uint64_t id, const std::string &name); void deleteGroup(uint64_t id); void addToGroup(uint64_t groupId, uint64_t characterId); void removeFromGroup(uint64_t groupId, uint64_t characterId); @@ -378,7 +398,21 @@ public: /* Auto-save to fixed path (called after mutations) */ void autoSave(); + /** Bulk-update guard: while suspended, mutations skip the + * auto-save file write (population generation adds hundreds of + * records at once, phase 4.7.2). Call autoSave() explicitly + * afterwards if persistent data changed. */ + void setAutoSaveSuspended(bool suspended) + { + m_autoSaveSuspended = suspended; + } + bool isAutoSaveSuspended() const + { + return m_autoSaveSuspended; + } + private: + bool m_autoSaveSuspended = false; uint64_t m_nextId = 1; uint64_t m_nextRuntimeId = 1ull << 32; diff --git a/src/features/editScene/systems/EditorSunSystem.cpp b/src/features/editScene/systems/EditorSunSystem.cpp index a8689cc..5de64e2 100644 --- a/src/features/editScene/systems/EditorSunSystem.cpp +++ b/src/features/editScene/systems/EditorSunSystem.cpp @@ -1,4 +1,5 @@ #include "EditorSunSystem.hpp" +#include "GameClockSystem.hpp" #include "../components/Sun.hpp" #include "../components/Transform.hpp" #include @@ -32,12 +33,17 @@ void EditorSunSystem::update(float deltaTime, Ogre::Camera *camera) sun.dirty = false; } - // Advance time - sun.timeOfDay += deltaTime * sun.timeSpeed; - if (sun.timeOfDay >= 24.0f) - sun.timeOfDay -= 24.0f; - if (sun.timeOfDay < 0.0f) - sun.timeOfDay += 24.0f; + // Advance time (skipped while the GameClockSystem drives + // the sun — demo-taxi phase 4.7.4: one authoritative + // clock for the schedule layer and the visuals) + GameClockSystem *clock = GameClockSystem::getInstance(); + if (!clock || !clock->drivesSun()) { + sun.timeOfDay += deltaTime * sun.timeSpeed; + if (sun.timeOfDay >= 24.0f) + sun.timeOfDay -= 24.0f; + if (sun.timeOfDay < 0.0f) + sun.timeOfDay += 24.0f; + } // Compute sun angle: // 6:00 = 0 rad (horizon, east) diff --git a/src/features/editScene/systems/GameClockSystem.cpp b/src/features/editScene/systems/GameClockSystem.cpp new file mode 100644 index 0000000..832a3de --- /dev/null +++ b/src/features/editScene/systems/GameClockSystem.cpp @@ -0,0 +1,71 @@ +#include "GameClockSystem.hpp" + +#include + +GameClockSystem *GameClockSystem::ms_instance = nullptr; + +GameClockSystem::GameClockSystem(flecs::world &world) + : m_world(world) + , m_sunQuery(world.query()) +{ + ms_instance = this; +} + +GameClockSystem::~GameClockSystem() +{ + ms_instance = nullptr; +} + +void GameClockSystem::setTime(uint32_t day, float timeOfDayHours) +{ + m_day = day; + m_timeOfDay = timeOfDayHours; + while (m_timeOfDay >= 24.0f) { + m_timeOfDay -= 24.0f; + ++m_day; + } + while (m_timeOfDay < 0.0f) { + m_timeOfDay += 24.0f; + --m_day; + } +} + +void GameClockSystem::update(float deltaTime) +{ + if (!m_paused) { + m_timeOfDay += deltaTime * m_speed; + while (m_timeOfDay >= 24.0f) { + m_timeOfDay -= 24.0f; + ++m_day; + } + while (m_timeOfDay < 0.0f) { + m_timeOfDay += 24.0f; + --m_day; + } + } + + if (!m_driveSun) + return; + + /* Adopt the first sun's configured time/speed on sight; later, + * adopt again whenever a sun's time/speed diverges from what the + * clock last wrote (an external edit, e.g. the sun panel + * sliders). Then drive every sun from the clock. */ + m_sunQuery.each([this](SunComponent &sun) { + if (!m_adoptedSun) { + m_timeOfDay = sun.timeOfDay; + m_day = 0; + m_speed = sun.timeSpeed; + m_adoptedSun = true; + m_lastWritten = sun.timeOfDay; + return; + } + if (std::fabs(sun.timeOfDay - m_lastWritten) > 1.0e-3f || + std::fabs(sun.timeSpeed - m_speed) > 1.0e-6f) { + m_timeOfDay = sun.timeOfDay; + m_speed = sun.timeSpeed; + } + sun.timeOfDay = m_timeOfDay; + }); + m_lastWritten = m_timeOfDay; +} diff --git a/src/features/editScene/systems/GameClockSystem.hpp b/src/features/editScene/systems/GameClockSystem.hpp new file mode 100644 index 0000000..e9ea929 --- /dev/null +++ b/src/features/editScene/systems/GameClockSystem.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include + +#include + +#include "../components/Sun.hpp" + +/** + * Game clock (demo-taxi phase 4.7.4). + * + * The one queryable time source for the schedule/goal layer: a day + * counter plus time-of-day hours, pauseable, advanced by frame delta + * (fixed-step friendly — the caller passes the same fixed dt it uses + * for physics). Singleton owned by EditorApp, same pattern as + * NpcPopulationSystem. + * + * Sun integration: with drivesSun (the default) the clock adopts the + * first SunComponent's timeOfDay/timeSpeed on sight, then writes the + * clock time back to every sun each frame — the visuals and the + * schedule clock can never diverge. An external edit (the sun panel + * sliders) is detected by divergence from the last written value and + * re-adopted. EditorSunSystem skips its own time advance while the + * clock drives. Scenes without a GameClockSystem (other demos) + * behave exactly as before. + */ +class GameClockSystem +{ +public: + GameClockSystem(flecs::world &world); + ~GameClockSystem(); + + static GameClockSystem *getInstance() + { + return ms_instance; + } + + /** Advance the clock (unless paused) and drive the suns. + * Call before EditorSunSystem::update. */ + void update(float deltaTime); + + uint32_t getDay() const + { + return m_day; + } + /** Hours, [0, 24). */ + float getTimeOfDayHours() const + { + return m_timeOfDay; + } + /** day * 24 + timeOfDay. */ + float getAbsoluteHours() const + { + return m_day * 24.0f + m_timeOfDay; + } + /** Game-hours per real second. */ + float getSpeed() const + { + return m_speed; + } + void setSpeed(float speed) + { + m_speed = speed; + } + bool isPaused() const + { + return m_paused; + } + void setPaused(bool paused) + { + m_paused = paused; + } + void setTime(uint32_t day, float timeOfDayHours); + + bool drivesSun() const + { + return m_driveSun; + } + void setDrivesSun(bool drive) + { + m_driveSun = drive; + } + +private: + flecs::world &m_world; + flecs::query m_sunQuery; + + uint32_t m_day = 0; + float m_timeOfDay = 12.0f; + /* Matches the SunComponent default. */ + float m_speed = 0.05f; + bool m_paused = false; + bool m_driveSun = true; + /* Adopted the initial time/speed from a sun. */ + bool m_adoptedSun = false; + /* What the clock last wrote into the suns (external-edit + * divergence detection). */ + float m_lastWritten = 12.0f; + + static GameClockSystem *ms_instance; +}; diff --git a/src/features/editScene/systems/NpcPopulationSystem.cpp b/src/features/editScene/systems/NpcPopulationSystem.cpp new file mode 100644 index 0000000..c62075a --- /dev/null +++ b/src/features/editScene/systems/NpcPopulationSystem.cpp @@ -0,0 +1,285 @@ +#include "NpcPopulationSystem.hpp" + +#include + +#include "CharacterRegistry.hpp" +#include "RoadSystem.hpp" +#include "TerrainSystem.hpp" +#include "TownGenSystem.hpp" +#include "../components/RoadGraph.hpp" + +/* Id layout inside one area's band (AREA_POP_CAP = 1<<20): + * [0, AREA_POP_CAP/2) character ids (building * HOUSEHOLD_ID_CAP + + * member — up to 8192 buildings of up to 63 members), and + * [AREA_POP_CAP/2, AREA_POP_CAP) household group ids. */ +static const uint64_t kGroupBandOffset = TownPopulation::AREA_POP_CAP / 2; + +/* Population draws are decorrelated from the town layout draws. */ +static const uint32_t kPopSeedXor = 0x9e3779b9u; + +/* Location rolls are decorrelated from both (phase 4.7.3). */ +static const uint32_t kLocSeedXor = 0x85ebca6bu; + +/* Schedule draws get their own decorrelation (phase 4.7.4). */ +static const uint32_t kScheduleSeedXor = 0xc2b2ae35u; + +NpcPopulationSystem *NpcPopulationSystem::ms_instance = nullptr; + +NpcPopulationSystem::NpcPopulationSystem(flecs::world &world) + : m_world(world) +{ + ms_instance = this; +} + +NpcPopulationSystem::~NpcPopulationSystem() +{ + ms_instance = nullptr; +} + +int NpcPopulationSystem::generateAreaPopulation(int areaIndex, + std::string *error) +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + TownGenSystem *townGen = ts ? ts->getTownGenSystem() : nullptr; + if (!townGen) { + if (error) + *error = "no terrain system"; + return 0; + } + + TownGen::TownResult town; + if (!townGen->runAreaGeneration(areaIndex, town, error)) + return 0; + if (town.buildings.empty()) { + if (error) + *error = "no buildings in the town area"; + return 0; + } + + const RoadGraph *graph = nullptr; + flecs::entity terrain = ts->getRoadSystem()->getTerrainEntity(); + if (terrain.is_alive() && terrain.has()) + graph = &terrain.get().roadGraph; + if (!graph || areaIndex < 0 || + areaIndex >= (int)graph->townAreas.size()) { + if (error) + *error = "town area index out of range"; + return 0; + } + const RoadTownArea &area = graph->townAreas[areaIndex]; + + TownPopulation::PopulationRules rules; + uint32_t popSeed = area.seed ^ kPopSeedXor; + uint64_t idBase = TownPopulation::NPC_ID_BASE + + (uint64_t)areaIndex * TownPopulation::AREA_POP_CAP; + if (town.buildings.size() * TownPopulation::HOUSEHOLD_ID_CAP >= + kGroupBandOffset) { + if (error) + *error = "too many buildings for the population id " + "band"; + return 0; + } + + TownPopulation::Population pop = + TownPopulation::generatePopulation(town, popSeed, rules, + idBase); + + /* Phase 4.7.3: the area's symbolic locations (home per + * building, curb per pickup POI, parking per parking POI, + * interest subset). */ + TownLocations::LocationSet locations = + TownLocations::buildLocations(town, area.seed ^ kLocSeedXor); + + CharacterRegistry ® = CharacterRegistry::getSingleton(); + reg.setAutoSaveSuspended(true); + + /* Replace exactly what this area generated before. */ + clearArea(areaIndex); + + AreaPopulation ap; + for (size_t hi = 0; hi < pop.households.size(); ++hi) { + const TownPopulation::Household &hh = pop.households[hi]; + uint64_t groupId = reg.ensureRuntimeGroup( + idBase + kGroupBandOffset + (uint64_t)hi, + hh.address + " household"); + if (!groupId) + continue; + ap.groupIds.push_back(groupId); + + for (size_t mi = 0; mi < hh.members.size(); ++mi) { + const TownPopulation::PopMember &pm = + pop.members[hh.members[mi]]; + uint64_t id = reg.ensureRuntimeCharacter( + pm.registryId, pm.firstName, pm.lastName); + if (!id) + continue; + ap.characterIds.push_back(id); + CharacterRegistry::CharacterRecord *rec = + reg.findCharacter(id); + rec->inlineSex = pm.sex; + rec->age = pm.age; + rec->ageYears = pm.ageYears; + rec->inlineOutfitLevel = pm.outfitLevel; + rec->stringColumns["npcKey"] = pm.stableKey; + rec->stringColumns["npcHome"] = hh.address; + rec->stringColumns["npcArea"] = area.name; + /* Phase 4.7.3 location bindings (shared with + * the 4.7.4 schedule generator): work picks + * from the area's Interest locations (a + * placeholder until zoning exists), interest + * from Interest+Curb — both fall back to the + * home location when the town has none. */ + TownLocations::NpcBindings bind = + TownLocations::computeBindings( + locations, pm.stableKey, hh.address); + rec->stringColumns["npcWork"] = bind.work; + rec->stringColumns["npcInterest"] = bind.interest; + rec->tags.push_back("npc"); + rec->tags.push_back("townie"); + /* Symbolic home position: the building centre + * (the motion core moves it from here). */ + rec->position = town.buildings[hh.building].center; + reg.addToGroup(groupId, id); + } + + /* Household-local relationship indices -> registry ids. */ + for (const TownPopulation::PopRelationship &r : + hh.relationships) { + uint64_t fromId = + pop.members[hh.members[r.from]].registryId; + uint64_t toId = + pop.members[hh.members[r.to]].registryId; + reg.addRelationshipTag(fromId, false, toId, false, + r.tag); + } + } + + ap.locations = std::move(locations); + /* Phase 4.7.4: per-NPC time-of-day schedules over the area's + * locations (same town state -> same schedules; a script + * forcing a regeneration stays deterministic). */ + ap.schedules = TownSchedule::generateSchedules( + pop, ap.locations, area.seed ^ kScheduleSeedXor, + TownSchedule::ScheduleRules()); + m_areas[areaIndex] = ap; + reg.setAutoSaveSuspended(false); + + Ogre::LogManager::getSingleton().logMessage( + "NpcPopulationSystem[" + area.name + "]: " + + std::to_string(ap.characterIds.size()) + " NPCs in " + + std::to_string(ap.groupIds.size()) + " households"); + return (int)ap.characterIds.size(); +} + +int NpcPopulationSystem::generateAllAreas(std::string *error) +{ + TerrainSystem *ts = TerrainSystem::getInstance(); + if (!ts || !ts->getRoadSystem()) { + if (error) + *error = "no terrain system"; + return 0; + } + flecs::entity terrain = ts->getRoadSystem()->getTerrainEntity(); + if (!terrain.is_alive() || !terrain.has()) { + if (error) + *error = "no active terrain"; + return 0; + } + const RoadGraph &graph = + terrain.get().roadGraph; + if (graph.townAreas.empty()) { + if (error) + *error = "no town areas defined"; + return 0; + } + + int total = 0; + for (size_t ai = 0; ai < graph.townAreas.size(); ++ai) { + std::string areaError; + int n = generateAreaPopulation((int)ai, &areaError); + if (n == 0 && !areaError.empty()) + Ogre::LogManager::getSingleton().logMessage( + "NpcPopulationSystem: area " + + graph.townAreas[ai].name + ": " + + areaError); + total += n; + } + return total; +} + +int NpcPopulationSystem::clearArea(int areaIndex) +{ + auto it = m_areas.find(areaIndex); + if (it == m_areas.end()) + return 0; + + CharacterRegistry ® = CharacterRegistry::getSingleton(); + int removed = 0; + for (uint64_t id : it->second.characterIds) { + if (reg.findCharacter(id)) { + reg.deleteCharacter(id); + ++removed; + } + } + for (uint64_t id : it->second.groupIds) { + if (reg.findGroup(id)) + reg.deleteGroup(id); + } + m_areas.erase(it); + return removed; +} + +int NpcPopulationSystem::clearAreaPopulation(int areaIndex) +{ + CharacterRegistry ® = CharacterRegistry::getSingleton(); + reg.setAutoSaveSuspended(true); + int removed = clearArea(areaIndex); + reg.setAutoSaveSuspended(false); + return removed; +} + +int NpcPopulationSystem::clearAllPopulations() +{ + CharacterRegistry ® = CharacterRegistry::getSingleton(); + reg.setAutoSaveSuspended(true); + int removed = 0; + while (!m_areas.empty()) + removed += clearArea(m_areas.begin()->first); + reg.setAutoSaveSuspended(false); + return removed; +} + +int NpcPopulationSystem::countPopulationNpcs() const +{ + int n = 0; + for (const auto &kv : m_areas) + n += (int)kv.second.characterIds.size(); + return n; +} + +std::vector NpcPopulationSystem::populationCharacterIds() const +{ + std::vector out; + for (const auto &kv : m_areas) + out.insert(out.end(), kv.second.characterIds.begin(), + kv.second.characterIds.end()); + return out; +} + +const TownLocations::LocationSet * +NpcPopulationSystem::areaLocations(int areaIndex) const +{ + auto it = m_areas.find(areaIndex); + return it == m_areas.end() ? nullptr : &it->second.locations; +} + +const TownSchedule::NpcSchedule * +NpcPopulationSystem::scheduleOf(uint64_t registryId) const +{ + for (const auto &kv : m_areas) + for (const TownSchedule::NpcSchedule &s : + kv.second.schedules) + if (s.registryId == registryId) + return &s; + return nullptr; +} diff --git a/src/features/editScene/systems/NpcPopulationSystem.hpp b/src/features/editScene/systems/NpcPopulationSystem.hpp new file mode 100644 index 0000000..91a56ff --- /dev/null +++ b/src/features/editScene/systems/NpcPopulationSystem.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "../roadlib/TownPopulation.hpp" +#include "../roadlib/TownLocations.hpp" +#include "../roadlib/TownSchedule.hpp" + +/** + * NPC population system (demo-taxi phase 4.7.2). + * + * Generates the deterministic population of a town area + * (roadlib/TownPopulation over the area's pure TownGen result, + * re-run through TownGenSystem::runAreaGeneration so it always + * matches the generated/streamed buildings) and applies it to the + * CharacterRegistry as runtime-only entries: + * + * - one runtime GroupRecord per household (deterministic id), + * - one runtime CharacterRecord per member (deterministic id from + * the area's band) with the attribute-driven look fields + * (inlineSex/age/ageYears/inlineOutfitLevel), the home binding and + * the stable key in stringColumns, and the symbolic position at + * the home building, + * - the seeded household relationships (spouse/parent/child/ + * sibling, directed). + * + * Re-generation of the same area replaces exactly the entries + * recorded for that area, so a town rebuild never leaks or + * duplicates population entries. Everything is runtime-only: + * nothing reaches character_registry.json. + * + * Phase 4.7.3 (location model): each area also owns its generated + * TownLocations::LocationSet (areaLocations()), and every NPC gets + * deterministic location bindings in stringColumns — npcHome stays + * the household address (the home location is "home:" + address), + * npcWork / npcInterest hold location names picked deterministically + * from the area's Interest (work placeholder until zoning exists) + * and Interest+Curb locations. + */ +class NpcPopulationSystem +{ +public: + NpcPopulationSystem(flecs::world &world); + ~NpcPopulationSystem(); + + /** Singleton access (same pattern as TerrainSystem). */ + static NpcPopulationSystem *getInstance() + { + return ms_instance; + } + + /** Generate (or replace) the population of town area + * @p areaIndex. Returns the number of NPCs (0 on failure, + * @p error set). */ + int generateAreaPopulation(int areaIndex, std::string *error); + + /** Generate the populations of every town area of the active + * terrain's road graph. Returns the total number of NPCs. */ + int generateAllAreas(std::string *error); + + /** Remove the generated population of one area. Returns the + * number of character records removed. */ + int clearAreaPopulation(int areaIndex); + + /** Remove every generated population. */ + int clearAllPopulations(); + + /** Generated population NPCs currently in the registry. */ + int countPopulationNpcs() const; + + /** All generated population character ids (area order, then + * id band order) — for inspection/debug UIs. */ + std::vector populationCharacterIds() const; + + /** The generated location set of an area, nullptr when the + * area has no generated population. */ + const TownLocations::LocationSet *areaLocations(int areaIndex) const; + + /** The generated schedule of a population NPC (phase 4.7.4), + * nullptr when unknown. Pair with TownSchedule::entryAt() and + * GameClockSystem::getTimeOfDayHours() for the current goal. */ + const TownSchedule::NpcSchedule *scheduleOf(uint64_t registryId) const; + +private: + /** Ids currently owned by an area (characters + household + * groups) for exact replacement on regeneration, plus the + * area's generated locations and schedules. */ + struct AreaPopulation { + std::vector characterIds; + std::vector groupIds; + TownLocations::LocationSet locations; + std::vector schedules; + }; + + int clearArea(int areaIndex); + + flecs::world &m_world; + std::map m_areas; + + static NpcPopulationSystem *ms_instance; +}; diff --git a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp new file mode 100644 index 0000000..d67796c --- /dev/null +++ b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp @@ -0,0 +1,200 @@ +#include "NpcScheduleDebugOverlay.hpp" + +#include + +#include +#include + +#include "../EditorApp.hpp" +#include "CharacterRegistry.hpp" +#include "GameClockSystem.hpp" +#include "NpcPopulationSystem.hpp" + +NpcScheduleDebugOverlay::NpcScheduleDebugOverlay(EditorApp *editorApp) + : m_editorApp(editorApp) +{ +} + +void NpcScheduleDebugOverlay::render() +{ + if (!m_visible || !m_editorApp) + return; + if (m_editorApp->getGameMode() != EditorApp::GameMode::Game) + return; + /* Headless runs have no ImGui context. */ + if (!ImGui::GetCurrentContext()) + return; + + GameClockSystem *clock = GameClockSystem::getInstance(); + NpcPopulationSystem *pop = NpcPopulationSystem::getInstance(); + + ImGui::SetNextWindowSize(ImVec2(620.0f, 520.0f), + ImGuiCond_FirstUseEver); + bool open = m_visible; + if (!ImGui::Begin("NPC Schedules (F7)", &open)) { + ImGui::End(); + m_visible = open; + return; + } + + const float tod = clock ? clock->getTimeOfDayHours() : 0.0f; + + if (clock) { + const int hh = (int)tod; + const int mm = (int)((tod - hh) * 60.0f); + ImGui::Text("Day %u, %02d:%02d", clock->getDay(), hh, mm); + bool paused = clock->isPaused(); + if (ImGui::Checkbox("Paused", &paused)) + clock->setPaused(paused); + float speed = clock->getSpeed(); + if (ImGui::SliderFloat("Speed (game h/s)", &speed, 0.0f, + 2.0f)) + clock->setSpeed(speed); + float t = tod; + if (ImGui::SliderFloat("Time of Day", &t, 0.0f, 24.0f, + "%.2f h")) + clock->setTime(clock->getDay(), t); + } else { + ImGui::TextUnformatted("No game clock system"); + } + ImGui::Separator(); + + if (!pop) { + ImGui::TextUnformatted("No population system"); + ImGui::End(); + m_visible = open; + return; + } + + CharacterRegistry ® = CharacterRegistry::getSingleton(); + std::vector ids = pop->populationCharacterIds(); + ImGui::Text("%zu population NPCs", ids.size()); + if (ids.empty()) { + ImGui::TextUnformatted( + "(generate the town population first)"); + ImGui::End(); + m_visible = open; + return; + } + if (m_selected >= (int)ids.size()) + m_selected = -1; + + /* Group by town area (the npcArea string column). */ + std::map > byArea; + for (uint64_t id : ids) { + const CharacterRegistry::CharacterRecord *rec = + reg.findCharacter(id); + std::string area; + if (rec) { + auto it = rec->stringColumns.find("npcArea"); + if (it != rec->stringColumns.end()) + area = it->second; + } + byArea[area].push_back(id); + } + + ImGui::BeginChild("NpcList", ImVec2(300.0f, 0.0f), true); + int flatIndex = 0; + for (const auto &kv : byArea) { + if (!ImGui::TreeNode(kv.first.c_str(), "%s (%zu)", + kv.first.empty() ? "?" : + kv.first.c_str(), + kv.second.size())) + continue; + for (uint64_t id : kv.second) { + const CharacterRegistry::CharacterRecord *rec = + reg.findCharacter(id); + if (!rec) + continue; + const TownSchedule::NpcSchedule *sched = + pop->scheduleOf(id); + const TownSchedule::ScheduleEntry *entry = + sched ? TownSchedule::entryAt(*sched, tod) : + nullptr; + std::string label = rec->firstName + " " + + rec->lastName; + if (entry) + label += " | " + entry->activity + " @ " + + entry->location; + if (ImGui::Selectable(label.c_str(), + m_selected == flatIndex)) + m_selected = flatIndex; + ++flatIndex; + } + ImGui::TreePop(); + } + ImGui::EndChild(); + + ImGui::SameLine(); + ImGui::BeginChild("NpcDetails", ImVec2(0.0f, 0.0f), true); + if (m_selected >= 0) { + /* Re-flatten to find the selected id. */ + uint64_t selId = 0; + int fi = 0; + for (const auto &kv : byArea) + for (uint64_t id : kv.second) { + if (fi == m_selected) + selId = id; + ++fi; + } + const CharacterRegistry::CharacterRecord *rec = + selId ? reg.findCharacter(selId) : nullptr; + const TownSchedule::NpcSchedule *sched = + selId ? pop->scheduleOf(selId) : nullptr; + if (rec && sched) { + ImGui::Text("%s %s (%s, %d y)", rec->firstName.c_str(), + rec->lastName.c_str(), rec->age.c_str(), + rec->ageYears); + auto col = [rec](const char *key) -> std::string { + auto it = rec->stringColumns.find(key); + return it == rec->stringColumns.end() ? + std::string() : + it->second; + }; + ImGui::Text("home: %s", col("npcHome").c_str()); + ImGui::Text("work: %s", col("npcWork").c_str()); + ImGui::Text("interest: %s", + col("npcInterest").c_str()); + ImGui::Separator(); + if (ImGui::BeginTable("schedule", 4, + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("From"); + ImGui::TableSetupColumn("To"); + ImGui::TableSetupColumn("Activity"); + ImGui::TableSetupColumn("Location"); + ImGui::TableHeadersRow(); + for (const TownSchedule::ScheduleEntry &e : + sched->entries) { + const bool active = tod >= e.startHour && + tod < e.endHour; + ImGui::TableNextRow(); + if (active) + ImGui::TableSetBgColor( + ImGuiTableBgTarget_RowBg0, + IM_COL32(60, 90, 60, + 128)); + ImGui::TableNextColumn(); + ImGui::Text("%05.2f", e.startHour); + ImGui::TableNextColumn(); + ImGui::Text("%05.2f", e.endHour); + ImGui::TableNextColumn(); + ImGui::TextUnformatted( + e.activity.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted( + e.location.c_str()); + } + ImGui::EndTable(); + } + } else { + ImGui::TextUnformatted("(no schedule)"); + } + } else { + ImGui::TextUnformatted("Select an NPC"); + } + ImGui::EndChild(); + + ImGui::End(); + m_visible = open; +} diff --git a/src/features/editScene/systems/NpcScheduleDebugOverlay.hpp b/src/features/editScene/systems/NpcScheduleDebugOverlay.hpp new file mode 100644 index 0000000..a8097bf --- /dev/null +++ b/src/features/editScene/systems/NpcScheduleDebugOverlay.hpp @@ -0,0 +1,37 @@ +#pragma once + +class EditorApp; + +/** + * NPC schedule debug overlay (demo-taxi phase 4.7.4). + * + * Game-mode-only ImGui window toggled with F7: game clock readout + * with pause/speed/time-of-day controls, the per-town-area list of + * population NPCs with the schedule entry active at the current + * time, and the selected NPC's full 24h schedule + location + * bindings. Read-only for everything except the clock. + */ +class NpcScheduleDebugOverlay +{ +public: + NpcScheduleDebugOverlay(EditorApp *editorApp); + + void toggle() + { + m_visible = !m_visible; + } + bool isVisible() const + { + return m_visible; + } + + /** Render the window (no-op unless visible in game mode with a + * live ImGui context). */ + void render(); + +private: + EditorApp *m_editorApp; + bool m_visible = false; + /** Index into the flattened population id list, -1 = none. */ + int m_selected = -1; +}; diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index c857577..83c53ef 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -50,6 +50,7 @@ #include "../components/Actuator.hpp" #include "../components/Pushable.hpp" #include "../components/TargetZone.hpp" +#include "../components/NpcLocation.hpp" #include "../components/GoapPlanner.hpp" #include "../components/Item.hpp" #include "../components/Inventory.hpp" @@ -422,6 +423,9 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) if (entity.has()) { json["targetZone"] = serializeTargetZone(entity); } + if (entity.has()) { + json["npcLocation"] = serializeNpcLocation(entity); + } if (entity.has()) { json["eventHandler"] = serializeEventHandler(entity); } @@ -677,6 +681,9 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, if (json.contains("targetZone")) { deserializeTargetZone(entity, json["targetZone"]); } + if (json.contains("npcLocation")) { + deserializeNpcLocation(entity, json["npcLocation"]); + } if (json.contains("eventHandler")) { deserializeEventHandler(entity, json["eventHandler"]); } @@ -1008,6 +1015,9 @@ void SceneSerializer::deserializeEntityComponents( if (json.contains("targetZone")) { deserializeTargetZone(entity, json["targetZone"]); } + if (json.contains("npcLocation")) { + deserializeNpcLocation(entity, json["npcLocation"]); + } if (json.contains("sceneScript")) { deserializeSceneScript(entity, json["sceneScript"]); } @@ -4509,6 +4519,28 @@ void SceneSerializer::deserializeTargetZone(flecs::entity entity, entity.set(zone); } +nlohmann::json SceneSerializer::serializeNpcLocation(flecs::entity entity) +{ + const NpcLocationComponent &loc = entity.get(); + nlohmann::json json; + json["kind"] = loc.kind; + json["name"] = loc.name; + json["radius"] = loc.radius; + json["enabled"] = loc.enabled; + return json; +} + +void SceneSerializer::deserializeNpcLocation(flecs::entity entity, + const nlohmann::json &json) +{ + NpcLocationComponent loc; + loc.kind = json.value("kind", 3); + loc.name = json.value("name", ""); + loc.radius = json.value("radius", 2.0f); + loc.enabled = json.value("enabled", true); + entity.set(loc); +} + nlohmann::json SceneSerializer::serializeEventHandler(flecs::entity entity) { const EventHandlerComponent &handler = diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 9c7c861..1a90b0e 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -281,6 +281,7 @@ private: nlohmann::json serializeActuator(flecs::entity entity); nlohmann::json serializePushable(flecs::entity entity); nlohmann::json serializeTargetZone(flecs::entity entity); + nlohmann::json serializeNpcLocation(flecs::entity entity); nlohmann::json serializeEventHandler(flecs::entity entity); nlohmann::json serializeSceneScript(flecs::entity entity); nlohmann::json serializeStandaloneDoor(flecs::entity entity); @@ -300,6 +301,8 @@ private: const nlohmann::json &json); void deserializeTargetZone(flecs::entity entity, const nlohmann::json &json); + void deserializeNpcLocation(flecs::entity entity, + const nlohmann::json &json); void deserializeEventHandler(flecs::entity entity, const nlohmann::json &json); void deserializeSceneScript(flecs::entity entity, diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index 6b4383e..dfd82cd 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -448,6 +448,47 @@ int TownGenSystem::generateAreas(std::string *error) return total; } +bool TownGenSystem::runAreaGeneration(int areaIndex, + TownGen::TownResult &out, + std::string *error) +{ + TerrainSystem *ts = m_terrainSystem ? + m_terrainSystem : + TerrainSystem::getInstance(); + const RoadGraph *graph = nullptr; + float waterY = -FLT_MAX; + RoadNetworkGen::HeightSampler sampler; + if (!setupTownRun(ts, &graph, &waterY, &sampler, m_world, error)) + return false; + + if (areaIndex < 0 || areaIndex >= (int)graph->townAreas.size()) { + if (error) + *error = "town area index out of range"; + return false; + } + + const RoadTownArea &area = graph->townAreas[areaIndex]; + TownGen::TownParams p; + p.district = (TownGen::District)(int)area.district; + p.seed = area.seed; + p.densityScale = area.densityScale; + p.waterLevel = waterY; + + LayoutLib::LayoutLibrary layouts = loadLayoutLibrary(); + + /* See generate(): paged generation on streaming terrains. */ + TownGen::PageScope scope; + const TownGen::PageScope *scopePtr = nullptr; + if (ts->getStreamingActive() && ts->getTerrainGroup()) { + scope.mapper = makePageMapper(ts); + scopePtr = &scope; + } + + out = TownGen::generateTown(*graph, p, sampler, &layouts, + &graph->townAreas, areaIndex, scopePtr); + return true; +} + int TownGenSystem::regenerateBuilding(const std::string &address, const std::string &genMode, std::string *error) diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp index 4d40999..6f4af82 100644 --- a/src/features/editScene/systems/TownGenSystem.hpp +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -59,6 +59,16 @@ public: * set — also when the graph defines no town areas). */ int generateAreas(std::string *error); + /** Phase 4.7.2: re-run the PURE generation of one town area + * (no entities created) and return the TownResult — the + * population/location generators consume it. Uses the same + * graph, area params, height sampler, layout library and page + * scope as generateAreas(), so the result matches the generated + * (or streamed) town exactly. Returns false with @p error set + * on failure. */ + bool runAreaGeneration(int areaIndex, TownGen::TownResult &out, + std::string *error); + /** Per-frame driver (phase 4.5 item 5, called from * TerrainSystem::update). Game mode + streaming terrain + town * areas in the graph: regenerates each loaded page's buildings diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index 6d1fd00..904fe8c 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -37,6 +37,9 @@ #include "roadlib/RoadGeometryLib.hpp" #include "roadlib/RoadNetworkGen.hpp" #include "roadlib/TownGen.hpp" +#include "roadlib/TownPopulation.hpp" +#include "roadlib/TownLocations.hpp" +#include "roadlib/TownSchedule.hpp" #include #include @@ -4837,6 +4840,775 @@ static bool runTownGenProbe() printf(" %s\n", ok ? "passed" : "FAILURES above"); return ok; } + +/* Phase 4.7.2 probe: population generation over a generated town. + * Determinism (same seed -> identical population), per-building RNG + * isolation (renaming one building shifts only its own household), + * household composition invariants, relationship symmetry and + * registry id uniqueness. */ +static bool runTownPopulationProbe() +{ + printf("town population probe\n"); + bool ok = true; + + RoadGraph graph; + { + int ids[3][3]; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + ids[i][j] = graph.addNode( + Vector3(i * 100.0f, 0, j * 100.0f), + 0.0f); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) { + if (i < 2) + graph.addEdge(ids[i][j], + ids[i + 1][j]); + if (j < 2) + graph.addEdge(ids[i][j], + ids[i][j + 1]); + } + } + RoadNetworkGen::HeightSampler flat = [](float, float) { + return 0.0f; + }; + TownGen::TownParams cc; + cc.district = TownGen::District::CityCenter; + cc.seed = 7; + TownGen::TownResult town = TownGen::generateTown(graph, cc, flat); + if (town.buildings.size() != 12) { + printf(" *** setup: %zu buildings, want 12 ***\n", + town.buildings.size()); + return false; + } + + TownPopulation::PopulationRules rules; + const uint32_t popSeed = 42; + TownPopulation::Population a = + TownPopulation::generatePopulation(town, popSeed, rules); + TownPopulation::Population b = + TownPopulation::generatePopulation(town, popSeed, rules); + + /* Determinism: identical seed -> identical output. */ + auto sameMember = [](const TownPopulation::PopMember &x, + const TownPopulation::PopMember &y) { + return x.registryId == y.registryId && + x.stableKey == y.stableKey && + x.household == y.household && + x.firstName == y.firstName && + x.lastName == y.lastName && x.sex == y.sex && + x.age == y.age && x.ageYears == y.ageYears && + x.outfitLevel == y.outfitLevel; + }; + if (a.members.size() != b.members.size() || + a.households.size() != b.households.size()) { + printf(" *** determinism: size mismatch ***\n"); + ok = false; + } else { + for (size_t i = 0; i < a.members.size(); ++i) { + if (!sameMember(a.members[i], b.members[i])) { + printf(" *** determinism: member %zu " + "differs ***\n", i); + ok = false; + break; + } + } + for (size_t i = 0; i < a.households.size(); ++i) { + const TownPopulation::Household &ha = + a.households[i]; + const TownPopulation::Household &hb = + b.households[i]; + if (ha.members != hb.members || + ha.relationships.size() != + hb.relationships.size()) { + printf(" *** determinism: household %zu " + "differs ***\n", i); + ok = false; + break; + } + for (size_t r = 0; r < ha.relationships.size(); ++r) { + const TownPopulation::PopRelationship &ra = + ha.relationships[r]; + const TownPopulation::PopRelationship &rb = + hb.relationships[r]; + if (ra.from != rb.from || ra.to != rb.to || + ra.tag != rb.tag) { + printf(" *** determinism: " + "relationship %zu of " + "household %zu differs ***\n", + r, i); + ok = false; + break; + } + } + } + } + + /* Invariants. */ + if (a.households.size() != town.buildings.size()) { + printf(" *** %zu households for %zu buildings ***\n", + a.households.size(), town.buildings.size()); + ok = false; + } + std::set ids; + for (size_t hi = 0; hi < a.households.size(); ++hi) { + const TownPopulation::Household &hh = a.households[hi]; + int size = (int)hh.members.size(); + if (size < rules.minHouseholdSize || + size > rules.maxHouseholdSize) { + printf(" *** household %zu size %d out of " + "[%d,%d] ***\n", hi, size, + rules.minHouseholdSize, + rules.maxHouseholdSize); + ok = false; + } + int adults = 0; + for (size_t mi = 0; mi < hh.members.size(); ++mi) { + int gi = hh.members[mi]; + if (gi < 0 || gi >= (int)a.members.size()) { + printf(" *** household %zu member index " + "%d out of range ***\n", hi, gi); + ok = false; + continue; + } + const TownPopulation::PopMember &m = a.members[gi]; + if (m.household != (int)hi) { + printf(" *** member %d back-pointer %d, " + "want %zu ***\n", gi, m.household, hi); + ok = false; + } + if (m.lastName != hh.lastName) { + printf(" *** member %d lastName '%s', " + "want family '%s' ***\n", gi, + m.lastName.c_str(), + hh.lastName.c_str()); + ok = false; + } + bool isChild = m.age == "child"; + bool isAdult = m.age == "adult"; + bool isElder = m.age == "elder"; + if (!isChild && !isAdult && !isElder) { + printf(" *** member %d bad age band " + "'%s' ***\n", gi, m.age.c_str()); + ok = false; + } + if (isChild && + (m.ageYears < rules.childMinYears || + m.ageYears > rules.childMaxYears)) { + printf(" *** child ageYears %d ***\n", + m.ageYears); + ok = false; + } + if (isAdult && + (m.ageYears < rules.adultMinYears || + m.ageYears > rules.adultMaxYears)) { + printf(" *** adult ageYears %d ***\n", + m.ageYears); + ok = false; + } + if (isElder && + (m.ageYears < rules.elderMinYears || + m.ageYears > rules.elderMaxYears)) { + printf(" *** elder ageYears %d ***\n", + m.ageYears); + ok = false; + } + /* Adults come first in the member list. */ + if (!isChild) { + ++adults; + if (mi >= 2) { + printf(" *** adult at member " + "slot %zu ***\n", mi); + ok = false; + } + } + if (m.sex != "male" && m.sex != "female") { + printf(" *** member %d bad sex '%s' ***\n", + gi, m.sex.c_str()); + ok = false; + } + if (!ids.insert(m.registryId).second) { + printf(" *** duplicate registryId %llu " + "***\n", + (unsigned long long)m.registryId); + ok = false; + } + if (m.registryId < TownPopulation::NPC_ID_BASE || + m.registryId >= TownPopulation::NPC_ID_BASE + + town.buildings.size() * + TownPopulation::HOUSEHOLD_ID_CAP) { + printf(" *** registryId %llu outside the " + "per-building bands ***\n", + (unsigned long long)m.registryId); + ok = false; + } + } + if (adults < 1 || adults > 2) { + printf(" *** household %zu has %d adults ***\n", + hi, adults); + ok = false; + } + /* Relationship sanity: indices in range, spouse + * symmetric, child tags go child -> adult. */ + for (const TownPopulation::PopRelationship &r : + hh.relationships) { + if (r.from < 0 || r.from >= size || r.to < 0 || + r.to >= size || r.from == r.to) { + printf(" *** household %zu bad " + "relationship %d->%d '%s' ***\n", hi, + r.from, r.to, r.tag.c_str()); + ok = false; + continue; + } + const TownPopulation::PopMember &mf = + a.members[hh.members[r.from]]; + const TownPopulation::PopMember &mt = + a.members[hh.members[r.to]]; + if (r.tag == "spouse") { + bool back = false; + for (const TownPopulation::PopRelationship &q : + hh.relationships) + if (q.from == r.to && + q.to == r.from && + q.tag == "spouse") + back = true; + if (!back) { + printf(" *** asymmetric spouse in " + "household %zu ***\n", hi); + ok = false; + } + if (mf.age == "child" || + mt.age == "child") { + printf(" *** spouse involving a " + "child ***\n"); + ok = false; + } + } + if (r.tag == "child" && + (mf.age != "child" || mt.age == "child")) { + printf(" *** 'child' tag %s -> %s ***\n", + mf.age.c_str(), mt.age.c_str()); + ok = false; + } + } + } + + /* Per-building RNG isolation: renaming one building in a copy + * of the town must change only that building's household (its + * size may change too, shifting the global member list — the + * households are compared pairwise, not the flat arrays). */ + { + TownGen::TownResult town2 = town; + town2.buildings[5].address += " renamed"; + TownPopulation::Population c = + TownPopulation::generatePopulation(town2, popSeed, + rules); + if (c.households.size() != a.households.size()) { + printf(" *** isolation: household count %zu -> " + "%zu ***\n", a.households.size(), + c.households.size()); + ok = false; + } else { + for (size_t hi = 0; hi < a.households.size(); ++hi) { + bool expectSame = hi != 5; + bool same = true; + const TownPopulation::Household &ha = + a.households[hi]; + const TownPopulation::Household &hc = + c.households[hi]; + if (ha.members.size() != hc.members.size()) { + same = false; + } else { + for (size_t mi = 0; + mi < ha.members.size(); ++mi) { + if (!sameMember( + a.members[ha.members + [mi]], + c.members[hc.members + [mi]])) + same = false; + } + } + if (expectSame && !same) { + printf(" *** isolation: household " + "%zu changed ***\n", hi); + ok = false; + } + if (!expectSame && same) { + printf(" *** isolation: renamed " + "household unchanged ***\n"); + ok = false; + } + } + } + } + + /* Different seed -> different population. */ + { + TownPopulation::Population d = + TownPopulation::generatePopulation(town, popSeed + 1, + rules); + bool anyDiff = d.members.size() != a.members.size(); + if (!anyDiff) + for (size_t i = 0; i < a.members.size(); ++i) + if (!sameMember(a.members[i], d.members[i])) { + anyDiff = true; + break; + } + if (!anyDiff) { + printf(" *** seed change produced identical " + "population ***\n"); + ok = false; + } + } + + printf(" %s (%zu buildings, %zu households, %zu members)\n", + ok ? "passed" : "FAILURES above", town.buildings.size(), + a.households.size(), a.members.size()); + return ok; +} + +static bool runTownLocationsProbe() +{ + printf("town locations probe\n"); + bool ok = true; + + RoadGraph graph; + { + int ids[3][3]; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + ids[i][j] = graph.addNode( + Vector3(i * 100.0f, 0, j * 100.0f), + 0.0f); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) { + if (i < 2) + graph.addEdge(ids[i][j], + ids[i + 1][j]); + if (j < 2) + graph.addEdge(ids[i][j], + ids[i][j + 1]); + } + } + RoadNetworkGen::HeightSampler flat = [](float, float) { + return 0.0f; + }; + TownGen::TownParams cc; + cc.district = TownGen::District::CityCenter; + cc.seed = 7; + TownGen::TownResult town = TownGen::generateTown(graph, cc, flat); + if (town.buildings.size() != 12) { + printf(" *** setup: %zu buildings, want 12 ***\n", + town.buildings.size()); + return false; + } + + const uint32_t seed = 42; + TownLocations::LocationSet a = + TownLocations::buildLocations(town, seed); + TownLocations::LocationSet b = + TownLocations::buildLocations(town, seed); + + auto sameLocation = [](const TownLocations::NpcLocation &x, + const TownLocations::NpcLocation &y) { + return x.id == y.id && x.kind == y.kind && + x.name == y.name && x.address == y.address && + x.position == y.position && x.building == y.building; + }; + + /* Determinism: identical seed -> identical set. */ + if (a.locations.size() != b.locations.size()) { + printf(" *** determinism: size mismatch %zu vs %zu ***\n", + a.locations.size(), b.locations.size()); + ok = false; + } else { + for (size_t i = 0; i < a.locations.size(); ++i) { + if (!sameLocation(a.locations[i], b.locations[i])) { + printf(" *** determinism: location %zu " + "differs ***\n", i); + ok = false; + break; + } + } + } + + /* Counts: one Home per building, one Curb per pickup POI, one + * Parking per parking POI. */ + size_t pickups = 0, parkings = 0; + for (const TownGen::TownPoi &poi : town.pois) + if (poi.kind == TownGen::TownPoi::Parking) + ++parkings; + else + ++pickups; + std::vector homes = + a.indicesOfKind(TownLocations::LocationKind::Home); + std::vector curbs = + a.indicesOfKind(TownLocations::LocationKind::Curb); + std::vector parks = + a.indicesOfKind(TownLocations::LocationKind::Parking); + if (homes.size() != town.buildings.size()) { + printf(" *** %zu homes for %zu buildings ***\n", + homes.size(), town.buildings.size()); + ok = false; + } + if (curbs.size() != pickups || parks.size() != parkings) { + printf(" *** %zu curbs (%zu pickups), %zu parkings " + "(%zu POIs) ***\n", curbs.size(), pickups, + parks.size(), parkings); + ok = false; + } + + /* Interest ratio: 0 -> none, 1 -> one per building. */ + if (!TownLocations::buildLocations(town, seed, 0.0f) + .indicesOfKind(TownLocations::LocationKind::Interest) + .empty()) { + printf(" *** interest ratio 0 still yields interest " + "locations ***\n"); + ok = false; + } + if (TownLocations::buildLocations(town, seed, 1.0f) + .indicesOfKind(TownLocations::LocationKind::Interest) + .size() != town.buildings.size()) { + printf(" *** interest ratio 1 does not cover every " + "building ***\n"); + ok = false; + } + + /* Lookup helpers: ids are the vector index, homeOfBuilding and + * findByName resolve, names are unique. */ + std::set names; + for (size_t i = 0; i < a.locations.size(); ++i) { + const TownLocations::NpcLocation &loc = a.locations[i]; + if (loc.id != (int)i) { + printf(" *** location %zu has id %d ***\n", i, + loc.id); + ok = false; + } + if (!names.insert(loc.name).second) { + printf(" *** duplicate location name '%s' ***\n", + loc.name.c_str()); + ok = false; + } + if (a.findByName(loc.name) != &loc) { + printf(" *** findByName('%s') mismatch ***\n", + loc.name.c_str()); + ok = false; + } + } + for (size_t bi = 0; bi < town.buildings.size(); ++bi) { + int hi = a.homeOfBuilding((int)bi); + if (hi < 0 || a.locations[hi].building != (int)bi || + a.locations[hi].kind != TownLocations::LocationKind::Home) { + printf(" *** building %zu home lookup failed ***\n", + bi); + ok = false; + } + } + + /* Per-building isolation: renaming one building in a copy may + * only change that building's locations. */ + { + TownGen::TownResult town2 = town; + town2.buildings[5].address += " renamed"; + TownLocations::LocationSet c = + TownLocations::buildLocations(town2, seed); + if (c.locations.size() != a.locations.size()) { + printf(" *** isolation: location count %zu -> " + "%zu ***\n", a.locations.size(), + c.locations.size()); + ok = false; + } else { + for (size_t i = 0; i < a.locations.size(); ++i) { + bool expectSame = + a.locations[i].building != 5; + bool same = sameLocation(a.locations[i], + c.locations[i]); + if (expectSame && !same) { + printf(" *** isolation: location " + "%zu ('%s') changed ***\n", i, + a.locations[i].name.c_str()); + ok = false; + } + } + } + } + + /* A different seed may only move the interest rolls: every + * non-interest location must exist unchanged (compared by name, + * as interest insertions shift the vector). */ + { + TownLocations::LocationSet d = + TownLocations::buildLocations(town, seed + 1); + for (const TownLocations::NpcLocation &x : a.locations) { + if (x.kind == TownLocations::LocationKind::Interest) + continue; + const TownLocations::NpcLocation *y = + d.findByName(x.name); + if (!y || y->kind != x.kind || + y->address != x.address || + y->position != x.position || + y->building != x.building) { + printf(" *** seed change moved " + "non-interest location '%s' ***\n", + x.name.c_str()); + ok = false; + break; + } + } + } + + printf(" %s (%zu locations: %zu homes, %zu curbs, %zu parkings, " + "%zu interest)\n", ok ? "passed" : "FAILURES above", + a.locations.size(), homes.size(), curbs.size(), parks.size(), + a.indicesOfKind(TownLocations::LocationKind::Interest).size()); + return ok; +} + +static bool runTownScheduleProbe() +{ + printf("town schedule probe\n"); + bool ok = true; + + RoadGraph graph; + { + int ids[3][3]; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + ids[i][j] = graph.addNode( + Vector3(i * 100.0f, 0, j * 100.0f), + 0.0f); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) { + if (i < 2) + graph.addEdge(ids[i][j], + ids[i + 1][j]); + if (j < 2) + graph.addEdge(ids[i][j], + ids[i][j + 1]); + } + } + RoadNetworkGen::HeightSampler flat = [](float, float) { + return 0.0f; + }; + TownGen::TownParams cc; + cc.district = TownGen::District::CityCenter; + cc.seed = 7; + TownGen::TownResult town = TownGen::generateTown(graph, cc, flat); + if (town.buildings.size() != 12) { + printf(" *** setup: %zu buildings, want 12 ***\n", + town.buildings.size()); + return false; + } + + TownPopulation::PopulationRules popRules; + TownPopulation::Population pop = + TownPopulation::generatePopulation(town, 42, popRules); + TownLocations::LocationSet locs = + TownLocations::buildLocations(town, 42); + TownSchedule::ScheduleRules rules; + const uint32_t seed = 99; + + std::vector a = + TownSchedule::generateSchedules(pop, locs, seed, rules); + std::vector b = + TownSchedule::generateSchedules(pop, locs, seed, rules); + + auto sameEntry = [](const TownSchedule::ScheduleEntry &x, + const TownSchedule::ScheduleEntry &y) { + return x.startHour == y.startHour && x.endHour == y.endHour && + x.location == y.location && x.activity == y.activity; + }; + auto sameSchedule = [&sameEntry](const TownSchedule::NpcSchedule &x, + const TownSchedule::NpcSchedule &y) { + if (x.registryId != y.registryId || x.stableKey != y.stableKey || + x.entries.size() != y.entries.size()) + return false; + for (size_t i = 0; i < x.entries.size(); ++i) + if (!sameEntry(x.entries[i], y.entries[i])) + return false; + return true; + }; + + /* Determinism: identical seed -> identical schedules. */ + if (a.size() != pop.members.size() || b.size() != a.size()) { + printf(" *** size: %zu schedules for %zu members ***\n", + a.size(), pop.members.size()); + ok = false; + } else { + for (size_t i = 0; i < a.size(); ++i) { + if (!sameSchedule(a[i], b[i])) { + printf(" *** determinism: schedule %zu " + "differs ***\n", i); + ok = false; + break; + } + } + } + + /* Per-schedule invariants: tiling of [0, 24), sorted entries, + * existing locations, adults work (never children). */ + for (size_t i = 0; i < a.size(); ++i) { + const TownSchedule::NpcSchedule &s = a[i]; + if (s.entries.empty()) { + printf(" *** schedule %zu is empty ***\n", i); + ok = false; + continue; + } + float t = 0.0f; + for (size_t e = 0; e < s.entries.size(); ++e) { + const TownSchedule::ScheduleEntry &en = s.entries[e]; + if (std::fabs(en.startHour - t) > 1.0e-3f || + en.endHour <= en.startHour) { + printf(" *** schedule %zu entry %zu breaks " + "tiling at %.3f ***\n", i, e, t); + ok = false; + break; + } + t = en.endHour; + if (!locs.findByName(en.location)) { + printf(" *** schedule %zu entry %zu at " + "unknown location '%s' ***\n", i, e, + en.location.c_str()); + ok = false; + } + if (en.activity != "sleep" && en.activity != "home" && + en.activity != "work" && en.activity != "errand") { + printf(" *** schedule %zu entry %zu bad " + "activity '%s' ***\n", i, e, + en.activity.c_str()); + ok = false; + } + } + if (std::fabs(t - 24.0f) > 1.0e-3f) { + printf(" *** schedule %zu ends at %.3f, not 24 " + "***\n", i, t); + ok = false; + } + + /* entryAt must agree with a manual scan at sample + * times. */ + for (float sample = 0.25f; sample < 24.0f; + sample += 1.37f) { + const TownSchedule::ScheduleEntry *e = + TownSchedule::entryAt(s, sample); + if (!e || sample < e->startHour - 1.0e-3f || + sample >= e->endHour + 1.0e-3f) { + printf(" *** entryAt(%.2f) wrong on " + "schedule %zu ***\n", sample, i); + ok = false; + break; + } + } + + /* Band rules vs the bindings. */ + const TownPopulation::PopMember *pm = nullptr; + for (const TownPopulation::PopMember &m : pop.members) + if (m.registryId == s.registryId) { + pm = &m; + break; + } + if (!pm) { + printf(" *** schedule %zu registryId not in the " + "population ***\n", i); + ok = false; + continue; + } + TownLocations::NpcBindings bind = + TownLocations::computeBindings( + locs, pm->stableKey, + pop.households[pm->household].address); + bool hasWork = false; + bool locationsMatch = true; + for (const TownSchedule::ScheduleEntry &en : s.entries) { + if (en.activity == "work") { + hasWork = true; + if (en.location != bind.work) + locationsMatch = false; + } + if (en.activity == "errand" && + en.location != bind.interest) + locationsMatch = false; + if ((en.activity == "sleep" || + en.activity == "home") && + en.location != bind.home) + locationsMatch = false; + } + if (!locationsMatch) { + printf(" *** schedule %zu location/binding " + "mismatch ***\n", i); + ok = false; + } + if (pm->age == "adult" && !hasWork) { + printf(" *** adult schedule %zu has no work " + "***\n", i); + ok = false; + } + if (pm->age != "adult" && hasWork) { + printf(" *** %s schedule %zu works ***\n", + pm->age.c_str(), i); + ok = false; + } + } + + /* Per-NPC isolation: renaming one building changes its own + * members' schedules; other members change only when their + * bindings referenced the renamed building's locations (the + * rendezvous pick rebinds exactly those). */ + { + TownGen::TownResult town2 = town; + town2.buildings[5].address += " renamed"; + TownPopulation::Population pop2 = + TownPopulation::generatePopulation(town2, 42, + popRules); + TownLocations::LocationSet locs2 = + TownLocations::buildLocations(town2, 42); + std::vector c = + TownSchedule::generateSchedules(pop2, locs2, seed, + rules); + const std::string &addr5 = town.buildings[5].address; + std::set renamed; + for (const TownPopulation::PopMember &m : pop2.members) + if (pop2.households[m.household].building == 5) + renamed.insert(m.registryId); + /* Match by registryId — a household size change shifts + * the flat member array. */ + std::map byId; + for (const TownSchedule::NpcSchedule &s : c) + byId[s.registryId] = &s; + for (const TownSchedule::NpcSchedule &sa : a) { + auto it = byId.find(sa.registryId); + if (it == byId.end()) + continue; /* member vanished with the rename */ + bool ownMember = renamed.count(sa.registryId) != 0; + bool references5 = false; + for (const TownSchedule::ScheduleEntry &en : + sa.entries) + if (en.location.find(addr5) != + std::string::npos) + references5 = true; + bool expectSame = !ownMember && !references5; + bool same = sameSchedule(sa, *it->second); + if (expectSame && !same) { + printf(" *** isolation: schedule of %llu " + "changed ***\n", + (unsigned long long)sa.registryId); + ok = false; + } + if (ownMember && same) { + printf(" *** isolation: renamed member " + "schedule of %llu unchanged ***\n", + (unsigned long long)sa.registryId); + ok = false; + } + } + } + + printf(" %s (%zu schedules)\n", ok ? "passed" : "FAILURES above", + a.size()); + return ok; +} + static bool runGeneratorProbe() { printf("grid generator probe\n"); @@ -6443,6 +7215,9 @@ int main(int argc, char **argv) ok &= runStreetProbe(); ok &= runGeneratorProbe(); ok &= runTownGenProbe(); + ok &= runTownPopulationProbe(); + ok &= runTownLocationsProbe(); + ok &= runTownScheduleProbe(); ok &= runLaneTransitionTaper(); ok &= runApronSkirt(); diff --git a/src/features/editScene/ui/NpcLocationEditor.cpp b/src/features/editScene/ui/NpcLocationEditor.cpp new file mode 100644 index 0000000..2494f84 --- /dev/null +++ b/src/features/editScene/ui/NpcLocationEditor.cpp @@ -0,0 +1,51 @@ +#include "NpcLocationEditor.hpp" +#include +#include + +#include "../roadlib/TownLocations.hpp" + +bool NpcLocationEditor::renderComponent(flecs::entity entity, + NpcLocationComponent &loc) +{ + (void)entity; + bool modified = false; + + const int kindCount = (int)TownLocations::LocationKind::COUNT; + if (ImGui::BeginCombo("Kind", TownLocations::locationKindName( + (TownLocations::LocationKind) + (loc.kind >= 0 && loc.kind < kindCount ? + loc.kind : 3)))) { + for (int k = 0; k < kindCount; ++k) { + const bool sel = loc.kind == k; + if (ImGui::Selectable(TownLocations::locationKindName( + (TownLocations::LocationKind)k), + sel)) { + loc.kind = k; + modified = true; + } + if (sel) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + char nameBuf[256] = { 0 }; + strncpy(nameBuf, loc.name.c_str(), sizeof(nameBuf) - 1); + if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf))) { + loc.name = nameBuf; + modified = true; + } + + if (ImGui::InputFloat("Radius", &loc.radius, 0.1f, 1.0f)) + modified = true; + + if (ImGui::Checkbox("Enabled", &loc.enabled)) + modified = true; + + ImGui::TextWrapped( + "Authored NPC location (phase 4.7.3): a named symbolic " + "place NPCs can bind to and travel between. The entity " + "transform is the location position; the name is the " + "binding key and must be unique within the town area."); + return modified; +} diff --git a/src/features/editScene/ui/NpcLocationEditor.hpp b/src/features/editScene/ui/NpcLocationEditor.hpp new file mode 100644 index 0000000..5ec210a --- /dev/null +++ b/src/features/editScene/ui/NpcLocationEditor.hpp @@ -0,0 +1,20 @@ +#ifndef EDITSCENE_NPC_LOCATION_EDITOR_HPP +#define EDITSCENE_NPC_LOCATION_EDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/NpcLocation.hpp" + +class NpcLocationEditor : public ComponentEditor { +public: + const char *getName() const override + { + return "NpcLocation"; + } + +protected: + bool renderComponent(flecs::entity entity, + NpcLocationComponent &loc) override; +}; + +#endif // EDITSCENE_NPC_LOCATION_EDITOR_HPP diff --git a/src/features/editScene/ui/SunEditor.hpp b/src/features/editScene/ui/SunEditor.hpp index 6bef498..172bb28 100644 --- a/src/features/editScene/ui/SunEditor.hpp +++ b/src/features/editScene/ui/SunEditor.hpp @@ -26,6 +26,10 @@ public: if (ImGui::SliderFloat("Time of Day", &component.timeOfDay, 0.0f, 24.0f, "%.2f h")) { changed = true; + /* No markDirty: the GameClockSystem adopts + * externally-edited time/speed by divergence + * (phase 4.7.4) and dirty triggers a full + * light/sphere rebuild. */ } if (ImGui::SliderFloat("Time Speed", diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 20853ce..50bcbb5 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -9,6 +9,7 @@ #include "../systems/TerrainSystem.hpp" #include "../systems/RoadSystem.hpp" #include "../systems/TownGenSystem.hpp" +#include "../systems/NpcPopulationSystem.hpp" #include "../layoutlib/LayoutEval.hpp" #include "../systems/TerrainPrefabSpawnerSystem.hpp" #include "../systems/PrefabSystem.hpp" @@ -1563,6 +1564,37 @@ private: "district/seed/density; only lots whose " "centroid it owns). Replaces all " "generated towns."); + ImGui::SameLine(); + if (ImGui::Button("Generate Population")) { + NpcPopulationSystem *pop = + NpcPopulationSystem::getInstance(); + char buf[256]; + if (!pop) { + snprintf(buf, sizeof(buf), + "Population system unavailable."); + } else { + std::string err; + int npcs = pop->generateAllAreas(&err); + if (npcs == 0 && !err.empty()) + snprintf(buf, sizeof(buf), + "Population generation " + "failed: %s", err.c_str()); + else + snprintf(buf, sizeof(buf), + "Generated %d NPCs across " + "%zu area(s).", + npcs, rg.townAreas.size()); + } + s_townResult = buf; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip( + "Generate the deterministic NPC " + "population of every town area into the " + "character registry (runtime-only " + "entries; one household per building). " + "Replaces the previously generated " + "population."); ImGui::Separator(); /* Phase 4.6: rebuild one building from the current