From 19c5528f8e50e8ce08c9e5b11019ca9e0de5b9ef Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Thu, 29 Oct 2026 02:39:05 +0300 Subject: [PATCH] phase 6.8 checkpoint --- src/features/editScene/AGENTS.md | 19 +++ .../editScene/demos/demo-taxi/PLAN.md | 59 ++++++-- .../editScene/roadlib/TownSchedule.cpp | 8 +- .../editScene/roadlib/TownSchedule.hpp | 10 +- .../editScene/systems/NpcPopulationSystem.cpp | 79 +++++++++++ .../editScene/systems/NpcPopulationSystem.hpp | 21 +++ .../systems/NpcScheduleDebugOverlay.cpp | 134 ++++++++++++++++++ .../tests/road_geometry_overlap_test.cpp | 38 ++++- 8 files changed, 352 insertions(+), 16 deletions(-) diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 732aff8..91ad6af 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -847,6 +847,25 @@ WeekScheduleRules, stats = {}, scoreHook = {}, warnings* )`: only) for the stat-driven choices (6.8.10). - Fallbacks warn once per distinct line per build. +**Runtime store (6.8.4a — runtime motion/traffic NOT flipped yet):** +`NpcPopulationSystem::generateAreaPopulation` runs the weekly build +alongside the daily one (seed `effSeed ^ kWeekScheduleSeedXor +0x27d4eb2f`, default rules) and stores it on the area — +`weekScheduleOf(registryId)` returns the `WeekSchedule`, +`weekUsageOf(areaIndex)` the build's pick tally. The scoring flows +through an adapter onto `systems/ScheduleScoring::score`, so +`ecs.schedule.set_score_hook` steers weekly picks too; stats +snapshots stay empty until 6.8.10. **Migration:** the +`npcWork`/`npcInterest` registry columns rebind to the weekly +resolves (the first week's sticky `work:
` and the first +`leisure:
` pick; the old Interest placeholders remain only +for tagless areas). Pure probes stay bit-identical; the runtime +swap (motion `entryAt`/helpers + traffic legs) is 6.8.4b. +`NpcScheduleDebugOverlay` (**F7**) gained a Week pane per selected +NPC: Mon-Sun day selector (initial = the clock's day-of-week), a +"today" line, From/To/Activity/**Query -> Location** rows and the +usage tally (top-4). + Runtime is NOT wired yet (6.8.4): motion/traffic keep the daily `generateSchedules()` product; the week verifier `TownSchedule::weekEntryAt(week, day, hour)` exists for it. diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 645782f..3a1ba5b 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -4538,8 +4538,9 @@ feet sliding. ### Phase 6.8 — DSL macros, symbolic schedules and POIs **Status: IN PROGRESS — 6.8.9 stats foundation, 6.8.1 DSL macros, -6.8.2 POI/tag model (a+b) and 6.8.3 weekly schedule generator DONE -(2026-10-29); 6.8.4 runtime integration + migration next.** Rewritten +6.8.2 POI/tag model (a+b), 6.8.3 weekly schedule generator and +6.8.4a weekly store/F7/migration DONE (2026-10-29); 6.8.4b +runtime day-tiling flip + week-rollover probe next.** Rewritten 2026-10-28: the original bullet list mixed four independent features and contradicted the clock-pure motion core; it is now split into workstreams with explicit design decisions, task-sized pieces and @@ -4858,27 +4859,63 @@ symbolic schedule generator) next.** **Task 6.8.4 — Runtime integration + migration (workstream B).** +**Status: 6.8.4a DONE (2026-10-29) — the weekly store, the registry +rebind migration and the F7 week pane are in (runtime motion/traffic +NOT flipped yet); the day-tiling flip + rollover probe are 6.8.4b.** + - `NpcPopulationSystem` stores weekly schedules per area and the resolved bindings; `NpcMotionSystem` reads the day-of-week tiling through the same `entryAt()` path (clock-pure, no latch); `NpcTrafficSystem` keeps deriving drive legs from the same entries, so walking and driving never disagree. -- Indoor scheduling follows the entry contract (6.8.12): interior - destinations approach via the building's entry POI; the symbolic - tier completes interior entries entity-less there, the live tier - despawns/respawns at the door — no behavior change vs the current - `isIndoorKind` rules. +- **Implemented (6.8.4a):** every area population build + (`generateAreaPopulation`) now also runs the pure + `generateWeekSchedules` (seed `effSeed ^ kWeekScheduleSeedXor + 0x27d4eb2f`, default rules) and stores it on the area: + `weekSchedules` (registryId -> WeekSchedule) + `weekUsage` (the + build's pick tally); accessors `weekScheduleOf(registryId)` / + `weekUsageOf(areaIndex)`. The scoring flows through an adapter + onto `systems/ScheduleScoring::score` — the Lua override + (`ecs.schedule.set_score_hook`) steers weekly picks (exact hook + contract: only the candidate + snapshot). Stats snapshots stay + EMPTY this phase (6.8.10 samples them live at week start). + **Migration:** the `npcWork`/`npcInterest` registry columns + rebind to the weekly resolves — `npcWork` is now the first + week's sticky job location (a real `work:
` when the + area has Work locations, else the old Interest fallback) and + `npcInterest` the week's first leisure pick (`leisure:
`, + else curb/interest fallbacks) — the Interest placeholders are + gone for tagged towns; the taxi "Send by taxi to interest/work" + buttons and the F7 columns read the rebound values deterministically. +- F7 overlay: **Week pane** (per selected NPC): Mon-Sun day + selector (initial selection = the clock's day-of-week), a + "today: (clock day N)" line, per-entry rows + From/To/Activity/**Query -> Location** (empty queries show the + raw location) and the build's usage tally (top-4 in the pane; + the full list lives in the location inspection). +- Indoor scheduling follows the entry contract (6.8.12): unchanged + this phase — the weekly entries resolve to approach-anchored + LocationSet names like the daily ones do. - Migration: existing `npcWork` Interest placeholders rebind to the new Work locations once; schedules still recompute on load at this stage (6.8.10 turns the weekly store into saved state). -- F7 overlay: week view per NPC (day selector), resolved location - names next to their tag queries, per-location usage counts. +- **Remaining (6.8.4b):** flip `NpcMotionSystem`'s active-entry + evaluation and the shared `nextLocationChangeOf`/ + `walkTravelHours` helpers (and through them + NpcTrafficSystem's drive-leg derivation + the claim replay) onto + the weekly day-tiling (day-of-week = `(absoluteDay % 7)`, the + daily schedule as the fallback), the week-rollover determinism + probe, and the full `--test-*` battery re-run. - Done when: motion/traffic behave as before on a default town, weekly variety is visible in the overlay, and scrubbing the clock across a week boundary reproduces positions bit-exactly. - Verification: `--test-motion`, `--test-determinism`, - `--test-traffic-schedule`, `--test-taxi`, `--test-parking` all - pass; new week-rollover determinism probe. + `--test-traffic-schedule`, `--test-taxi`, `--test-parking`, + `--test-detail`, `--test-population-switch` all PASS unchanged + (the runtime is untouched so far — weekly variety is visible in + the overlay; the swap comes with 6.8.4b); the week probe in + road_geometry_overlap_test grew the usage-tally identity check + (picks == members-with-a-job + leisure picks). **Task 6.8.5 — Schedule realism rules (workstream B, content).** diff --git a/src/features/editScene/roadlib/TownSchedule.cpp b/src/features/editScene/roadlib/TownSchedule.cpp index e4ed24e..c57efcb 100644 --- a/src/features/editScene/roadlib/TownSchedule.cpp +++ b/src/features/editScene/roadlib/TownSchedule.cpp @@ -258,7 +258,8 @@ std::vector generateWeekSchedules( const TownLocations::LocationSet &locations, uint32_t seed, const WeekScheduleRules &rules, const std::unordered_map &stats, - const ScoreHook &hook, std::vector *warnings) + const ScoreHook &hook, std::vector *warnings, + std::map *usageOut) { const WeekIndex ix = buildWeekIndex(locations); @@ -577,6 +578,11 @@ std::vector generateWeekSchedules( out.push_back(std::move(ws)); } + if (usageOut) { + usageOut->clear(); + for (const auto &kv : usage.counts) + (*usageOut)[kv.first] = kv.second; + } return out; } diff --git a/src/features/editScene/roadlib/TownSchedule.hpp b/src/features/editScene/roadlib/TownSchedule.hpp index 21e3ccb..74dc30a 100644 --- a/src/features/editScene/roadlib/TownSchedule.hpp +++ b/src/features/editScene/roadlib/TownSchedule.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -175,7 +176,11 @@ struct WeekScheduleRules { * failure is never fatal — every day tiles. * * @p stats maps a registry id to its week-start snapshot; only the - * hook reads it (the default score ignores stats).*/ + * hook reads it (the default score ignores stats). + * + * @p usageOut, when set, receives the build's location-name -> + * pick-count tally (decision 4 — the F7 week pane reports it; an + * output of the build, never game state). */ std::vector generateWeekSchedules( const TownPopulation::Population &pop, const TownLocations::LocationSet &locations, uint32_t seed, @@ -183,7 +188,8 @@ std::vector generateWeekSchedules( const std::unordered_map &stats = std::unordered_map(), const ScoreHook &hook = ScoreHook(), - std::vector *warnings = nullptr); + std::vector *warnings = nullptr, + std::map *usageOut = nullptr); /** The active week entry at a day/hour; nullptr only for an empty * week (no tiling). */ diff --git a/src/features/editScene/systems/NpcPopulationSystem.cpp b/src/features/editScene/systems/NpcPopulationSystem.cpp index 89b24c5..a7e9930 100644 --- a/src/features/editScene/systems/NpcPopulationSystem.cpp +++ b/src/features/editScene/systems/NpcPopulationSystem.cpp @@ -6,6 +6,7 @@ #include "CharacterSlotSystem.hpp" #include "RenderOriginSystem.hpp" #include "RoadSystem.hpp" +#include "ScheduleScoring.hpp" #include "TerrainSystem.hpp" #include "TownGenSystem.hpp" #include "../GameMode.hpp" @@ -73,6 +74,8 @@ static const uint32_t kLocSeedXor = 0x85ebca6bu; /* Schedule draws get their own decorrelation (phase 4.7.4). */ static const uint32_t kScheduleSeedXor = 0xc2b2ae35u; +/* 6.8.4a: the baseline week's seed discipline mirrors the others. */ +static const uint32_t kWeekScheduleSeedXor = 0x27d4eb2fu; NpcPopulationSystem *NpcPopulationSystem::ms_instance = nullptr; @@ -278,6 +281,62 @@ int NpcPopulationSystem::generateAreaPopulation(int areaIndex, ap.schedules = TownSchedule::generateSchedules( pop, ap.locations, effSeed ^ kScheduleSeedXor, TownSchedule::ScheduleRules()); + /* Phase 6.8.4a: the pure baseline WEEK builds alongside. The + * stats snapshots stay empty until 6.8.10 samples them; the + * scoring flows through systems/ScheduleScoring::score so the + * Lua hook (ecs.schedule.set_score_hook) steers weekly picks. + * Runtime motion/traffic keep the daily schedules this slice; + * the F7 overlay previews the week. */ + { + TownSchedule::ScoreHook hook = + [](const TownSchedule::WeekCandidate &c, + const TownSchedule::StatsSnapshot &snap) { + ScheduleScoring::Candidate sc; + sc.name = c.name; + sc.tags = c.tags; + sc.distanceMeters = c.distanceMeters; + sc.usage = c.usage; + sc.effects = c.effects; + return ScheduleScoring::score(sc, snap); + }; + std::vector weekWarns; + std::map usage; + std::vector weeks = + TownSchedule::generateWeekSchedules( + pop, ap.locations, effSeed ^ kWeekScheduleSeedXor, + TownSchedule::WeekScheduleRules(), {}, hook, + &weekWarns, &usage); + for (const TownSchedule::WeekSchedule &w : weeks) + ap.weekSchedules[w.registryId] = w; + ap.weekUsage = usage; + for (const std::string &w : weekWarns) + Ogre::LogManager::getSingleton().logMessage( + "NpcPopulationSystem[" + area.name + + "]: " + w); + /* Migration (6.8.4a): the npcWork/npcInterest registry + * columns rebind ONCE to the weekly resolves (real + * Work locations + the week's first leisure pick) + * instead of the Interest placeholders. */ + for (const TownSchedule::WeekSchedule &w : weeks) { + CharacterRegistry::CharacterRecord *rec = + reg.findCharacter(w.registryId); + if (!rec) + continue; + std::string newWork, newInterest; + for (const TownSchedule::WeekEntry &e : w.entries) { + if (e.activity == "work" && newWork.empty()) + newWork = e.location; + if (e.activity == "leisure" && + newInterest.empty()) + newInterest = e.location; + } + if (!newWork.empty()) + rec->stringColumns["npcWork"] = newWork; + if (!newInterest.empty()) + rec->stringColumns["npcInterest"] = + newInterest; + } + } m_areas[areaIndex] = ap; reg.setAutoSaveSuspended(false); @@ -598,3 +657,23 @@ NpcPopulationSystem::scheduleOf(uint64_t registryId) const return &s; return nullptr; } + +const TownSchedule::WeekSchedule * +NpcPopulationSystem::weekScheduleOf(uint64_t registryId) const +{ + for (const auto &kv : m_areas) { + std::unordered_map::const_iterator + it = kv.second.weekSchedules.find(registryId); + if (it != kv.second.weekSchedules.end()) + return &it->second; + } + return nullptr; +} + +const std::map * +NpcPopulationSystem::weekUsageOf(int areaIndex) const +{ + auto it = m_areas.find(areaIndex); + return it == m_areas.end() ? nullptr : &it->second.weekUsage; +} diff --git a/src/features/editScene/systems/NpcPopulationSystem.hpp b/src/features/editScene/systems/NpcPopulationSystem.hpp index 0a66d33..7c3050a 100644 --- a/src/features/editScene/systems/NpcPopulationSystem.hpp +++ b/src/features/editScene/systems/NpcPopulationSystem.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -97,6 +98,17 @@ public: * GameClockSystem::getTimeOfDayHours() for the current goal. */ const TownSchedule::NpcSchedule *scheduleOf(uint64_t registryId) const; + /** The generated baseline WEEK of a population NPC (6.8.4a), + * nullptr when unknown or the area carries no weekly build. + * Runtime motion/traffic still consume the daily schedule; + * the F7 overlay shows the week. */ + const TownSchedule::WeekSchedule *weekScheduleOf( + uint64_t registryId) const; + /** The weekly build's location usage tally (decision 4), null + * when the area has no weekly build. Read-only — an output + * of the generation, not game state. */ + const std::map *weekUsageOf(int areaIndex) const; + /** * Per-frame update. In game mode the population is generated * automatically once the active terrain's road graph carries @@ -120,6 +132,15 @@ private: PoiDatabase::Set poiSet; TownLocations::LocationSet locations; std::vector schedules; + /* The baseline WEEK per NPC (6.8.4a): the pure + * generateWeekSchedules build. Keyed by registry id so + * lookups per area hash. Runtime motion/traffic keep + * the daily schedules for now (6.8.4b flips them). */ + std::unordered_map + weekSchedules; + /* The weekly build's location -> pick tally (decision + * 4; F7 week pane). */ + std::map weekUsage; /* Ids merged from authored components (markers/POIs) so * the periodic re-merge can replace exactly those — * runtime-added POIs appear within a second, no full diff --git a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp index 50f8f10..6edd8da 100644 --- a/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp +++ b/src/features/editScene/systems/NpcScheduleDebugOverlay.cpp @@ -1,5 +1,7 @@ #include "NpcScheduleDebugOverlay.hpp" +#include +#include #include #include @@ -397,6 +399,138 @@ void NpcScheduleDebugOverlay::render() } } ImGui::Separator(); + /* Phase 6.8.4a: the baseline WEEK view (the pure + * generateWeekSchedules build; runtime motion + * still consumes the daily schedule until 6.8.4b + * flips it). */ + { + const TownSchedule::WeekSchedule *wk = + pop->weekScheduleOf(selId); + if (wk && ImGui::CollapsingHeader( + "Week", + ImGuiTreeNodeFlags_DefaultOpen)) { + static int weekDay = -1; + if (weekDay < 0 && clock) + weekDay = + (int)std::floor( + clock->getAbsoluteHours() / + 24.0) % + 7; + static const char *dayNames[7] = { + "Mon", "Tue", "Wed", "Thu", + "Fri", "Sat", "Sun" + }; + for (int d = 0; d < 7; ++d) { + if (d) + ImGui::SameLine(); + if (weekDay == d) + ImGui::PushStyleColor( + ImGuiCol_Button, + IM_COL32(60, 90, + 60, 255)); + if (ImGui::SmallButton( + dayNames[d])) + weekDay = d; + if (weekDay == d) + ImGui::PopStyleColor(); + } + ImGui::Separator(); + if (clock) + ImGui::TextDisabled( + "today: %s (clock day %d)", + dayNames[(int)std::floor( + clock->getAbsoluteHours() / + 24.0) % + 7], + (int)std::floor( + clock->getAbsoluteHours() / + 24.0)); + if (ImGui::BeginTable( + "weeksched", 4, + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn( + "From"); + ImGui::TableSetupColumn( + "To"); + ImGui::TableSetupColumn( + "Activity"); + ImGui::TableSetupColumn( + "Query -> Location"); + ImGui::TableHeadersRow(); + for (const TownSchedule:: + WeekEntry &e : + wk->entries) { + if (e.day != weekDay) + continue; + ImGui::TableNextRow(); + 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(); + if (e.tagQuery.empty()) + ImGui::TextUnformatted( + e.location + .c_str()); + else + ImGui::TextDisabled( + "%s -> %s", + e.tagQuery + .c_str(), + e.location + .c_str()); + } + ImGui::EndTable(); + } + /* Decision 4: the build's usage + * tally. */ + int areaIdxW = + pop->areaIndexOf(selId); + const std::map + *usage = pop->weekUsageOf( + areaIdxW); + if (usage && !usage->empty()) { + ImGui::TextDisabled( + "location usage: %zu distinct, top:", + usage->size()); + std::vector< + std::pair > + top(usage->begin(), + usage->end()); + std::sort( + top.begin(), + top.end(), + [](const std::pair + + &a, + const std::pair + + &b) { + return a + .second > + b.second; + }); + for (size_t k = 0; + k < top.size() && k < 4; + ++k) + ImGui::TextDisabled( + " %s: %d", + top[k] + .first + .c_str(), + top[k].second); + } + } + } + ImGui::Separator(); if (ImGui::BeginTable("schedule", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index 6c9c542..ffbdac7 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -7662,14 +7662,18 @@ static bool runTownWeekScheduleProbe() } /* Usage spread report (the plan asks the probe to print max/ - * mean location usage). */ + * mean location usage). The tally equals the pick-bearing + * entries: one per adult's job plus one per errand day. */ { std::map picks; - int total = 0; + int total = 0, pickEntries = 0; for (const TownSchedule::WeekSchedule &ws : week) for (const TownSchedule::WeekEntry &e : ws.entries) { ++picks[e.location]; ++total; + if (e.activity == "work" || + e.activity == "leisure") + ++pickEntries; } if (!picks.empty()) { size_t maxU = 0; @@ -7682,6 +7686,36 @@ static bool runTownWeekScheduleProbe() (double)maxU, (double)total / (double)picks.size()); } + /* The reported tally counts PICKS (decision 4): one per + * member's sticky job (which then spans 5 work entries) + * plus one per day's leisure pick. */ + std::map usageOut; + TownSchedule::generateWeekSchedules(pop, set, 42, rules, + {}, {}, nullptr, + &usageOut); + int pickedTotal = 0; + for (const auto &kv : usageOut) + pickedTotal += kv.second; + int membersWithWork = 0; + int leisureEntries = 0; + for (const TownSchedule::WeekSchedule &ws : week) { + bool hasWork = false; + for (const TownSchedule::WeekEntry &e : + ws.entries) { + if (e.activity == "work") + hasWork = true; + if (e.activity == "leisure") + ++leisureEntries; + } + if (hasWork) + ++membersWithWork; + } + if (pickedTotal != membersWithWork + leisureEntries) { + printf(" *** usage tally %d != picks %d + %d " + "***\n", pickedTotal, membersWithWork, + leisureEntries); + ok = false; + } } /* No-candidate fallbacks (decision 7): a tagless town with only