name: world-system description: Architecture of the World System including Map Hibernation, Spatial Offsets, and Macro-Simulation (Offline Catch-Up) for scaling the game.
WORLD SYSTEM ARCHITECTURE
1. Core Philosophy (Spatial Offset)
The World System avoids the overhead of Unity's NetworkSceneManager and additive scene loading. Instead, it uses a Single-Scene Spatial Offset Architecture.
- Regions / Maps are massive quadrants physically separated by large distances (e.g., Map A at
x=0, Map B atx=10000). - Building Interiors are placed high in the sky (
y=5000) or deep underground. - Transitions between these areas are seamless and are handled by
CharacterMovement.Warp().
Because Maps are physically distant, Unity NGO's Interest Management naturally filters out network packets across Maps.
2. Map Hibernation (Performance)
To support thousands of NPCs and hundreds of Maps locally, we use Map Hibernation.
- Activation: The
MapControllertracks active players. WhenPlayerCount == 0, the Map enters Hibernation. - Phase 1 (Pause): All NPCs on the Map are serialized into
HibernatedNPCData(insideMapSaveData), extracting only pure logic (Stats, Schedule target, Inventory, Needs) and position. The actual heavy UnityGameObject(andNavMeshAgent,Animator,NetworkObject) is DESPAWNED and DESTROYED. The Map is visually dead. - Phase 2 (Wake Up): When the first player re-enters the dormant Map, the
MapControllerimmediately calls theMacroSimulator.
3. Macro-Simulation (Catch-Up Math)
The MacroSimulator operates entirely off-screen when a Map wakes up.
- It calculates the absolute time delta (
DeltaTime = CurrentTime - HibernationTime). - It looks at each
HibernatedNPCDataand mathematically computes what the NPC would have done during that gap. - Offline Needs Decay: It calculates how much time has passed and subtracts that from the serialized
CharacterNeed.CurrentValue(e.g., Hunger, Social), ensuring NPCs wake up with accurate stat depletion proportionate to theDeltaTime. - Persistent Character Progression: It handles extraction and injection of logic unique to the NPC (e.g.
CharacterBlueprints.UnlockedBuildingIds) to ensure the city-building capabilities are consistent across Map Hibernation and Wake-Up phases. - Offline City Growth: Driven entirely by the
CommunityLeader'sCharacterBlueprintsknowledge. The Simulator filters theBuildingRegistryby the Leader's knownUnlockedBuildingIds, checks which are missing in the community, and spawns scaffold data respecting theCommunityPriority. - Simulation vs Realtime: Rather than simulating every frame of walking, the MacroSimulator just skips them to the end of their current scheduled task (e.g., if it's 8:00 AM, snap position to Blacksmith Forge).
- Once simulated, the Server reinstantiates the Prefab at the new position, assigns the updated stats and blueprint data, and calls
Spawn()to sync with the entering client.
3.1 Time Skip (player-initiated macro-sim loop)
The Time Skip path advances the in-game clock by N hours without running the live simulation, by hibernating the active map and running MacroSimulator.SimulateOneHour per hour. Coexists with GameSpeedController — that one scales Time.timeScale so live NPCs tick faster; this one freezes them entirely. Architecture details live in [[world-time-skip]].
- Triggers:
/timeskip <hours>chat command +DevTimeSkipModuledev-panel button (both passforce: true— admin override), and the bed flow +UI_BedSleepPrompt(consent path —force: false). All routes go throughTimeSkipController.Instance.RequestSkip(hours, force)— server-only, returnsbool. - Multiplayer consent gate (
force: false): every connected player'sCharacter.IsSleepingmust betruebeforeRequestSkipaccepts. Reject otherwise. The auto-trigger watcher inTimeSkipController.Update(server-only) polls every connected player; when allIsSleeping == trueAND at least one hasPendingSkipHours > 0, it firesRequestSkip(MIN(PendingSkipHours), force: false)automatically — closest target wins, so nobody is dragged past their requested duration. - Host force-override (
force: true): auto-EnterSleeps every connected player at the start of the coroutine. Bypasses the all-sleeping gate. Used by dev panel + chat command. Don't expose this to non-host gameplay paths or one player can override every other player's autonomy. - Bed flow contract: the bed
UseSlotpath raisesCharacter.IsSleeping = true; the bed prompt UI setsCharacter.PendingSkipHours = chosenHoursviaCharacter.SetPendingSkipHours(int)(server-only — clients use a ServerRpc wrapper). OnCharacter.ExitSleep,PendingSkipHoursresets to 0 so a stale value can't auto-trigger the next session. - Per-hour loop (server coroutine): validate → freeze
Time.timeScale→ resolveactiveMap+ players → pre-skip checkpoint save (waits forSaveManager.CurrentState == Idlebefore continuing) →HibernateForSkip→ for each hour (AdvanceOneHour→SimulateOneHour→OnSkipHourTick→ abort check) →WakeUpFromSkip→ExitSleepfor each player → restoreTime.timeScale. The_pendingSkipWakeflag on the activeMapControllermakesWakeUp()skip the redundant single-pass catch-up over the same delta we just simulated hour-by-hour. Cleanup is wrapped in try/catch — a WakeUp exception logs but doesn't strandIsSkipping = true. - Day-boundary gating: any new step that integrates over
daysPassedmust run insideif (crossedDayBoundary)inSimulateOneHour, not the hour-grained block, or it will floor-to-0 every hour and silently no-op the entire skip. Hour-grained steps (ApplyNeedsDecayHours,SnapPositionFromSchedule) run every hour because they're already scaled per-hour internally. - Bed lifecycle:
BedFurniture.UseSlot(slot, character)→Character.EnterSleep(anchor)(server-only; snaps position+rotation, toggles NavMeshAgent viaConfigureNavMesh(bool)) →Character.NetworkIsSleeping = true→PlayerController.Update()early-out.IsSleepingandPendingSkipHoursare NetworkVariables — replicate to all peers. Neither is persisted; reload always wakes the player. - Pre-skip save captures the world before the hibernate-skip-wake cycle. If anything goes wrong (exception, abort, crash), the player reverts to the pre-skip state on next load. The post-skip save still fires naturally via
SleepBehaviour.Exiton the bed flow; dev paths rely solely on the pre-skip checkpoint. - Virtual buildings caveat:
MapController.Hibernateskips anyBuildingwhoseNetworkObjectis shared with the MapController (the inheritedNetworkBehaviour.NetworkObjectgetter walks up viaGetComponentInParent). Despawning that NetworkObject would destroy the MapController itself — was the root cause of an early WakeUpMissingReferenceException. Do not remove the guard. - Manual setup required — see [[world-time-skip]] §"Manual setup checklist" for the Editor wiring (TimeSkipController on the GameManager GameObject, Time Skip tab in
DevModePanel.prefab,UI_TimeSkipOverlay.prefab,UI_BedSleepPrompt.prefab).
4. Map Transitions
Transitions are standardized via MapTransitionDoor (exterior-to-exterior) and BuildingInteriorDoor (exterior-to-interior).
- Players interact with a door.
CharacterMapTransitionActionfades screen viaScreenFadeManager, then warps. - Cross-NavMesh teleports (building interiors at y=5000) must use
CharacterMovement.ForceWarp(), notWarp(). ForceWarp disables the NavMeshAgent, teleports viatransform.position, then re-enables the agent after 2 frames. - The
CharacterMapTrackeris invoked viaRequestTransitionServerRpc(FixedString128Bytesfor map IDs). The server resolves interior positions viaResolveInteriorPosition()(lazy-spawns if needed) and sendsWarpClientRpcback if the position changed (ClientNetworkTransform is owner-authoritative). - The Server updates the
CurrentMapIDand notifies source/destination MapControllers viaMapController.NotifyPlayerTransition()for hibernation handoff. - See
building-systemSKILL.md for full interior transition architecture.
5. Development Rules
- Do not rely on
FindObjectOfType: Maps hibernate, so GameObjects will completely disappear. Only search serialized data structures (like a hypotheticalWorldManager.HibernatedData) if trying to locate an off-screen NPC. - No Cross-Map Physics: A projectile from Map A will never reach Map B. Do not attempt it. Any inter-map effects (e.g. economy shipments) must be calculated purely via Math in the
JobLogisticsManager, not via physical objects driving across the emptiness. - Character Maps are Authoritative: Always rely on
CharacterMapTracker.CurrentMapID.Valueto know what map a character belongs to.
6. World Hierarchy (Phase 1 refactor — ADR-0001, implemented)
The world is organized as: Region (authored container) → { MapController, WildernessZone, WeatherFront }. All three implement IWorldZone.
- Region:
NetworkBehaviour+ISaveableplaced in the scene. Holds aBiomeDefinition,BiomeClimateProfile, and a BoxCollider trigger.[RequireComponent(NetworkObject)]so scene-placed Regions auto-acquire aNetworkObjecton scene load — required so childMapController/WildernessZoneNetworkObjects can be NGO-parented viaTrySetParent. Auto-discovers scene-childMapControllers andWildernessZones inAwakeviaGetComponentsInChildren. Runtime-spawned children register viaRegisterMap(MapController)/RegisterWildernessZone(WildernessZone)— unregister counterparts on despawn. Clients learn their region via theCharacterMapTracker.CurrentRegionIdNetworkVariable. - Wilderness zone: A
MapType.WildernessMapController— not a separate class. Content is authored in aWildernessZoneDefSO and scattered byWildernessZoneController(server-onlyMonoBehaviouron the same GameObject). Backed by an unclaimed empty-leaderlessCommunityData(IsWildernessMap=true). See §6.4 below for the full API and lifecycle. - WeatherFront: Atmospheric region spawned by a parent
Regionon a timer (unchanged — formerly owned byBiomeRegion).
6.1 Map Birth (no more cluster auto-promotion)
Maps are never created by NPC-cluster auto-promotion (the old CommunityTracker.PromoteToSettlement path is removed). They are born via:
- (a) Scene authoring — designer places a
MapControllerinside aRegionin the scene. - (b) Building placement —
BuildingPlacementManageris region-aware: if the placement position falls inside a Region with no enclosing map, it callsMapRegistry.CreateMapAtPosition(worldPosition)to spawn a new wild map inside that Region rather than poaching a map from a neighbor. If the position is in the open world (no Region), it falls back to the legacy join-nearest-else-create flow usingMapController.GetNearestExteriorMap. - (c) Future procedural generation — not implemented.
MapControllers are elastic — the BoxCollider adapts instead of rejecting placements (post-merge iteration, 2026-04-22):
MapController.ClampBoundsToRegion(regionBounds)— called byMapRegistry.CreateMapAtPositionright afterNetworkObject.Spawn. Clamps the freshly-instantiated map's BoxCollider to the intersection of its prefab size and the Region's world-space bounds. Shrink-to-fit — small Regions produce smaller maps.MapController.ExpandBoundsToInclude(worldPoint, footprintSize, regionBounds)— called byBuildingPlacementManager.RegisterBuildingWithMapwhen a placement falls NEAR (withinWorldSettingsData.MapMinSeparation) an existing same-region map. Grows the BoxCollider to envelop the new building's footprint, clamped to Region bounds. Expand-to-envelop — communities stay contiguous, no micro-map proliferation.MapRegistry.FindNearestMapInRegion(position)— returns the closest exterior map in the same Region within MinSep, or null. Placement prefers expansion; only spawns a new wild map when this returns null.WorldSettingsData.MapMinSeparationis therefore a soft threshold (routes to expansion), NOT a hard rejection. The oldCreateMapAtPositionrejection-on-MinSep branch and client-side fit/MinSep toasts were removed.- Map-overlap prevention (2026-06-05). A new community map (founding an AB on genuine open ground) is gated + sized so it never overlaps an existing map.
WorldSettingsData.MapMinGap(bounds-gap, default 24u) is the clearance between map bounds — distinct from the size-blind center-to-centerMapMinSeparation.MapRegistry.WouldNewMapOverlap(pos, out conflictId)is the placement gate (reusesWouldWildernessOverlapwith the MapController-prefab half-extent + gap); wired into both founder paths (BTAction_PursueAmbition.DrivePlaceBuilding+TryFindFoundingPosition) and the player admin-console founder branch inBuildingPlacementManager. Claim exemption (critical):WouldNewMapOverlapshort-circuits tofalsewhenMapController.GetMapAtPosition(pos) != null(inside a map → claiming an unclaimed MapController by placing an AB, or joining) orFindNearestMapInRegion(pos) != null(near one → expand) — a new map is only created on open ground, so the gate must never block claims/joins/expands. This is provably correct: the sameGetMapAtPosition(pos)resolveshostMapForGridin the placement path, so the guard is true exactly when the placement lands inside an existing map. (Foreign claimed maps are still blocked earlier by the founder-mode foreign gate.) Safety net:CreateMapAtPositionre-sizes the new map via the pureMapBoundsFitter.ComputeNonOverlappingBounds(per-face directional shrink, keeps the AB inside) applied throughMapController.SetWorldBoundsXZ(Bounds), so even a dev/player placement that bypasses the gate can't produce an overlapping map. Expansion twin (2026-06-05b): the elastic grow-to-envelop pathMapController.ExpandBoundsToIncludewas the unguarded twin — it clamped only to the Region, so a growing community map could expand straight through a neighbour (the CONFIRMED live overlap: Sloane's settlement grew 185×100 through a claimed Wilderness map). It now clamps the grown bounds via the pureMapBoundsFitter.ClampExpansionAgainstSiblings(current, desired, region, siblings, gap)— grows toward the new building but stopsMapMinGapshort of every sibling, never shrinking below the pre-expansion bounds (so already-placed buildings are never orphaned). Both map-bounds mutation points (create + expand) now enforce the no-overlap invariant;ExpandBoundsToIncludegathers siblings viaFindObjectsByType<MapController>(server-only, rare per-placement path). Pre-existing overlaps are not retroactively healed (the never-shrink-below-current rule forbids it). See [[building-placement-manager]] for the full gate write-up. - Wild maps strip
Biome/JobYieldson instantiation soMapController.SpawnVirtualBuildingsshort-circuits (noVirtualResourceSupplier_*children on small player outposts). Newly spawned mapsnetObj.TrySetParentunder their target Region — valid because Region is a NetworkBehaviour. - Known limitation:
BoxCollider.center/sizeare plain fields, notNetworkVariables — resize is server-authoritative only. Clients see the prefab's default bounds until a future PR replicates them. Hibernation/placement/save-load are unaffected because they run server-side.
MapRegistry (renamed from CommunityTracker; SaveKey = "CommunityTracker_Data" preserved for save-file back-compat) holds the persistent CommunityData list (leaders, constructed buildings, resource pools, build permits, pending claims). It no longer runs population heartbeats — only ProcessPendingBuildingClaims on TimeManager.OnNewDay.
Tier persistence (2026-06-01). The community's authoritative tier lives on the runtime Community (currentTierId string + shadow level enum) and is persisted via CommunityData.CurrentTierId. Save side: MapRegistry.CaptureState stamps each runtime Community.CurrentTier.TierId onto its matching commData before snapshotting (single chokepoint — catches ChangeTier, dev console, and direct level= writes). Load side: both reconstruction paths — MapRegistry.EnsureAllCommunitiesLoaded and CharacterCommunity.ResolveOrReconstructCommunity — call Community.RestoreTierFromSave(commData.CurrentTierId) right after the leaderless ctor, before RunDirectKingdomCurrencyCatchUpScan runs (the scan gates on CurrentTier.Order >= 5, so a wrong tier silently skips the Kingdom-currency re-credit). RestoreTierFromSave is side-effect-free (no mint hook, no "evolved" log) and sets currentTierId + level + the cached SO together to avoid the CurrentTier getter's level↔id desync-sync clobber. Back-compat: empty CurrentTierId (legacy save) keeps the SmallGroup ctor default. The legacy CommunityData.Tier (CommunityTier enum) is a dead axis — never write or read it for the runtime ladder.
6.1a Save/Load round-trip for dynamic wild maps
CommunityData.SpawnPosition records the exact world-space position of dynamic maps. On world load, MapRegistry.RestoreState schedules RespawnDynamicMapsDeferred via Invoke(..., 1.5f) — the 1.5s delay lets scene-placed MapControllers finish OnNetworkSpawn and register in _mapRegistry first. The deferred pass iterates _communities, skips IsPredefinedMap=true entries and any MapId already live, and for the rest:
- Instantiates
_mapControllerPrefabatSpawnPosition - Clears
Biome/JobYields(wild-map semantics) netObj.Spawn()- Re-establishes Region parenting via
Region.GetRegionAtPosition+RegisterMap+TrySetParent - Calls
mapController.SpawnSavedBuildings()to bring back the buildings saved inCommunityData.ConstructedBuildings
Without step 5, GameLauncher.cs:188-195 only calls SpawnSavedBuildings on predefined maps (iterated at world-load time, before our deferred respawn creates the wild ones).
AB-ownership reconciliation (2026-06-02). Never resolve "which community owns this AdministrativeBuilding" via MapRegistry.GetCommunity(mapId) — it is first-match (_communities.Find) and multiple CommunityData can deliberately share a MapId. Use the community Guid (GetCommunityById) or the placer's CurrentCommunity (Guid-keyed). A founder who founds on map A then places the AB on map B (commonly an unclaimed wilderness grove) leaves the AB claimed by TWO records — a leaderless one hosting it on B + the founder's leadered one on A — and any MapId-keyed bind picks the leaderless ghost (the "AB owned by an empty Settlement; founder community unchartered" bug). Healed at load by MapRegistry.ReconcileAbOwnershipClaims (wired into RestoreState after DeduplicateCommunitiesByFounder, before EnsureAllCommunitiesLoaded): RehomeOwnerOntoHostMap re-homes the founder's record onto the AB's host-map identity (adopts MapId + IsWildernessMap + spatial + wilderness content + the AB BSD) and deletes the ghost — i.e. it completes the rule #30 wilderness claim that failed to take live. Re-home the OWNER (never merge into the ghost) so the profile-referenced founder Guid survives. Companion fixes: leadered-first iteration in BindCommunityBuildingsToRuntimeCommunities, placer-Guid-first AdministrativeBuilding.TrySelfBindOwnerCommunityFromMap (MapId path is now fallback-only), and a live RegisterBuildingWithMap re-home + loud diagnostic when the placer's CurrentCommunity can't be resolved. Full pattern + detection: [[ab-binding-leaderless-ghost]] gotcha.
In-session same-Guid dedup (2026-06-05). CommunityManager.SyncCommunityToMapData stamps the runtime Guid onto the host map's PRE-EXISTING CommunityData (the grove/wilderness stub) at AB placement (commData.CommunityId = runtimeComm.CommunityId) WITHOUT removing the founder's earlier same-Guid record from SyncFounderIntoCommunityData. Result: two CommunityData share one Guid live. SanitizeCommunitiesInPlace Pass 1 folds same-Guid dups (richest-wins + union via CommunityRichness + MergeDuplicateCommunityInto) but runs ONLY at CaptureState/RestoreState — so the dup heals on reload yet accumulates in-session. Fix: MapRegistry.ReconcileDuplicateCommunityId(communityId) — the SAME merge, targeted + mid-session-safe — called at the END of SyncCommunityToMapData (the dup-formation site, the ONLY place it's wired). It collects all _communities with the Guid, folds non-canonical records into the richest via the existing helpers (NEVER a lossy drop — the 2026-06-03 "Aria's Settlement" incident proved a naive drop destroys the AB + grove harvestables), removes the folded-away records, no-ops at ≤1. Server-only. Prevention-at-source companion: SyncFounderIntoCommunityData's freshness gate now also adopts a leaderless WILDERNESS record (... && (string.IsNullOrEmpty(existing.CommunityId) || existing.IsWildernessMap)) so the found-inside-a-grove case never creates the transient dup — safe because a leaderless wilderness CD's Guid is a throwaway minted at spawn (no CharacterCommunity profile references it) and the outer LeaderIds.Count==0 keeps non-wilderness dormant protection intact. Test: Assets/Editor/Tests/CityAdminConsole/ReconcileDuplicateCommunityIdTests.cs (reflection-seeds _communities because AddCommunity Guid-dedups at add time — the real bug bypasses it by stamping). Does NOT touch save/load Sanitize wiring or AddCommunity dedup; existing live dups are healed by the next save/load Sanitize, not by this path.
6.1b CurrentMapID on trigger events
MapController.OnTriggerEnter / OnTriggerExit now update the entering/exiting character's CharacterMapTracker.CurrentMapID via SetCurrentMap(MapId) and SetCurrentMap(""). Original design assumed every map change went through a door RPC; wild maps on the same plane let players simply walk in, which requires trigger-driven tracker updates. The exit path only clears the tracker if it still matches THIS map (so a simultaneous enter/exit doesn't clobber the neighbor map's just-set value).
6.4 Wilderness Zones (MapType.Wilderness) — 2026-05-31
A wilderness zone is a MapType.Wilderness MapController backed by a WildernessZoneDef SO. It IS a regular exterior map — it participates in hibernation (when enabled), NGO spawning, and region parenting — but carries no community buildings or leaders. Key APIs:
Birth:
MapController MapRegistry.CreateWildernessMap(Vector3 pos, WildernessZoneDef def, bool devBypassLimits = false)
- Overlap is hard-blocked ALWAYS (even dev) via
WouldWildernessOverlap(pos, radius, out conflictId)(XZ bounds-intersection) — no map-controller overrides another.devBypassLimits=true(dev-mode spawn) skips ONLY the count caps below, never overlap. - Count caps: global (
MaxWildernessMapsGlobal), per-region (MaxWildernessMapsPerRegion), per-def per-region (WildernessZoneDef.MaxPerRegion). - Pre-registers an empty leaderless
CommunityData(IsWildernessMap=true,WildernessZoneDefId=def.ZoneTypeId) beforenetObj.Spawn()soMapController.EnsureCommunityDatano-ops. - Instantiates the def's
MapChunkPrefab(e.g.Wilderness_Chunk_1.prefab— must be NGO-registered), setsMapType.Wilderness,Biome=null(prevents double-accounting via VirtualResourceSupplier), attachesWildernessZoneController, callsScatterIfFresh(). - Sizes the map to
def.DefaultRadiusvia the sharedMapRegistry.ApplyWildernessMapSizing(mc, def, region)helper (server-only): resizes the rootBoxCollidertoDefaultRadius*2(preserving the prefab's authored Y),ClampBoundsToRegionto keep it inside the Region, then re-inits theTerrainCellGridto the new bounds (ground + foliage rebuild viaTerrainCellGrid.OnInitialized; empty cells = default biome ground, baked patch detail dropped) + re-bindsFarmGrowthSystem. So the def radius drives the visible zone size for every spawn path. This helper is the single source of truth — bothCreateWildernessMap(fresh/dev) ANDRespawnDynamicMapsDeferred(save/load reload) call it. The box size is server-authoritative on both paths (never aNetworkVariable); harvestables replicate as their ownNetworkObjects and terrain viaTerrainCellGridNetSync, so no extra channel is needed.
Auto-population trigger (single canonical path):
MapRegistry.Start() schedules RespawnDynamicMapsDeferred at 1.5s. For loaded worlds, RestoreState cancels and reschedules it with actual save data. For new worlds (no RestoreState), the Start() schedule fires and PopulateWildernessZones() runs at the end. Idempotent — tops up to caps.
Content scatter (WildernessZoneController.ScatterIfFresh):
- Runs once at map creation (not on every wake — avoids triggering before hibernation).
- Gates on still-wild (
comm.LeaderIds.Count == 0). Once claimed, fresh scatter stops. - Resolves
HarvestableSpawnEntry.HarvestableSoIdviaHarvestableRegistry.Get(id). - Uses
WildernessScatter.ComputeOffsets(seeded byMapId.GetHashCode()) for deterministic positions within the chunk's actual bounds. - Stamps
CommunityData.HarvestablesSeeded = trueafter first scatter.
Tree persistence across save/load (all maps, not just wilderness — 2026-06-14):
MapRegistry.CaptureState()snapshots live non-cell-coupledHarvestablecomponents on every live map intoCommunityData.PersistedHarvestablesbefore writing. Filter:- Wilderness maps persist all free nodes (content is runtime-scattered, no scene-authored trees to double).
- Non-wilderness maps (predefined home / player settlements) persist only
Harvestable.IsRuntimeSpawnednodes — scene-authoredTree.prefabchildren reload with the scene, so persisting+restoring them would double-spawn.IsRuntimeSpawnedis a server-only flag set byDevSpawnModule.TryInstantiateHarvestable(dev placement) and byMapController.RestoreSavedHarvestables(round-trip), never replicated.
- Restore is now issued for every map shape:
- Wilderness:
RespawnDynamicMapsDeferredfirst callsApplyWildernessMapSizing(after region reparent, before restore — see gotcha below), then checkscomm.HarvestablesSeeded→RestoreSavedHarvestablesfromPersistedHarvestables(else fresh scatter). Destroyed trees stay gone.
- Wilderness:
- Gotcha fixed (2026-06-14): the reload path (
RespawnDynamicMapsDeferred) previously did NOT resize the box / clamp / re-init the grid likeCreateWildernessMapdid — it instantiated the chunk prefab and left the authored (25u)BoxColliderwhileRestoreSavedHarvestablesre-spawned trees at their saved full-radius (DefaultRadius) positions → "wilderness box collider small, harvestables + terrain spawned OUTSIDE the box" on world reload. Fixed by extractingApplyWildernessMapSizingand calling it from both paths. Same omission also made fresh-scatter-on-reload produce a tiny grove andHibernate'sOverlapBox(reads_mapTrigger.bounds) miss out-of-box NPCs/trees.- Non-wilderness dynamic maps:
RespawnDynamicMapsDeferredelse-branch restores whenPersistedHarvestables.Count > 0. - Predefined maps (never
WakeUp()):GameLauncherrestores them explicitly right afterSpawnSavedBuildings(), gated onMapRegistry.GetCommunity(mc.MapId).PersistedHarvestables.Count > 0.
- Non-wilderness dynamic maps:
- Gotcha fixed: before 2026-06-14, capture+restore were gated
if (!comm.IsWildernessMap) continue;— dev-placed harvestables on the regular/home map were silently dropped on reload (survived in-session via in-memory hibernation only). Symptom: "create world, place trees, exit, reload → trees gone."
Claiming:
- Existing placer-community AB-bind path (
BuildingPlacementManager→SetOwnerCommunity+CommunityManager.SyncCommunityToMapData) works unchanged because the grove'sCommunityDatais leaderless. MapType.Wildernessis permanent. Claim state =LeaderIds.Count > 0. Once claimed, fresh scatter stops; restored trees continue.
Density helpers:
int MapRegistry.CountWildernessMaps(Region region = null)
bool MapRegistry.CanSpawnWilderness(Region region, WildernessZoneDef def)
bool MapRegistry.IsPositionInClaimedTerritory(Vector3 pos) // populate pass skips claimed territory
void MapRegistry.PopulateWildernessZones() // world-gen top-up; idempotent
CommunityData fields added:
IsWildernessMap : bool— exempts leaderless wilderness records fromIsEmptyOrphanpruning.WildernessZoneDefId : string— used by reload path to re-attachWildernessZoneController.HarvestablesSeeded : bool— prevents re-scatter on reload.PersistedHarvestables : List<HarvestableSaveData>— snapshotted tree states for save/load.
HarvestableRegistry (new, mirrors CropRegistry):
HarvestableRegistry.Initialize() // from GameLauncher.LaunchSequence (beside CropRegistry)
HarvestableRegistry.Clear() // from SaveManager.ResetForNewSession
HarvestableRegistry.Get(string id) // lazy-init; resolves any HarvestableSO by id
Shelved (unused): WildernessZone NetworkBehaviour, WildernessZoneManager, IStreamable. WildernessZoneDef repurposed as content-authoring SO.
6.2 Zone Motion (MacroSimulator step 6)
Each WildernessZone has a List<ScriptableZoneMotionStrategy>. Default StaticMotionStrategy returns Vector3.zero. MacroSimulator.TickZoneMotion(daysSinceLastTick) sums per-zone deltas, clamps by MapMinSeparation (zone-vs-zone overlap prevention), and applies. Runs on map wake-up as step 6 of SimulateCatchUp. Phase 1 is a no-op until reactive strategies (RandomDrift, AvoidWeatherFront, FollowResourceAbundance, …) ship in later phases.
6.3 Identity & Legacy
- Building Identity: Dynamic buildings generate a unique
NetworkBuildingId(GUID) on spawn. - Abandoned Cities: Cities never truly dissolve — if population drops, the city hibernates and retains its slot permanently.
- Wilderness zone spawn channel:
MapRegistry.CreateWildernessMap(pos, def)— the single entry point. Auto-called byPopulateWildernessZones().WildernessZoneManager.SpawnZoneis shelved.
7. Offset Allocation (Instanced Cells)
While dynamic open-world content uses centroid-stamping, pure instanced content (like Dungeons, specific Buildings, or isolated narrative maps) uses the WorldOffsetAllocator's physical spatial coordinates.
- Slots are separated by a constant (e.g., 40,000 units on the X-axis).
- The Allocator guarantees slot persistence via
WorldSaveManager. - Unused slots are managed via a Lazy Recycling FreeList (30-day cooldown) to prevent stale saves from warping NPCs into the void.
8. Debugging & Visualization
- MapControllerDebugUI: A UI component attached to a Canvas within the Map environment. It visualizes the real-time state of the Map (Active vs Hibernating), tracks the exact
ActivePlayerslist, and displays macro-simulation metrics fromHibernationData(such as the number of hibernated NPCs and items, and the last saved simulation time). This is crucial for verifying that the transition between live simulation and macro-simulation works perfectly off-screen.
9. NPC Interior Dwell & Hibernation (Sub-project B, 2026-06-10)
Home NPCs walk home on their schedule, sleep (bed or ground) / wander inside their interior, are stowed as DATA when no player is present, respawned at their goal on entry, and released to the world when their schedule window ends. Architecture lives in [[building-interior]] (wiki); this is the procedure + the seams.
- Two layers. Live =
BTAction_GoHome(Assets/Scripts/AI/Actions/) — wired intoBTCond_HasScheduledActivityforSleep+GoHome(replaces the oldBTAction_Idlestub). It drives the goal via the SHARED actions (rule #22):CharacterEnterBuildingAction→CharacterAction_SleepOnFurniture(bed) /CharacterAction_Sleep(ground) /BTAction_Wander(Home) →CharacterLeaveInteriorAction. Data = server-only, onBuildingInteriorRegistry. - GATE entry on
Building.SupportsInterior, NOTBuilding.HasInterior. Interiors lazy-spawn →HasInterioris false until someone first enters → using it means the NPC never enters (chicken-and-egg).SupportsInterior= authored-to-have-interior; the door action handles the actual spawn/transition. - Single-instance index (the #1 correctness rule).
Dictionary<CharacterId, NpcDwellState>onBuildingInteriorRegistry(IsStowed/MarkStowed/MarkSpawnedInInterior/MarkLive). NOT serialized —RebuildDwellIndexFromRecords()rebuilds it from every record'sStowedNpcsat the end ofRestoreState. Any new code that bulk-enumerates NPCs to serialize/despawn them MUSTcontinuepastIsStowed(id)(the 3 existing skips:MapController.SnapshotActiveNPCs/Hibernate,MapRegistry.RelocateMemberNpcSnapshots). A stowed NPC living in two places (interior record + exterior snapshot) is the cardinal bug. - Stow / Spawn / Release seams. Stow: fill
InteriorLifecycleController.CaptureToRecord(despawnEntities:true)— atomicStowedNpcs.Add→MarkStowed→Despawn(true). Spawn:BuildingInteriorRegistry.SpawnStowedNpcsInto(record, interiorMap)called fromBuildingInteriorSpawnerAFTERInteriorPersistence.Restore(furniture live) —MacroSimulator.AdvanceStowedNpccatch-up →MapController.SpawnNpcFromData(npcData, goalPos)→ queue seat action → restore-then-clear. Release: the hourlyTimeManager.OnHourChangedsweep on the registry → respawn atInteriorRecord.ExteriorDoorPositionwhenNpcDwell.ShouldRelease. SpawnNpcFromData/CaptureNpcToData(onMapController) are the reusable per-NPC NGO-correct spawn/capture (extracted fromSpawnNPCsFromSnapshot/SnapshotActiveNPCs). Spawn ordering is LOAD-BEARING: identity NetworkVars BEFORESpawn(true),ImportProfileAFTER — never reorder (half-spawn → silent client-sync break).- Position symmetry. Stow stores
npc.transform.localPositionrelative to the interiorMapControllerroot; spawn inverts withinteriorMap.transform.TransformPoint(...). Keep symmetric. - Add a future goal (store-items / place-furniture / buy-from-shop-interior / work-shift): the data layer is goal-agnostic — bounded to (a) a
DwellGoalKindvalue (+ anyDwellGoalfields), (b) aBTAction_GoHomebranch, (c) aSpawnStowedNpcsIntospawn-at-goal branch. Spec:docs/superpowers/specs/2026-06-10-npc-interior-dwell-engine-design.md. - Verify: pure helpers (
NpcDwell,BuildDwellIndex,AdvanceStowedNpc, theHibernatedNPCDataround-trip) are EditMode-covered inAssets/Editor/Tests/NPC/NpcDwellTests.cs. The live stow/spawn/release loop + the 7-case rule-#19b matrix is PlayMode-manual (see plan §Task 11).
9.1 PlayMode-hardening rules (learned the hard way, 2026-06-12 — Kevin-verified)
The compile-clean engine needed nine PlayMode rounds. The reusable lessons (each a wiki gotcha):
- Never enumerate Characters to save/despawn via
Physics.OverlapBoxalone — a sleeping NPC's collider state is altered by the sleep action, so the spatial query MISSES it and it's silently dropped (the founder-leaderless bug). Enumerate byCharacterMapTracker.CurrentMapIDand/or a transform-bounds.Containscompleteness pass, dedup by CharacterId. See [[collider-based-npc-capture-misses-sleeping-npcs]]. - A respawned NPC is a FRESH instance — its in-memory links are empty.
CharacterLocations.OwnedBuildings/ResidentRooms/homeBuildingaren't replicated or in the profile, soGetHomeBuilding()/GetAssignedBed()return null after a stow→respawn. Re-derive from the surviving replicated side (Building._ownerIds/InteriorRecord.RoomResidency) viaBuildingInteriorRegistry.TryRelinkHomeon EVERY respawn path. See [[in-memory-character-links-lost-on-respawn]]. - NPCs can't drive the player-centric door transition (owner-client-predicted
RequestTransitionServerRpc). Branch onIsInteriorSpawned: live → physically enter; not-live → stow directly as data (never spawn the interior for an NPC). See [[server-owned-npc-cannot-drive-owner-predicted-transition]]. - A naturally-finished repeating
CharacterActiondoes NOT runOnCancel, so held resources (the bed slot /EnterSleepstate) are never released. To make a sleeping NPC walk, explicitly WAKE first (BedFurniture.ReleaseSlot→ExitSleep, ground-sleep fallbackCharacter.ExitSleep) before queueing movement. - The schedule dispatcher (
BTCond_HasScheduledActivity) routes by CURRENT activity — at window-end (Sleep→Work) it stops ticking the home driver, so the "leave the interior" branch is dead code unless the dispatcher ALSO routes an interior-bound NPC through the home driver while it's outside its window. Place that route BEFORE the Wander early-out (a jobless NPC would otherwise wander INSIDE its house forever). - Stored position snapshots go stale (
record.ExteriorDoorPosition). Resolve the door LIVE at use time (FindBuildingById → BuildingInteriorDoor.ReturnWorldPosition); the snapshot is only the fallback. - Live-Character-scan UIs drop hibernated/stowed members. The AB Members tab now unions a replicated
CommunitySyncData.MembersCsv("uuid|name") roster with the live scan, rendering away members as "(away)" rows (server resolves names live → stowed-NPC record). - Save-while-inside an interior: SaveManager SKIPS interior maps, so a live interior NPC at save time is captured by nobody →
CaptureToRecordwrites a SAVE COPY on the save path (no despawn/no index-mark; the in-session release sweep skips unmarked copies).