Compare commits
2 Commits
54d9d4a28e
...
19c5528f8e
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c5528f8e | |||
| 2873a695f7 |
@@ -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 `<kind>:<address>`, 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,63 @@ 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:<tag>" / "leisure:<tag>", ""
|
||||
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 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:<address>` and the first
|
||||
`leisure:<address>` 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<std::string> 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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4537,8 +4537,10 @@ 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), 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
|
||||
@@ -4711,8 +4713,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 +4753,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 `<kind>:<address>`, 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 +4794,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,33 +4845,77 @@ 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).**
|
||||
|
||||
**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:<address>` when the
|
||||
area has Work locations, else the old Interest fallback) and
|
||||
`npcInterest` the week's first leisure pick (`leisure:<address>`,
|
||||
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: <day> (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).**
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -131,4 +131,472 @@ 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<int> works;
|
||||
std::vector<int> leisures;
|
||||
std::vector<int> interest;
|
||||
std::vector<int> curbs;
|
||||
/* Tag -> candidate location indices (location order,
|
||||
* deterministic). */
|
||||
std::unordered_map<std::string, std::vector<int> > jobTags;
|
||||
std::unordered_map<std::string, std::vector<int> > 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<int> &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<std::string, int> counts;
|
||||
int get(const std::string &name) const
|
||||
{
|
||||
std::unordered_map<std::string, int>::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<std::string> sortedTagKeys(
|
||||
const std::unordered_map<std::string, std::vector<int> > &tags)
|
||||
{
|
||||
std::vector<std::string> 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<WeekSchedule> generateWeekSchedules(
|
||||
const TownPopulation::Population &pop,
|
||||
const TownLocations::LocationSet &locations, uint32_t seed,
|
||||
const WeekScheduleRules &rules,
|
||||
const std::unordered_map<uint64_t, StatsSnapshot> &stats,
|
||||
const ScoreHook &hook, std::vector<std::string> *warnings,
|
||||
std::map<std::string, int> *usageOut)
|
||||
{
|
||||
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<std::string,
|
||||
std::vector<int> > &tags,
|
||||
const std::string &tag) -> std::vector<int> {
|
||||
std::unordered_map<std::string,
|
||||
std::vector<int> >::const_iterator it =
|
||||
tags.find(tag);
|
||||
return it == tags.end() ? std::vector<int>() :
|
||||
it->second;
|
||||
};
|
||||
|
||||
/* Registry-id order (decision 4): the usage tally is consumed
|
||||
* deterministically regardless of the population vector's
|
||||
* build order. */
|
||||
std::vector<const TownPopulation::PopMember *> 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<WeekSchedule> 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<uint64_t, StatsSnapshot>::
|
||||
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<std::string> tags =
|
||||
sortedTagKeys(ix.jobTags);
|
||||
if (!tags.empty()) {
|
||||
uint32_t pick = fnv1a(stableKey) ^
|
||||
fnv1a("job-tag");
|
||||
jobTag = tags[pick % tags.size()];
|
||||
}
|
||||
std::vector<int> 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<std::string> 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<int> 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));
|
||||
}
|
||||
if (usageOut) {
|
||||
usageOut->clear();
|
||||
for (const auto &kv : usage.counts)
|
||||
(*usageOut)[kv.first] = kv.second;
|
||||
}
|
||||
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 */
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "TownLocations.hpp"
|
||||
#include "TownPopulation.hpp"
|
||||
#include "TownScheduleScore.hpp"
|
||||
|
||||
/**
|
||||
* NPC time-of-day schedules (demo-taxi phase 4.7.4).
|
||||
@@ -77,4 +81,119 @@ std::vector<NpcSchedule> 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<double(const WeekCandidate &,
|
||||
const StatsSnapshot &)>;
|
||||
|
||||
/** 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<WeekEntry> entries;
|
||||
};
|
||||
|
||||
/** Weekly rules: the existing per-day numerics plus the weekend
|
||||
* day indexes (0..6; default {5, 6}). */
|
||||
struct WeekScheduleRules {
|
||||
ScheduleRules daily;
|
||||
std::vector<int> 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:<tag>" 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:<tag>"
|
||||
* 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).
|
||||
*
|
||||
* @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<WeekSchedule> generateWeekSchedules(
|
||||
const TownPopulation::Population &pop,
|
||||
const TownLocations::LocationSet &locations, uint32_t seed,
|
||||
const WeekScheduleRules &rules,
|
||||
const std::unordered_map<uint64_t, StatsSnapshot> &stats =
|
||||
std::unordered_map<uint64_t, StatsSnapshot>(),
|
||||
const ScoreHook &hook = ScoreHook(),
|
||||
std::vector<std::string> *warnings = nullptr,
|
||||
std::map<std::string, int> *usageOut = 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 */
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#include "TownScheduleScore.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* 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<std::string, double>;
|
||||
|
||||
/** 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<std::string> 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<std::string, double> 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
|
||||
@@ -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<std::string> weekWarns;
|
||||
std::map<std::string, int> usage;
|
||||
std::vector<TownSchedule::WeekSchedule> 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<uint64_t,
|
||||
TownSchedule::WeekSchedule>::const_iterator
|
||||
it = kv.second.weekSchedules.find(registryId);
|
||||
if (it != kv.second.weekSchedules.end())
|
||||
return &it->second;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::map<std::string, int> *
|
||||
NpcPopulationSystem::weekUsageOf(int areaIndex) const
|
||||
{
|
||||
auto it = m_areas.find(areaIndex);
|
||||
return it == m_areas.end() ? nullptr : &it->second.weekUsage;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <flecs.h>
|
||||
@@ -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<std::string, int> *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<TownSchedule::NpcSchedule> 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<uint64_t, TownSchedule::WeekSchedule>
|
||||
weekSchedules;
|
||||
/* The weekly build's location -> pick tally (decision
|
||||
* 4; F7 week pane). */
|
||||
std::map<std::string, int> 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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "NpcScheduleDebugOverlay.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <imgui.h>
|
||||
|
||||
#include <map>
|
||||
@@ -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<std::string, int>
|
||||
*usage = pop->weekUsageOf(
|
||||
areaIdxW);
|
||||
if (usage && !usage->empty()) {
|
||||
ImGui::TextDisabled(
|
||||
"location usage: %zu distinct, top:",
|
||||
usage->size());
|
||||
std::vector<
|
||||
std::pair<std::string,
|
||||
int> >
|
||||
top(usage->begin(),
|
||||
usage->end());
|
||||
std::sort(
|
||||
top.begin(),
|
||||
top.end(),
|
||||
[](const std::pair
|
||||
<std::string, int>
|
||||
&a,
|
||||
const std::pair
|
||||
<std::string, int>
|
||||
&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)) {
|
||||
|
||||
@@ -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<std::string>().empty())
|
||||
out.tags.push_back(t.get<std::string>());
|
||||
}
|
||||
}
|
||||
|
||||
RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY)
|
||||
|
||||
@@ -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<std::string> tags;
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<std::string>()
|
||||
.empty())
|
||||
lp.tags.push_back(
|
||||
t.get<std::string>());
|
||||
}
|
||||
};
|
||||
readLotParams("lotLeft", edge.lotLeft);
|
||||
readLotParams("lotRight", edge.lotRight);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<std::string> 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,436 @@ 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<std::string> &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<std::string> warns;
|
||||
std::vector<TownSchedule::WeekSchedule> 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<const TownSchedule::WeekEntry *> 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:<tag>". */
|
||||
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<TownSchedule::WeekSchedule> 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<uint64_t, TownSchedule::StatsSnapshot>
|
||||
stats;
|
||||
stats[pop.members.front().registryId] = { { "money", 5.0 },
|
||||
{ "stress",
|
||||
90.0 } };
|
||||
std::vector<TownSchedule::WeekSchedule> 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<TownSchedule::WeekSchedule> hooked =
|
||||
TownSchedule::generateWeekSchedules(pop, set, 42,
|
||||
rules, {},
|
||||
distanceHook);
|
||||
std::set<std::string> 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<std::string, int> 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). The tally equals the pick-bearing
|
||||
* entries: one per adult's job plus one per errand day. */
|
||||
{
|
||||
std::map<std::string, int> picks;
|
||||
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;
|
||||
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());
|
||||
}
|
||||
/* 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<std::string, int> 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
|
||||
* 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<std::string> warnsF;
|
||||
std::vector<TownSchedule::WeekSchedule> 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 +10350,7 @@ int main(int argc, char **argv)
|
||||
ok &= runTownPopulationProbe();
|
||||
ok &= runTownLocationsProbe();
|
||||
ok &= runTownTagIndexProbe();
|
||||
ok &= runTownWeekScheduleProbe();
|
||||
ok &= runTownParkingProbe();
|
||||
ok &= runPoiDatabaseProbe();
|
||||
ok &= runTownScheduleProbe();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user