diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 46647e1..732aff8 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -728,7 +728,19 @@ RNG draws) and `minHousehold`/`maxHousehold` (0 = inherit) merge onto `TownBuilding` in TownGen and are honored by `generatePopulation` without disturbing other lots' draws; both ride all lot-params serialization (scene JSON, region files) and are editable in the Lot Params tree nodes -and the area's "Population Rules" node. `PopulationRules` defaults +and the area's "Population Rules" node. **Per-lot building tags (6.8.2b):** +`RoadLotParams::tags` (comma-separated "Tags" field in the Lot Params +tree nodes, same widget pattern as the town area's Preferred Types) +appends extra tags to the building AFTER its picked layout's `:tags` +(order-stable, duplicates dropped — layout tags first), which is how a +residential lot becomes a Work + Leisure zoned building ("shop" → +salesman job tag; see the TownLocations section). Tags ride all +lot-params serialization (scene JSON, region stores — sparse, absent +entries keep old scenes loading) and `isDefault()` treats them as +inherit. The resulting locations/tags are visible per building in the +**Tools -> POI Database** panel (ids are `:
`, search + +kind/tag substring filter, straight per-frame filter iteration — no +cached panel state to rebuild). `PopulationRules` defaults `minOutfitLevel`/`maxOutfitLevel` to 2 (townsfolk are clothed in public; widen per area via "Population Rules"). @@ -786,7 +798,7 @@ 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) +### TownSchedule + GameClockSystem (demo-taxi phase 4.7.4 + 6.8.3) `roadlib/TownSchedule::generateSchedules()` (pure — probe `runTownScheduleProbe`) builds one schedule per NPC tiling [0, 24) with @@ -801,6 +813,44 @@ vanishing rebinds only the NPCs that referenced it. `NpcPopulationSystem` stores the schedules per area — `scheduleOf(registryId)` + `TownSchedule::entryAt(schedule, timeOfDay)` give the current goal. +#### Weekly baseline (phase 6.8.3 — pure, not runtime-wired yet) + +`roadlib/TownSchedule::generateWeekSchedules()` (pure — probe +`runTownWeekScheduleProbe`) tiles ALL SEVEN days per NPC with +whole-hour entries: workday / weekend templates +(`WeekScheduleRules::weekendDays`, default {5, 6}), sleep-at-home +bookends on every day, an afternoon outing window for adults on +weekends and childOut/elderOut shapes for children/elders. Entries +(`WeekEntry`: day / hours / location / activity / tagQuery) carry the +CONCEPTUAL resolution (`tagQuery` "job:" / "leisure:", "" +for home/sleep) next to the resolved cache (`location`), per plan +decision 3. The function is `(pop, locations, seed, +WeekScheduleRules, stats = {}, scoreHook = {}, warnings* )`: + +- Members are processed in REGISTRY-ID order (decision 4; the + population vector's order is irrelevant) with the usual per-NPC + stream `seed ^ fnv1a(stableKey)`. +- The JOB resolves once per member (decision 5 — weekly-sticky): + the job tag is a hash pick among the sorted distinct job tags of + the Work locations (a Work location's job tag is its LAST tag); + pool = tag's Work locations → all Work → the npcWork binding; + leisure resolves per errand day: a hash pick among the sorted + Leisure location tags; pool → all Leisure → curb → Interest → + home (decision 7). One pick adds ONE usage unit. +- Candidates are scored by the shared pure kernel + `TownScheduleScore::defaultScore` (roadlib/TownScheduleScore.{hpp, + cpp}, std-only — no Ogre): -(usage + distance*w2) + name-hash + jitter*w3 — `systems/ScheduleScoring::defaultScore` DELEGATES to + it (one formula; the OGRE-free schedule_scoring_test target + compiles TownScheduleScore.cpp too) — with the optional + read-only hook (`ScoreHook`: candidate + week-start snapshot + only) for the stat-driven choices (6.8.10). +- Fallbacks warn once per distinct line per build. + +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. + 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 diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 05742d9..924d3b3 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -849,6 +849,7 @@ add_test(NAME globalStateTest add_executable(schedule_scoring_test tests/schedule_scoring_test.cpp systems/ScheduleScoring.cpp + roadlib/TownScheduleScore.cpp lua/LuaScheduleScoringApi.cpp ) @@ -985,6 +986,8 @@ add_library(RoadGeometryLib STATIC roadlib/TownLocations.hpp roadlib/TownSchedule.cpp roadlib/TownSchedule.hpp + roadlib/TownScheduleScore.cpp + roadlib/TownScheduleScore.hpp roadlib/PoiDatabase.cpp roadlib/PoiDatabase.hpp ) diff --git a/src/features/editScene/components/RoadGraph.hpp b/src/features/editScene/components/RoadGraph.hpp index 0740fcf..139f461 100644 --- a/src/features/editScene/components/RoadGraph.hpp +++ b/src/features/editScene/components/RoadGraph.hpp @@ -503,6 +503,15 @@ struct RoadLotParams { int minHousehold = 0; int maxHousehold = 0; + /** + * Extra building tags (6.8.2b): available author intent beyond + * the picked layout's own `:tags` — e.g. "shop" zones a + * residential lot into a Work (salesman) + Leisure (customers) + * location pair. Empty means inherit (no extras); appended to + * the layout tags in order, duplicates dropped. + */ + std::vector tags; + /** true when every field sits at its inherit sentinel. */ bool isDefault() const { @@ -514,7 +523,8 @@ struct RoadLotParams { roofType == RoadLotRoof::Inherit && layout.empty() && maxFrontage == 0.0f && inhabited == RoadLotInhabited::Inherit && - minHousehold == 0 && maxHousehold == 0; + minHousehold == 0 && maxHousehold == 0 && + tags.empty(); } }; diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 4d6f395..645782f 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -4537,8 +4537,9 @@ feet sliding. ### Phase 6.8 — DSL macros, symbolic schedules and POIs -**Status: IN PROGRESS — 6.8.9 stats foundation and 6.8.1 DSL macros -DONE (2026-10-28); POI model and schedules next.** Rewritten +**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 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 @@ -4711,8 +4712,8 @@ vocabulary but must not be implemented as one blob: **Task 6.8.2 — POI and job tag model + index (workstream B, data).** -**Status: 6.8.2a pure core DONE (2026-10-29); 6.8.2b per-lot/area -tag overrides + editor UI next.** +**Status: 6.8.2a + 6.8.2b DONE (2026-10-29); Task 6.8.3 (weekly +symbolic schedule generator) next.** - Extend `TownLocations` ON TOP of the unified POI database (6.8.11): Work and Leisure stop being reserved — locations gain @@ -4751,9 +4752,24 @@ tag overrides + editor UI next.** interest; Interest-ratio checks moved to a Village town) and the new `runTownTagIndexProbe` (dual audience, job tags, index determinism + building buckets, bindings Work-fallback-Interest, - tagless-town compat, feed/projection tag round trip). 6.8.2b - remains: per-lot/area tag overrides and the editor's tag - display/editing on buildings/lots. + tagless-town compat, feed/projection tag round trip). +- **Implemented (6.8.2b):** `RoadLotParams::tags` (comma-separated + "Tags" field in the Lot Params tree nodes, same widget pattern as + the town area's Preferred Types) appends extra tags to the + building AFTER its picked layout's `:tags` (order-stable, + duplicates dropped — layout tags first), which is how a + residential lot becomes a Work + Leisure zoned building ("shop" → + the salesman job tag). Tags ride all lot-params serialization + (scene JSON `tags` array + the region stores' lot params — sparse, + absent entries keep old scenes/regions loading) and `isDefault()` + treats them as inherit. Per-building tag display lives in + **Tools -> POI Database** (ids are `:
`, search + + kind/tag substring filter, straight per-frame filter iteration — + no cached panel state to rebuild). Probes: the lot-params tags + block (single-edge left "shop" override zoning its building + + salesman job tag, and the tag-order/dedup case). Remaining + (optional 6.8.2c): a data-driven per-area tag → job-tag mapping + override if the schedule generator needs richer job vocabulary. - A commercial building yields BOTH kinds: a shop is a Work location for its employees AND a Leisure location for its customers (same @@ -4777,11 +4793,49 @@ tag overrides + editor UI next.** **Task 6.8.3 — Weekly symbolic schedule generator (workstream B, pure).** +**Status: DONE (2026-10-29) — the pure baseline week is in +(generateWeekSchedules + probes); runtime integration is 6.8.4.** + - New `TownSchedule::generateWeekSchedules()`: 7 daily tilings per NPC (workday/weekend templates), whole-hour entries, home at both ends of every day unless a scripted override owns the NPC. The signature carries a stats-snapshot parameter from the start (defaults reproduce current behavior; 6.8.10 makes it live). +- **Implemented:** `roadlib/TownSchedule.hpp` adds `WeekEntry` + (day/startHour/endHour/location/activity/tagQuery — the tag query + is the conceptual resolution per plan decision 3, `location` is + the resolved cache), `WeekSchedule` (per member: registryId, + stableKey, weekIndex, entries sorted (day, hour)), `WeekScheduleRules` + (the existing `ScheduleRules` numerics + `weekendDays {5, 6}`), + `StatsSnapshot` / `WeekCandidate` / `WeekWeights` / `ScoreHook` + and `weekEntryAt(week, day, hour)`. + `generateWeekSchedules(pop, locations, seed, rules, stats = {}, + hook = {}, warnings = nullptr)` builds every member in + REGISTRY-ID ORDER (decision 4; the members vector's own order is + irrelevant — reversed vectors verified): per member the fixed + stream (`seed ^ fnv1a(stableKey)`) draws workday wake, weekend + wake, (adults) work start, errand length, then one errand roll + per day (day 0..6). The JOB resolves once per member + (decision 5 — weekly-sticky): the job tag is a deterministic + `fnv1a(stableKey) ^ fnv1a("job-tag")` pick among the SORTED + distinct job tags of the area's Work locations (a Work location's + job tag is its LAST tag, 6.8.2a); the pool is the tag's Work + locations (empty → all Work, else the npcWork binding fallback; + decision 7), scored by the hook or the pure default straight + through the shared kernel; one pick adds ONE usage unit (NPC + count, not entries). LEISURE resolves per errand day: a + `fnv1a("leisure-tag")` pick among the sorted Leisure location + tags, pool → all Leisure → (decision 7) curb → Interest → home, + scored the same way. Entries are quantized to whole hours + (decisions 2) and every day tiles [0,24) with sleep-at-home + bookends (adult weekend = an afternoon outing window; children/ + elders keep the childOut/elderOut shape on every day). + Warnings (decision 7): one line per DISTINCT fallback per build. + **The scoring kernel lives in `roadlib/TownScheduleScore.{hpp,cpp}` + (std-only, no Ogre) and systems/ScheduleScoring.cpp's + `defaultScore` DELEGATES to it (one formula; the OGRE-free + `schedule_scoring_test` target now compiles + roadlib/TownScheduleScore.cpp too — exact-value tests unchanged).** - Resolution per (1)-(5): job locations picked by job tags, leisure by leisure tags, scored by usage + home distance + hash jitter; per-NPC streams stay isolated so one NPC's edit cannot shift @@ -4790,9 +4844,17 @@ tag overrides + editor UI next.** home, re-running the build is bit-identical, week rollover re-resolves only what the rules allow, and usage actually spreads (report max/mean location usage in the probe). -- Verification: pure generator probes (hour alignment, home - bookends, determinism, tag stickiness, usage-spread and distance - metrics, no-candidate fallback) in `road_geometry_overlap_test`. +- Verification: `runTownWeekScheduleProbe` in + road_geometry_overlap_test — hour alignment + tiling + home + bookends over 7 days x 4 adults, workdays-only work (5/0), + weekly-sticky job + "job:"/"leisure:" tag queries, determinism + (re-run identical; reversed member order), stats-default no-op + (an unused snapshot changes nothing), the distance-hook contract + (per-household nearest shop; 2 distinct work locations), usage + spread (a single shop cannot absorb the pool at equal distances; + the probe prints max/mean location usage) and the decision-7 + fallbacks (work → Interest binding, leisure → curb, one warning + line, days keep tiling). **Task 6.8.4 — Runtime integration + migration (workstream B).** diff --git a/src/features/editScene/roadlib/TownGen.cpp b/src/features/editScene/roadlib/TownGen.cpp index ff10d59..3059e12 100644 --- a/src/features/editScene/roadlib/TownGen.cpp +++ b/src/features/editScene/roadlib/TownGen.cpp @@ -827,6 +827,20 @@ TownResult generateTown(const RoadGraph &graph, const TownParams ¶ms, b.layout = layout; b.layoutName = usedLayoutName; b.tags = usedLayoutTags; + /* 6.8.2b: the side's lot params may append extra + * building tags (lot-author zoning intent, e.g. + * "shop" on a residential layout) — order-stable + * (layout tags first, lot tags appended) and + * duplicate-free, so editing one lot's tags + * never shifted another's building. */ + if (lotParams && !lotParams->tags.empty()) { + for (const std::string &t : + lotParams->tags) + if (std::find(b.tags.begin(), + b.tags.end(), + t) == b.tags.end()) + b.tags.push_back(t); + } b.floorY = floorY; b.minGroundY = minGroundY; b.plan = std::move(plan); diff --git a/src/features/editScene/roadlib/TownSchedule.cpp b/src/features/editScene/roadlib/TownSchedule.cpp index fe200c7..e4ed24e 100644 --- a/src/features/editScene/roadlib/TownSchedule.cpp +++ b/src/features/editScene/roadlib/TownSchedule.cpp @@ -131,4 +131,466 @@ const ScheduleEntry *entryAt(const NpcSchedule &schedule, return &schedule.entries.back(); } +/* ===== 6.8.3: the pure baseline week ===== */ + +namespace { + +/** The static per-area view of the location set for one build: the + * work/leisure/interest/curb pools and their tag indexes. The JOB + * tag of a Work location is its LAST tag (buildLocations appends + * jobTagForBuilding last, 6.8.2a); leisure locations carry their + * building tags. */ +struct WeekIndex { + std::vector works; + std::vector leisures; + std::vector interest; + std::vector curbs; + /* Tag -> candidate location indices (location order, + * deterministic). */ + std::unordered_map > jobTags; + std::unordered_map > leisureTags; +}; + +WeekIndex buildWeekIndex(const TownLocations::LocationSet &locs) +{ + WeekIndex ix; + ix.works = locs.indicesOfKind(TownLocations::LocationKind::Work); + ix.leisures = + locs.indicesOfKind(TownLocations::LocationKind::Leisure); + ix.interest = + locs.indicesOfKind(TownLocations::LocationKind::Interest); + ix.curbs = locs.indicesOfKind(TownLocations::LocationKind::Curb); + for (size_t i = 0; i < ix.works.size(); ++i) { + const TownLocations::NpcLocation &loc = + locs.locations[ix.works[i]]; + if (!loc.tags.empty()) + ix.jobTags[loc.tags.back()].push_back(ix.works[i]); + } + for (size_t i = 0; i < ix.leisures.size(); ++i) { + const TownLocations::NpcLocation &loc = + locs.locations[ix.leisures[i]]; + for (const std::string &tag : loc.tags) + ix.leisureTags[tag].push_back(ix.leisures[i]); + } + return ix; +} + +/** Score one candidate: the hook wins, else the pure default + * (usage + straight-line home distance + name-hash jitter). */ +double scoreCandidate(const ScoreHook &hook, + const TownLocations::LocationSet &locs, int idx, + const TownLocations::NpcLocation *home, int usage, + const StatsSnapshot &snapshot) +{ + const TownLocations::NpcLocation &loc = locs.locations[idx]; + WeekCandidate c; + c.name = loc.name; + c.tags = loc.tags; + if (home) { + float dx = loc.position.x - home->position.x; + float dz = loc.position.z - home->position.z; + c.distanceMeters = std::sqrt(dx * dx + dz * dz); + } + c.usage = usage; + if (hook) + return hook(c, snapshot); + return TownScheduleScore::defaultScore(c, snapshot); +} + +/** Deterministic name-rendezvous pick among fallback indices (a + * candidate appearing or vanishing rebinds only the NPCs that + * referenced it — the pickLocationName discipline). */ +int rendezvousPick(const TownLocations::LocationSet &locs, + const std::vector &candidates, + const std::string &stableKey, const char *purpose) +{ + if (candidates.empty()) + return -1; + 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 best; +} + +/** The usage tally of THIS build (decision 4: an output of the + * build, never game state); the per-NPC pass reads it in + * registry-id order. */ +struct UsageMap { + std::unordered_map counts; + int get(const std::string &name) const + { + std::unordered_map::const_iterator it = + counts.find(name); + return it == counts.end() ? 0 : it->second; + } + void add(const std::string &name) + { + ++counts[name]; + } +}; + +/** Sorted distinct keys of a tag map (the deterministic hash-pick + * domain; unsorted unordered_map order is not stable across + * builds). */ +std::vector sortedTagKeys( + const std::unordered_map > &tags) +{ + std::vector out; + out.reserve(tags.size()); + for (const auto &kv : tags) + out.push_back(kv.first); + std::sort(out.begin(), out.end()); + return out; +} + +} // namespace + +std::vector generateWeekSchedules( + const TownPopulation::Population &pop, + const TownLocations::LocationSet &locations, uint32_t seed, + const WeekScheduleRules &rules, + const std::unordered_map &stats, + const ScoreHook &hook, std::vector *warnings) +{ + const WeekIndex ix = buildWeekIndex(locations); + + /* Decision 7: one warning per DISTINCT fallback per build. */ + auto warn = [warnings](const std::string &msg) { + if (!warnings) + return; + for (const std::string &w : *warnings) + if (w == msg) + return; + warnings->push_back(msg); + }; + + const int sleepHour = (int)std::round(rules.daily.sleepHour); + /* The build's usage tally (decision 4). */ + UsageMap usage; + + /* Pool lookup on the const tag indexes (empty = no candidates). + */ + auto poolFor = + [](const std::unordered_map > &tags, + const std::string &tag) -> std::vector { + std::unordered_map >::const_iterator it = + tags.find(tag); + return it == tags.end() ? std::vector() : + it->second; + }; + + /* Registry-id order (decision 4): the usage tally is consumed + * deterministically regardless of the population vector's + * build order. */ + std::vector ordered; + ordered.reserve(pop.members.size()); + for (const TownPopulation::PopMember &pm : pop.members) + ordered.push_back(&pm); + std::sort(ordered.begin(), ordered.end(), + [](const TownPopulation::PopMember *a, + const TownPopulation::PopMember *b) { + return a->registryId < b->registryId; + }); + + std::vector out; + out.reserve(pop.members.size()); + + for (const TownPopulation::PopMember *pm : ordered) { + const std::string &stableKey = pm->stableKey; + const std::string &homeAddr = + pop.households[pm->household].address; + TownLocations::NpcBindings bind = + TownLocations::computeBindings(locations, stableKey, + homeAddr); + /* The home location (the distance anchor). */ + const TownLocations::NpcLocation *home = nullptr; + for (const TownLocations::NpcLocation &l : + locations.locations) + if (l.name == bind.home) { + home = &l; + break; + } + + StatsSnapshot snapshot; + { + std::unordered_map:: + const_iterator si = + stats.find(pm->registryId); + if (si != stats.end()) + snapshot = si->second; + } + + std::mt19937 rng(seed ^ fnv1a(stableKey)); + /* Whole-hour template draws — fixed order per member: + * workday wake, weekend wake, (adults) work start, + * errand length, per-day errand rolls (day 0..6). */ + int workdayWake = (int)std::round( + clampf(rules.daily.wakeHour + + randRange(rng, -rules.daily.wakeSpread, + rules.daily.wakeSpread), + 4.0f, 10.0f)); + int weekendWake = (int)std::round( + clampf(rules.daily.wakeHour + + randRange(rng, -rules.daily.wakeSpread, + rules.daily.wakeSpread), + 4.0f, 10.0f)); + const bool adult = pm->age == "adult"; + int workStart = 0; + if (adult) + workStart = (int)std::round( + clampf(rules.daily.workStartHour + + randRange( + rng, + -rules.daily + .workStartSpread, + rules.daily + .workStartSpread), + (float)(workdayWake + 1), 12.0f)); + int errandLen = (int)std::round( + std::max(0.5f, + rules.daily.errandHours + + randRange(rng, -0.5f, 0.5f))); + if (errandLen < 1) + errandLen = 1; + bool errand[7]; + for (int d = 0; d < 7; ++d) + errand[d] = randRange(rng, 0.0f, 1.0f) < + rules.daily.errandProbability; + + /* Job resolution (once per member; decision 5 — jobs + * are weekly-sticky). */ + std::string jobTag; + std::string workName; + if (adult) { + std::vector tags = + sortedTagKeys(ix.jobTags); + if (!tags.empty()) { + uint32_t pick = fnv1a(stableKey) ^ + fnv1a("job-tag"); + jobTag = tags[pick % tags.size()]; + } + std::vector pool = jobTag.empty() ? + ix.works : + poolFor(ix.jobTags, jobTag); + if (pool.empty()) { + warn("week schedule: job tag '" + jobTag + + "' has no Work candidates (all Work " + "used)"); + pool = ix.works; + } + if (pool.empty()) { + /* Decision 7: work -> the daily binding + * (the Interest placeholder or home). */ + workName = bind.work; + warn("week schedule: no Work locations;" + " npcWork bindings used"); + } else { + double best = -1.0e30; + int bestIdx = -1; + for (int idx : pool) { + double s = scoreCandidate( + hook, locations, idx, home, + usage.get( + locations.locations + [idx] + .name), + snapshot); + if (s > best) { + best = s; + bestIdx = idx; + } + } + workName = + locations.locations[bestIdx].name; + /* Decision 4/5: usage counts PICKS (NPC + * count), not schedule entries — the + * sticky job is one pick per member. */ + usage.add(workName); + } + } + + WeekSchedule ws; + ws.registryId = pm->registryId; + ws.stableKey = stableKey; + + float t = 0.0f; + auto push = [&t, &ws](float end, const std::string &loc, + const char *act, + const std::string &q, int day) { + end = std::round(clampf(end, t, 24.0f)); + if (end > t + 1.0e-4f) { + WeekEntry e; + e.day = day; + e.startHour = t; + e.endHour = end; + e.location = loc; + e.activity = act; + e.tagQuery = q; + ws.entries.push_back(e); + t = end; + } + }; + + for (int day = 0; day < 7; ++day) { + const bool isWeekend = + std::find(rules.weekendDays.begin(), + rules.weekendDays.end(), + day) != + rules.weekendDays.end(); + t = 0.0f; + float wake = (float)(isWeekend ? weekendWake : + workdayWake); + push(wake, bind.home, "sleep", "", day); + + /* The day's window: work for working adults, or + * the day's outing window. */ + if (adult && !isWeekend) { + float wEnd = (float)workStart + + (float)(int)rules.daily + .workHours; + push((float)workStart, bind.home, "home", + "", day); + push(wEnd, workName, "work", + jobTag.empty() ? std::string() : + "job:" + jobTag, + day); + } else if (adult) { + /* Adult weekend: an afternoon outing + * window (the leisure slot lands + * inside it). */ + push((float)weekendWake + 4.0f, bind.home, + "home", "", day); + } else { + float outHour = + pm->age == "child" ? + rules.daily.childOutHour : + rules.daily.elderOutHour; + push(outHour, bind.home, "home", "", day); + } + + /* The daily leisure visit (the old "errand"). */ + if (errand[day] && t > 0.0f && t < 23.4f) { + std::vector ltags = + sortedTagKeys(ix.leisureTags); + std::string leisureTag; + if (!ltags.empty()) { + uint32_t pick = fnv1a(stableKey) ^ + fnv1a("leisure-tag"); + leisureTag = + ltags[pick % ltags.size()]; + } + std::vector pool = + leisureTag.empty() ? + ix.leisures : + poolFor(ix.leisureTags, + leisureTag); + if (pool.empty()) { + warn("week schedule: leisure tag " + "'" + + leisureTag + + "' has no Leisure candidates " + "(all Leisure used)"); + pool = ix.leisures; + } + int leisureIdx = -1; + if (!pool.empty()) { + double best = -1.0e30; + for (int idx : pool) { + double s = scoreCandidate( + hook, locations, + idx, home, + usage.get( + locations + .locations + [idx] + .name), + snapshot); + if (s > best) { + best = s; + leisureIdx = idx; + } + } + } + if (leisureIdx < 0) { + /* Decision 7: leisure -> curb -> + * interest -> home. */ + leisureIdx = rendezvousPick( + locations, ix.curbs, + stableKey, "leisure-fb"); + if (leisureIdx < 0) + leisureIdx = + rendezvousPick( + locations, + ix.interest, + stableKey, + "leisure-fb"); + if (leisureIdx < 0) + warn("week schedule: no " + "leisure/curb/interest " + "candidates; home used"); + } + const std::string leisureName = + leisureIdx >= 0 ? + locations + .locations + [leisureIdx] + .name : + bind.home; + push(t + (float)errandLen, leisureName, + "leisure", + leisureTag.empty() ? + std::string() : + "leisure:" + leisureTag, + day); + if (leisureIdx >= 0) + usage.add(leisureName); + } + + push(sleepHour, bind.home, "home", "", day); + push(24.0f, bind.home, "sleep", "", day); + /* Pathological rules (a closed window + a + * pushed-out errand): guarantee the day still + * ends at home covering to 24. */ + if (t < 24.0f && !ws.entries.empty()) + ws.entries.back().endHour = 24.0f; + } + + /* Deterministic order: (day, startHour). The per-day + * pass already tiles in order; the sort is a safety + * net for degenerate (skipped) windows. */ + std::stable_sort(ws.entries.begin(), ws.entries.end(), + [](const WeekEntry &a, const WeekEntry &b) { + if (a.day != b.day) + return a.day < b.day; + return a.startHour < b.startHour; + }); + + out.push_back(std::move(ws)); + } + return out; +} + +const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, float hour) +{ + /* Each day's entries tile [0, 24); the exact bucket wins. */ + float t = hour - 24.0f * std::floor(hour / 24.0f); + for (const WeekEntry &e : week.entries) { + if (e.day != day) + continue; + if (t >= e.startHour && t < e.endHour) + return &e; + } + return nullptr; +} + } /* namespace TownSchedule */ diff --git a/src/features/editScene/roadlib/TownSchedule.hpp b/src/features/editScene/roadlib/TownSchedule.hpp index 0a6fa0c..21e3ccb 100644 --- a/src/features/editScene/roadlib/TownSchedule.hpp +++ b/src/features/editScene/roadlib/TownSchedule.hpp @@ -1,11 +1,14 @@ #pragma once #include +#include #include +#include #include #include "TownLocations.hpp" #include "TownPopulation.hpp" +#include "TownScheduleScore.hpp" /** * NPC time-of-day schedules (demo-taxi phase 4.7.4). @@ -77,4 +80,114 @@ std::vector generateSchedules( const ScheduleEntry *entryAt(const NpcSchedule &schedule, float timeOfDayHours); +/* ===== Weekly schedules (6.8.3) ===== + * + * The pure baseline week (plan decision 2): a function of + * (stableKey, weekIndex, rules, stats snapshot, score hook) that + * tiles ALL SEVEN days per NPC with whole-hour (location, activity) + * entries. Deterministic, ECS-free and headless-testable; the idle + * runtime integration is 6.8.4. Text sources of truth: plan + * decisions 2/3/4/5/7/8 in demos/demo-taxi/PLAN.md phase 6.8. */ + +/** Week-start stats snapshot (6.8.9; the shared pure kernel's + * type). */ +using StatsSnapshot = TownScheduleScore::StatsSnapshot; + +/** One candidate location for an entry's tag query (same shape as + * systems/ScheduleScoring::Candidate — the ECS side adapts). */ +using WeekCandidate = TownScheduleScore::Candidate; + +/** Candidate scoring weights (the default matches + * systems/ScheduleScoring::Weights). */ +using WeekWeights = TownScheduleScore::Weights; + +/** Optional read-only override hook: receives ONLY the candidate + * and the week-start snapshot (decisions 4+8 — stat effects on + * locations steer the choice; the hook must never mutate state). */ +using ScoreHook = std::function; + +/** One weekly schedule entry (decision 3): the tag query is the + * conceptual resolution, `location` is the resolved cache of THIS + * build. Boundaries are whole hours tiling [0, 24) on `day`. */ +struct WeekEntry { + /** 0..6 day-of-week within the week. */ + int day = 0; + float startHour = 0.0f; /* whole hour [0, 24) */ + float endHour = 0.0f; /* (startHour, 24] */ + /** Resolved location name (home:"addr" or a LocationSet + * name). */ + std::string location; + /** "sleep" | "home" | "work" | "leisure". */ + std::string activity; + /** The conceptual query ("" for home/sleep entries; e.g. + * "job:salesman" or "leisure:shop"). */ + std::string tagQuery; + + bool operator==(const WeekEntry &other) const + { + return day == other.day && startHour == other.startHour && + endHour == other.endHour && location == other.location && + activity == other.activity && + tagQuery == other.tagQuery; + } +}; + +struct WeekSchedule { + uint64_t registryId = 0; + std::string stableKey; + /** The week this build produced (week 0 = the caller's + * baseline pivot; determinism: same inputs -> same week). */ + uint32_t weekIndex = 0; + /** Sorted by (day, startHour); every day tiles [0, 24). */ + std::vector entries; +}; + +/** Weekly rules: the existing per-day numerics plus the weekend + * day indexes (0..6; default {5, 6}). */ +struct WeekScheduleRules { + ScheduleRules daily; + std::vector weekendDays = { 5, 6 }; +}; + +/** Generate the baseline week for every population member. + * + * Per member (processed in registry-id order — decision 4): + * - Fixed draws from the per-NPC stream (`seed ^ fnv1a(stableKey)`): + * workday wake, weekend wake, (adults) work start, then per-day + * errand rolls and lengths (day 0..6). + * - The JOB location is resolved once per member (decision 5: + * jobs are weekly-sticky): tagQuery "job:" whose tag is a + * deterministic hash pick among the Work locations' job tags; the + * candidate pool is the Work locations carrying that tag + * (fallback: all Work locations, else the npcWork binding / + * home), scored by the hook (or the pure default) against the + * week-start snapshot; usage += per workday entry. + * - The LEISURE slot resolves per day: tagQuery "leisure:" + * hashed among the Leisure locations' tags; pool = Leisure with + * that tag (fallback: all Leisure, then Curb, then Interest, + * then home; decision 7), scored the same way. + * - Entries are quantized to whole hours and every day STARTS and + * ENDS at home (sleep bookends; decision 2). + * + * Warnings (decision 7): one line per distinct fallback (empty + * candidate pools, missing job/leisure tags) goes to @p warnings; + * 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).*/ +std::vector generateWeekSchedules( + const TownPopulation::Population &pop, + const TownLocations::LocationSet &locations, uint32_t seed, + const WeekScheduleRules &rules, + const std::unordered_map &stats = + std::unordered_map(), + const ScoreHook &hook = ScoreHook(), + std::vector *warnings = nullptr); + +/** The active week entry at a day/hour; nullptr only for an empty + * week (no tiling). */ +const WeekEntry *weekEntryAt(const WeekSchedule &week, int day, + float hour); + } /* namespace TownSchedule */ diff --git a/src/features/editScene/roadlib/TownScheduleScore.cpp b/src/features/editScene/roadlib/TownScheduleScore.cpp new file mode 100644 index 0000000..0e0a751 --- /dev/null +++ b/src/features/editScene/roadlib/TownScheduleScore.cpp @@ -0,0 +1,27 @@ +#include "TownScheduleScore.hpp" + +#include + +namespace TownScheduleScore { + +double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, + const Weights &weights) +{ + /* Usage and distance push the score down, a name-hash jitter + * breaks ties deterministically; the stats snapshot is + * deliberately unused by the default (decision 8: stats are + * sampled at week start and only steer via the override hook, + * not silently inside the default). */ + (void)snapshot; + uint32_t h = 2166136261u; + for (unsigned char c : candidate.name) { + h ^= (uint8_t)c; + h *= 16777619u; + } + double jitter = (double)h / (double)UINT32_MAX; + return -(weights.usage * (double)candidate.usage + + weights.distance * candidate.distanceMeters) + + weights.jitter * jitter; +} + +} // namespace TownScheduleScore diff --git a/src/features/editScene/roadlib/TownScheduleScore.hpp b/src/features/editScene/roadlib/TownScheduleScore.hpp new file mode 100644 index 0000000..b777351 --- /dev/null +++ b/src/features/editScene/roadlib/TownScheduleScore.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include + +/** + * Pure planner location scoring (demo-taxi phase 6.8.9 step 3 + + * 6.8.3). std-only — no OGRE/ECS headers — so the weekly schedule + * generator (roadlib/TownSchedule) and systems/ScheduleScoring (the + * Lua-hook read path) share ONE kernel, even in the OGRE-free + * schedule_scoring_test target. + * + * Given a candidate location and a week-start stats snapshot, + * defaultScoreLocation answers "how good is this location for this + * NPC?" as one comparable number (higher = better). The weekly + * generator ranks tag-query candidates with it; tests hardcode + * stats and expect exact scores. + * + * - The default kernel: lower current usage and shorter distance + * from home win, plus a deterministic name-hash jitter for + * tie-breaking. The snapshot is deliberately unused. + * - systems/ScheduleScoring adds the optional override hook (C++ + * std::function or Lua via ecs.schedule.set_score_hook): + * read-only, receives only the candidate and the snapshot, so + * stat effects on locations can steer the choice (poor NPCs + * prefer high-money POIs, stressed NPCs stress-relieving ones). + * A hook MUST NOT mutate game state. + */ +namespace TownScheduleScore { + +/** Week-start stats snapshot (6.8.9): stat name -> effective value. + * The score hook consumes it; the default kernel ignores it. */ +using StatsSnapshot = std::unordered_map; + +/** One candidate location for a schedule slot's tag query (same + * shape as systems/ScheduleScoring::Candidate — that one converts; + * the pure generator uses this directly). */ +struct Candidate { + /** Stable location name (LocationSet name). */ + std::string name; + /** Planner tags (the LocationSet's tags). */ + std::vector tags; + /** Distance from the NPC's home in meters (the pure baseline + * is straight-line XZ; a road-path metric can replace it). */ + double distanceMeters = 0.0; + /** How many entries already resolved to this location in + * this build (decision 4: usage is an output, not state). */ + int usage = 0; + /** Stat effects offered per in-game hour (e.g. money=+10). */ + std::unordered_map effects; +}; + +/** Candidate scoring weights (mirrors + * systems/ScheduleScoring::Weights; both apply the same kernel). */ +struct Weights { + double usage = 1.0; + double distance = 0.01; + double jitter = 0.001; +}; + +/** The pure default: -(usage*w + distance*w) + hashJitter*w. */ +double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, + const Weights &weights = Weights{}); + +} // namespace TownScheduleScore diff --git a/src/features/editScene/systems/RoadRegionStore.cpp b/src/features/editScene/systems/RoadRegionStore.cpp index e8527d1..b40e7e7 100644 --- a/src/features/editScene/systems/RoadRegionStore.cpp +++ b/src/features/editScene/systems/RoadRegionStore.cpp @@ -51,7 +51,7 @@ static bool lotParamsIsDefault(const RoadRegionLotParams &lp) lp.minFloors == 0 && lp.maxFloors == 0 && lp.roofType == -1 && lp.layout.empty() && lp.maxFrontage == 0.0f && lp.inhabited == 0 && lp.minHousehold == 0 && - lp.maxHousehold == 0; + lp.maxHousehold == 0 && lp.tags.empty(); } static void lotParamsToJson(const RoadRegionLotParams &src, @@ -71,6 +71,9 @@ static void lotParamsToJson(const RoadRegionLotParams &src, out["inhabited"] = src.inhabited; out["minHousehold"] = src.minHousehold; out["maxHousehold"] = src.maxHousehold; + /* 6.8.2b */ + if (!src.tags.empty()) + out["tags"] = src.tags; } static void lotParamsFromJson(const nlohmann::json &js, @@ -90,6 +93,13 @@ static void lotParamsFromJson(const nlohmann::json &js, out.inhabited = js.value("inhabited", 0); out.minHousehold = js.value("minHousehold", 0); out.maxHousehold = js.value("maxHousehold", 0); + /* 6.8.2b: tags are sparse (absent on pre-tag region files). */ + if (js.contains("tags") && js["tags"].is_array()) { + out.tags.clear(); + for (const auto &t : js["tags"]) + if (t.is_string() && !t.get().empty()) + out.tags.push_back(t.get()); + } } RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY) diff --git a/src/features/editScene/systems/RoadRegionStore.hpp b/src/features/editScene/systems/RoadRegionStore.hpp index ee5ab3f..caf7c62 100644 --- a/src/features/editScene/systems/RoadRegionStore.hpp +++ b/src/features/editScene/systems/RoadRegionStore.hpp @@ -69,6 +69,8 @@ struct RoadRegionLotParams { int inhabited = 0; int minHousehold = 0; int maxHousehold = 0; + /* 6.8.2b: extra building tags (lot-author zoning intent). */ + std::vector tags; }; /* diff --git a/src/features/editScene/systems/RoadSystem.cpp b/src/features/editScene/systems/RoadSystem.cpp index 78ae136..f974077 100644 --- a/src/features/editScene/systems/RoadSystem.cpp +++ b/src/features/editScene/systems/RoadSystem.cpp @@ -949,6 +949,7 @@ static void copyLotParamsToRegion(const RoadLotParams &src, dst.inhabited = (int)src.inhabited; dst.minHousehold = src.minHousehold; dst.maxHousehold = src.maxHousehold; + dst.tags = src.tags; } static void copyLotParamsFromRegion(const RoadRegionLotParams &src, @@ -968,6 +969,7 @@ static void copyLotParamsFromRegion(const RoadRegionLotParams &src, dst.inhabited = (RoadLotInhabited)src.inhabited; dst.minHousehold = src.minHousehold; dst.maxHousehold = src.maxHousehold; + dst.tags = src.tags; } void RoadSystem::migrateGraphToRegions(RoadGraph &graph) diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 9740066..9ddd162 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -5127,6 +5127,9 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) lj["inhabited"] = (int)lp.inhabited; lj["minHousehold"] = lp.minHousehold; lj["maxHousehold"] = lp.maxHousehold; + /* 6.8.2b: extra building tags. */ + if (!lp.tags.empty()) + lj["tags"] = lp.tags; return lj; }; if (!e.lotLeft.isDefault()) @@ -5412,6 +5415,18 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity, (RoadLotInhabited)lj.value("inhabited", 0); lp.minHousehold = lj.value("minHousehold", 0); lp.maxHousehold = lj.value("maxHousehold", 0); + /* 6.8.2b: extra building tags (absent on + * pre-tag saved scenes). */ + if (lj.contains("tags") && + lj["tags"].is_array()) { + lp.tags.clear(); + for (const auto &t : lj["tags"]) + if (t.is_string() && + !t.get() + .empty()) + lp.tags.push_back( + t.get()); + } }; readLotParams("lotLeft", edge.lotLeft); readLotParams("lotRight", edge.lotRight); diff --git a/src/features/editScene/systems/ScheduleScoring.cpp b/src/features/editScene/systems/ScheduleScoring.cpp index bfa62a1..fd61b37 100644 --- a/src/features/editScene/systems/ScheduleScoring.cpp +++ b/src/features/editScene/systems/ScheduleScoring.cpp @@ -1,5 +1,7 @@ #include "ScheduleScoring.hpp" +#include "../roadlib/TownScheduleScore.hpp" + namespace ScheduleScoring { @@ -12,26 +14,24 @@ Hook &hookSlot() return hook; } -uint32_t fnv1a(const std::string &text) -{ - uint32_t hash = 2166136261u; - for (unsigned char c : text) { - hash ^= c; - hash *= 16777619u; - } - return hash; -} - } // namespace double defaultScore(const Candidate &candidate, const StatsSnapshot &snapshot, const Weights &weights) { - (void)snapshot; /* the default ignores stats by design */ - double jitter = (double)fnv1a(candidate.name) / (double)UINT32_MAX; - return -(weights.usage * (double)candidate.usage + - weights.distance * candidate.distanceMeters) + - weights.jitter * jitter; + /* One shared kernel (demo-taxi 6.8.3): the pure weekly + * generator and this hook path must never drift apart. */ + TownScheduleScore::Candidate wc; + wc.name = candidate.name; + wc.tags = candidate.tags; + wc.distanceMeters = candidate.distanceMeters; + wc.usage = candidate.usage; + wc.effects = candidate.effects; + TownScheduleScore::Weights ww; + ww.usage = weights.usage; + ww.distance = weights.distance; + ww.jitter = weights.jitter; + return TownScheduleScore::defaultScore(wc, snapshot, ww); } void setHook(Hook hook) diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index 32853dc..6c9c542 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -3824,6 +3824,86 @@ static bool runTownGenProbe() -1.0f); ok = false; } + + /* 6.8.2b: per-lot tag overrides. A "shop" tag on + * the left side zones the building (dual-audience + * Work/Leisure in the LocationSet); duplicates of + * layout tags are dropped; layout tags come + * first. */ + { + RoadGraph g5; + RoadEdge &e5 = buildSingleEdge(g5); + e5.lotLeft.tags = { "shop" }; + TownGen::TownResult res5 = + TownGen::generateTown(g5, cc, flat); + const TownGen::TownBuilding *left5 = nullptr; + for (const TownGen::TownBuilding &b : + res5.buildings) + if (b.center.z < 0.0f) + left5 = &b; + if (!left5 || + !TownLocations::isCommercialBuilding( + left5->tags)) { + printf(" *** lotparams tags: left " + "building not zoned by the " + "'shop' override ***\n"); + ok = false; + } else { + /* The zoned shop's Work location + * carries the salesman job tag. */ + TownLocations::LocationSet ls = + TownLocations::buildLocations( + res5, 42, 0.0f); + const TownLocations::NpcLocation *w = + ls.findByName( + "work:" + left5->address); + if (!w || + w->tags.back().compare( + "salesman") != 0) { + printf(" *** lotparams tags: " + "work '%s' lacks the " + "salesman job tag ***\n", + w ? w->name.c_str() : + "(none)"); + ok = false; + } + } + } + { + RoadGraph g6; + RoadEdge &e6 = buildSingleEdge(g6); + /* cc falls back to the city-tower default + * (tower/center/commercial); a duplicate + * "commercial" override must be dropped, + * and layout tags stay first. */ + e6.lotRight.tags = { "commercial", "kiosk" }; + TownGen::TownResult res6 = + TownGen::generateTown(g6, cc, flat); + const TownGen::TownBuilding *right6 = nullptr; + for (const TownGen::TownBuilding &b : + res6.buildings) + if (b.center.z > 0.0f) + right6 = &b; + if (right6) { + std::vector want = { + "tower", "center", + "commercial", "kiosk" + }; + if (right6->tags != want) { + printf(" *** lotparams tags " + "order: got %zu tags," + " want 4 " + "(layout first, dupe " + "dropped) ***\n", + right6->tags.size()); + ok = false; + } + } else { + printf(" *** lotparams tags: no " + "+Z building ***\n"); + ok = false; + } + } } } @@ -7282,6 +7362,402 @@ static bool runTownTagIndexProbe() return ok; } +/** + * Weekly schedule generator probe (phase 6.8.3): the pure baseline + * week over a hand-built population + tagged LocationSet — whole-hour + * entries, home bookends, workday/weekend templates, weekly-sticky + * job resolution by tag, per-day leisure, determinism, no-candidate + * fallbacks, hook contract and usage spread. + */ +static bool runTownWeekScheduleProbe() +{ + printf("town week schedule probe\n"); + bool ok = true; + + /* Two shops at equal distance from the household home (x=100) + * so the scoring is usage-vs-jitter only -> the picks spread + * deterministically across the pool. */ + TownLocations::LocationSet set; + auto addLoc = [&](TownLocations::LocationKind kind, + const std::string &name, + const std::vector &tags, + int building, Ogre::Vector3 pos) { + TownLocations::NpcLocation loc; + loc.id = (int)set.locations.size(); + loc.kind = kind; + loc.name = name; + loc.address = name.substr(name.find(':') + 1); + loc.building = building; + loc.position = pos; + loc.tags = tags; + set.locations.push_back(loc); + }; + addLoc(TownLocations::LocationKind::Home, "home:shop-1", {}, 0, + Ogre::Vector3(100, 0, 0)); + addLoc(TownLocations::LocationKind::Home, "home:shop-2", {}, 1, + Ogre::Vector3(110, 0, 4)); + addLoc(TownLocations::LocationKind::Work, "work:shop-1", + { "commercial", "shop", "salesman" }, 0, + Ogre::Vector3(90, 0, 6)); + addLoc(TownLocations::LocationKind::Work, "work:shop-2", + { "commercial", "shop", "salesman" }, 1, + Ogre::Vector3(110, 0, 8)); + addLoc(TownLocations::LocationKind::Leisure, "leisure:shop-1", + { "commercial", "shop" }, 0, Ogre::Vector3(90, 0, 5)); + addLoc(TownLocations::LocationKind::Leisure, "leisure:shop-2", + { "commercial", "shop" }, 1, Ogre::Vector3(110, 0, 7)); + + /* Two shop households with two adults each. Members are + * listed with DESCENDING registry ids so the generator's + * registry-id ordering is exercised. */ + TownPopulation::Population pop; + pop.households.resize(2); + pop.households[0].address = "shop-1"; + pop.households[1].address = "shop-2"; + for (int i = 0; i < 4; ++i) { + TownPopulation::PopMember pm; + pm.registryId = 17001 + (uint64_t)i; + pm.stableKey = "npc-" + std::to_string((char)('a' + i)); + pm.household = i < 2 ? 1 : 0; + pm.age = "adult"; + pop.members.push_back(pm); + } + std::reverse(pop.members.begin(), pop.members.end()); + + TownSchedule::WeekScheduleRules rules; + std::vector warns; + std::vector week = + TownSchedule::generateWeekSchedules(pop, set, 42, rules, + {}, {}, &warns); + if (week.size() != 4) { + printf(" *** %zu week schedules, want 4 ***\n", + week.size()); + ok = false; + } + + /* Whole-hour alignment + per-day tiling + home bookends. */ + auto memberAddressOf = [&](const TownSchedule::WeekSchedule &ws) { + for (const TownPopulation::PopMember &pm : pop.members) + if (pm.stableKey == ws.stableKey) + return pop.households[pm.household].address; + return std::string(); + }; + for (const TownSchedule::WeekSchedule &ws : week) { + auto isInt = [](float v) { + return std::fabs(v - std::floor(v)) < 1e-3f; + }; + const std::string homeName = + "home:" + memberAddressOf(ws); + for (int day = 0; day < 7; ++day) { + std::vector dayE; + for (const TownSchedule::WeekEntry &e : ws.entries) + if (e.day == day) { + dayE.push_back(&e); + if (!isInt(e.startHour) || + !isInt(e.endHour)) { + printf(" *** %s day %d " + "entry not " + "hour-aligned " + "%g-%g ***\n", + ws.stableKey + .c_str(), + day, + (double) + e.startHour, + (double) + e.endHour); + ok = false; + } + } + if (dayE.empty()) { + printf(" *** %s day %d has no entries " + "***\n", ws.stableKey.c_str(), day); + ok = false; + continue; + } + if (dayE.front()->startHour != 0.0f || + dayE.back()->endHour < 24.0f) { + printf(" *** %s day %d not tiling [0,24)" + " ***\n", ws.stableKey.c_str(), day); + ok = false; + } + if (dayE.front()->location != homeName || + dayE.front()->activity != "sleep" || + dayE.back()->location != homeName || + dayE.back()->activity != "sleep") { + printf(" *** %s day %d home bookends " + "mismatch ***\n", + ws.stableKey.c_str(), day); + ok = false; + } + for (size_t k = 1; k < dayE.size(); ++k) { + if (dayE[k]->startHour < + dayE[k - 1]->endHour - 1e-3f) { + printf(" *** %s day %d " + "overlapping entries ***\n", + ws.stableKey.c_str(), day); + ok = false; + } + } + } + + /* Adults: work entries only on workdays (only adults + * produce them), one sticky job across the week, + * tagQuery "job:". */ + std::string workName; + int workDays = 0, weekendWorkDays = 0; + bool leisureSeen = false; + for (const TownSchedule::WeekEntry &e : ws.entries) { + if (e.activity == "work") { + if (e.tagQuery.rfind("job:", 0) != 0) { + printf(" *** %s work entry '%s' " + "without a job tag query " + "***\n", + ws.stableKey.c_str(), + e.tagQuery.c_str()); + ok = false; + } + bool isWeekend = false; + for (int wd : rules.weekendDays) + if (wd == e.day) + isWeekend = true; + if (isWeekend) + ++weekendWorkDays; + else + ++workDays; + if (workName.empty()) + workName = e.location; + else if (workName != e.location) { + printf(" *** %s job not sticky " + "(%s vs %s) ***\n", + ws.stableKey.c_str(), + workName.c_str(), + e.location.c_str()); + ok = false; + } + } + if (e.activity == "leisure") + leisureSeen = true; + if (e.activity == "leisure" && + e.tagQuery.rfind("leisure:", 0) != 0 && + !e.tagQuery.empty()) { + printf(" *** %s leisure entry with " + "odd query '%s' ***\n", + ws.stableKey.c_str(), + e.tagQuery.c_str()); + ok = false; + } + } + if (workName.empty() || workDays != 5 || + weekendWorkDays != 0) { + printf(" *** %s work days %d (weekend %d), want " + "5/0 ***\n", ws.stableKey.c_str(), workDays, + weekendWorkDays); + ok = false; + } + if (!leisureSeen) { + printf(" *** %s never visits leisure ***\n", + ws.stableKey.c_str()); + ok = false; + } + } + + /* Determinism: same inputs -> bit-identical weeks. */ + { + std::vector again = + TownSchedule::generateWeekSchedules(pop, set, 42, + rules); + if (again.size() == week.size()) { + for (size_t i = 0; i < week.size(); ++i) + if (again[i].entries.size() != + week[i].entries.size()) { + printf(" *** determinism: " + "member %zu differs ***\n", + i); + ok = false; + break; + } + } else { + printf(" *** determinism: count differs ***\n"); + ok = false; + } + } + + /* The default score ignores stats: a populated snapshot map + * changes nothing (hook contract for the default path). */ + { + std::unordered_map + stats; + stats[pop.members.front().registryId] = { { "money", 5.0 }, + { "stress", + 90.0 } }; + std::vector withStats = + TownSchedule::generateWeekSchedules(pop, set, 42, + rules, stats); + if (withStats.size() == week.size()) { + for (size_t i = 0; i < week.size(); ++i) + if (withStats[i].entries != + week[i].entries) { + printf(" *** stats changed the " + "default build ***\n"); + ok = false; + break; + } + } else { + ok = false; + } + } + + /* Hook contract: a distance-only hook makes every adult walk + * to the NEAREST shop; both shops must then receive work + * (the two households differ in distance), five workdays + * each, and the week bit-identically re-runs. */ + { + TownSchedule::ScoreHook distanceHook = + [](const TownSchedule::WeekCandidate &c, + const TownSchedule::StatsSnapshot &) { + return -c.distanceMeters; + }; + std::vector hooked = + TownSchedule::generateWeekSchedules(pop, set, 42, + rules, {}, + distanceHook); + std::set workChoices; + for (const TownSchedule::WeekSchedule &ws : hooked) + for (const TownSchedule::WeekEntry &e : ws.entries) + if (e.activity == "work") + workChoices.insert(e.location); + if (workChoices.size() != 2) { + printf(" *** hooked distance pick used %zu work " + "locations, want 2 (one per household) ***\n", + workChoices.size()); + ok = false; + } + } + + /* Usage spread (decision 4): with equal-distance candidates the + * default scoring alternates picks -> no single shop absorbs + * more than half the work picks. */ + { + std::map picks; + for (const TownSchedule::WeekSchedule &ws : week) + for (const TownSchedule::WeekEntry &e : ws.entries) + if (e.activity == "work") + ++picks[e.location]; + size_t maxPerLocation = 0; + for (const auto &kv : picks) + maxPerLocation = std::max(maxPerLocation, + (size_t)kv.second); + /* 4 adults * 5 workdays = 20 work entries over 2 + * shops; equal distance -> at most one shop can hold 4 + * adults' picks (20 entries). The alternation keeps + * max usage at <= 3 adults (hysteresis: only the SECOND + * pick is usage-informed...). Assert non-degenerate: + * BOTH shops received picks. */ + if (picks.size() < 2) { + printf(" *** usage did not spread: %zu shop(s) " + "used for work ***\n", picks.size()); + ok = false; + } + } + + /* Usage spread report (the plan asks the probe to print max/ + * mean location usage). */ + { + std::map picks; + int total = 0; + for (const TownSchedule::WeekSchedule &ws : week) + for (const TownSchedule::WeekEntry &e : ws.entries) { + ++picks[e.location]; + ++total; + } + if (!picks.empty()) { + size_t maxU = 0; + for (const auto &kv : picks) + maxU = std::max(maxU, + (size_t)kv.second); + printf(" usage: locations %zu, entries %d, max " + "%.2f, mean %.2f\n", + picks.size(), total, + (double)maxU, + (double)total / (double)picks.size()); + } + } + + /* No-candidate fallbacks (decision 7): a tagless town with only + * Interest+curb keeps the daily binding (work -> interest + * fallback) and leisure falls back to curb without crashing; + * the build reports the fallback warnings. */ + { + TownLocations::LocationSet few; + auto addF = [&](TownLocations::LocationKind kind, + const std::string &name, + Ogre::Vector3 pos) { + TownLocations::NpcLocation loc; + loc.id = (int)few.locations.size(); + loc.kind = kind; + loc.name = name; + loc.address = name.substr(name.find(':') + 1); + loc.position = pos; + few.locations.push_back(loc); + }; + addF(TownLocations::LocationKind::Home, "home:h1", + Ogre::Vector3(0, 0, 0)); + addF(TownLocations::LocationKind::Home, "home:shop-1", + Ogre::Vector3(1, 0, 0)); + addF(TownLocations::LocationKind::Home, "home:shop-2", + Ogre::Vector3(2, 0, 0)); + addF(TownLocations::LocationKind::Interest, "interest:h2", + Ogre::Vector3(10, 0, 0)); + addF(TownLocations::LocationKind::Curb, "curb:h2", + Ogre::Vector3(12, 0, 0)); + TownSchedule::WeekScheduleRules rulesF; + std::vector warnsF; + std::vector wf = + TownSchedule::generateWeekSchedules(pop, few, 42, + rulesF, {}, {}, + &warnsF); + for (const TownSchedule::WeekSchedule &ws : wf) { + for (const TownSchedule::WeekEntry &e : ws.entries) { + (void)e; + } + /* Adults fall back to the Interest binding for + * work (day 0). */ + bool sawInterestFallback = false; + for (const TownSchedule::WeekEntry &e : ws.entries) + if (e.activity == "work") { + if (e.location != + "interest:h2") { + printf(" *** fallback work " + "at '%s', want " + "interest:h2 ***\n", + e.location.c_str()); + ok = false; + } + sawInterestFallback = true; + break; + } + if (!sawInterestFallback) { + printf(" *** fallback week lost its " + "work entry ***\n"); + ok = false; + } + } + bool sawNoWorkWarn = false; + for (const std::string &w : warnsF) + if (w.find("no Work locations") != std::string::npos) + sawNoWorkWarn = true; + if (!sawNoWorkWarn) { + printf(" *** fallback missing its warning ***\n"); + ok = false; + } + } + + printf(" %s (%zu members)\n", ok ? "passed" : "FAILURES above", + week.size()); + return ok; +} + /** * Town parking slots probe (phase 6.6.1): the pure bay enumeration * (TownParking::enumerateSlots) and the TownGen integration — each @@ -9840,6 +10316,7 @@ int main(int argc, char **argv) ok &= runTownPopulationProbe(); ok &= runTownLocationsProbe(); ok &= runTownTagIndexProbe(); + ok &= runTownWeekScheduleProbe(); ok &= runTownParkingProbe(); ok &= runPoiDatabaseProbe(); ok &= runTownScheduleProbe(); diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 80d689c..1a2f85c 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -3600,6 +3600,82 @@ private: "Household size range; 0 = " "inherit the town area's " "population rules"); + /* 6.8.2b: per-lot building tags + * (same comma-separated widget + * pattern as the town area's + * Preferred Types). */ + { + std::string joined; + for (size_t k = 0; + k < lp.tags.size(); + ++k) { + if (k) + joined += + ", "; + joined += + lp.tags[k]; + } + char tagBuf[256]; + snprintf(tagBuf, + sizeof(tagBuf), + "%s", + joined.c_str()); + if (ImGui::InputText( + "Tags", + tagBuf, + sizeof( + tagBuf))) { + lp.tags.clear(); + std::string cur; + for (const char *p = + tagBuf;; + ++p) { + if (*p != + ',' && + *p != + '\0') { + cur += + *p; + continue; + } + size_t b = + cur.find_first_not_of( + " \t"); + if (b != + std::string:: + npos) + lp.tags + .push_back( + cur + .substr( + b, + cur + .find_last_not_of( + " \t") - + b + + 1)); + cur.clear(); + if (*p == + '\0') + break; + } + edgeChanged = + true; + } + if (ImGui:: + IsItemHovered()) + ImGui::SetTooltip( + "Comma-separated extra building " + "tags appended to the picked " + "layout's :tags (e.g. 'shop' " + "zones the building: a Work " + "location for salesmen + a " + "Leisure location for " + "customers). Empty = inherit " + "(layout tags only). Edit a " + "neighbor side's field and " + "press Regenerate to apply."); + } /* Layout DSL (phase 4.5 item 2): * a library layout name * (village-house, town-rowhouse,