world-system

star 1

Architecture of the World System including Map Hibernation, Spatial Offsets, and Macro-Simulation (Offline Catch-Up) for scaling the game.

Silac1995 By Silac1995 schedule Updated 6/14/2026

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 at x=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 MapController tracks active players. When PlayerCount == 0, the Map enters Hibernation.
  • Phase 1 (Pause): All NPCs on the Map are serialized into HibernatedNPCData (inside MapSaveData), extracting only pure logic (Stats, Schedule target, Inventory, Needs) and position. The actual heavy Unity GameObject (and NavMeshAgent, 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 MapController immediately calls the MacroSimulator.

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 HibernatedNPCData and 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 the DeltaTime.
  • 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 Community Leader's CharacterBlueprints knowledge. The Simulator filters the BuildingRegistry by the Leader's known UnlockedBuildingIds, checks which are missing in the community, and spawns scaffold data respecting the CommunityPriority.
  • 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 + DevTimeSkipModule dev-panel button (both pass force: true — admin override), and the bed flow + UI_BedSleepPrompt (consent path — force: false). All routes go through TimeSkipController.Instance.RequestSkip(hours, force) — server-only, returns bool.
  • Multiplayer consent gate (force: false): every connected player's Character.IsSleeping must be true before RequestSkip accepts. Reject otherwise. The auto-trigger watcher in TimeSkipController.Update (server-only) polls every connected player; when all IsSleeping == true AND at least one has PendingSkipHours > 0, it fires RequestSkip(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 UseSlot path raises Character.IsSleeping = true; the bed prompt UI sets Character.PendingSkipHours = chosenHours via Character.SetPendingSkipHours(int) (server-only — clients use a ServerRpc wrapper). On Character.ExitSleep, PendingSkipHours resets to 0 so a stale value can't auto-trigger the next session.
  • Per-hour loop (server coroutine): validate → freeze Time.timeScale → resolve activeMap + players → pre-skip checkpoint save (waits for SaveManager.CurrentState == Idle before continuing)HibernateForSkip → for each hour (AdvanceOneHourSimulateOneHourOnSkipHourTick → abort check) → WakeUpFromSkipExitSleep for each player → restore Time.timeScale. The _pendingSkipWake flag on the active MapController makes WakeUp() 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 strand IsSkipping = true.
  • Day-boundary gating: any new step that integrates over daysPassed must run inside if (crossedDayBoundary) in SimulateOneHour, 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 via ConfigureNavMesh(bool)) → Character.NetworkIsSleeping = truePlayerController.Update() early-out. IsSleeping and PendingSkipHours are 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.Exit on the bed flow; dev paths rely solely on the pre-skip checkpoint.
  • Virtual buildings caveat: MapController.Hibernate skips any Building whose NetworkObject is shared with the MapController (the inherited NetworkBehaviour.NetworkObject getter walks up via GetComponentInParent). Despawning that NetworkObject would destroy the MapController itself — was the root cause of an early WakeUp MissingReferenceException. 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. CharacterMapTransitionAction fades screen via ScreenFadeManager, then warps.
  • Cross-NavMesh teleports (building interiors at y=5000) must use CharacterMovement.ForceWarp(), not Warp(). ForceWarp disables the NavMeshAgent, teleports via transform.position, then re-enables the agent after 2 frames.
  • The CharacterMapTracker is invoked via RequestTransitionServerRpc (FixedString128Bytes for map IDs). The server resolves interior positions via ResolveInteriorPosition() (lazy-spawns if needed) and sends WarpClientRpc back if the position changed (ClientNetworkTransform is owner-authoritative).
  • The Server updates the CurrentMapID and notifies source/destination MapControllers via MapController.NotifyPlayerTransition() for hibernation handoff.
  • See building-system SKILL.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 hypothetical WorldManager.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.Value to 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 + ISaveable placed in the scene. Holds a BiomeDefinition, BiomeClimateProfile, and a BoxCollider trigger. [RequireComponent(NetworkObject)] so scene-placed Regions auto-acquire a NetworkObject on scene load — required so child MapController/WildernessZone NetworkObjects can be NGO-parented via TrySetParent. Auto-discovers scene-child MapControllers and WildernessZones in Awake via GetComponentsInChildren. Runtime-spawned children register via RegisterMap(MapController) / RegisterWildernessZone(WildernessZone) — unregister counterparts on despawn. Clients learn their region via the CharacterMapTracker.CurrentRegionId NetworkVariable.
  • Wilderness zone: A MapType.Wilderness MapController — not a separate class. Content is authored in a WildernessZoneDef SO and scattered by WildernessZoneController (server-only MonoBehaviour on the same GameObject). Backed by an unclaimed empty-leaderless CommunityData (IsWildernessMap=true). See §6.4 below for the full API and lifecycle.
  • WeatherFront: Atmospheric region spawned by a parent Region on a timer (unchanged — formerly owned by BiomeRegion).

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 MapController inside a Region in the scene.
  • (b) Building placementBuildingPlacementManager is region-aware: if the placement position falls inside a Region with no enclosing map, it calls MapRegistry.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 using MapController.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 by MapRegistry.CreateMapAtPosition right after NetworkObject.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 by BuildingPlacementManager.RegisterBuildingWithMap when a placement falls NEAR (within WorldSettingsData.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.MapMinSeparation is therefore a soft threshold (routes to expansion), NOT a hard rejection. The old CreateMapAtPosition rejection-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-center MapMinSeparation. MapRegistry.WouldNewMapOverlap(pos, out conflictId) is the placement gate (reuses WouldWildernessOverlap with the MapController-prefab half-extent + gap); wired into both founder paths (BTAction_PursueAmbition.DrivePlaceBuilding + TryFindFoundingPosition) and the player admin-console founder branch in BuildingPlacementManager. Claim exemption (critical): WouldNewMapOverlap short-circuits to false when MapController.GetMapAtPosition(pos) != null (inside a map → claiming an unclaimed MapController by placing an AB, or joining) or FindNearestMapInRegion(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 same GetMapAtPosition(pos) resolves hostMapForGrid in 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: CreateMapAtPosition re-sizes the new map via the pure MapBoundsFitter.ComputeNonOverlappingBounds (per-face directional shrink, keeps the AB inside) applied through MapController.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 path MapController.ExpandBoundsToInclude was 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 pure MapBoundsFitter.ClampExpansionAgainstSiblings(current, desired, region, siblings, gap) — grows toward the new building but stops MapMinGap short 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; ExpandBoundsToInclude gathers siblings via FindObjectsByType<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/JobYields on instantiation so MapController.SpawnVirtualBuildings short-circuits (no VirtualResourceSupplier_* children on small player outposts). Newly spawned maps netObj.TrySetParent under their target Region — valid because Region is a NetworkBehaviour.
  • Known limitation: BoxCollider.center/size are plain fields, not NetworkVariables — 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:

  1. Instantiates _mapControllerPrefab at SpawnPosition
  2. Clears Biome/JobYields (wild-map semantics)
  3. netObj.Spawn()
  4. Re-establishes Region parenting via Region.GetRegionAtPosition + RegisterMap + TrySetParent
  5. Calls mapController.SpawnSavedBuildings() to bring back the buildings saved in CommunityData.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) before netObj.Spawn() so MapController.EnsureCommunityData no-ops.
  • Instantiates the def's MapChunkPrefab (e.g. Wilderness_Chunk_1.prefab — must be NGO-registered), sets MapType.Wilderness, Biome=null (prevents double-accounting via VirtualResourceSupplier), attaches WildernessZoneController, calls ScatterIfFresh().
  • Sizes the map to def.DefaultRadius via the shared MapRegistry.ApplyWildernessMapSizing(mc, def, region) helper (server-only): resizes the root BoxCollider to DefaultRadius*2 (preserving the prefab's authored Y), ClampBoundsToRegion to keep it inside the Region, then re-inits the TerrainCellGrid to the new bounds (ground + foliage rebuild via TerrainCellGrid.OnInitialized; empty cells = default biome ground, baked patch detail dropped) + re-binds FarmGrowthSystem. So the def radius drives the visible zone size for every spawn path. This helper is the single source of truth — both CreateWildernessMap (fresh/dev) AND RespawnDynamicMapsDeferred (save/load reload) call it. The box size is server-authoritative on both paths (never a NetworkVariable); harvestables replicate as their own NetworkObjects and terrain via TerrainCellGridNetSync, 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.HarvestableSoId via HarvestableRegistry.Get(id).
  • Uses WildernessScatter.ComputeOffsets (seeded by MapId.GetHashCode()) for deterministic positions within the chunk's actual bounds.
  • Stamps CommunityData.HarvestablesSeeded = true after first scatter.

Tree persistence across save/load (all maps, not just wilderness — 2026-06-14):

  • MapRegistry.CaptureState() snapshots live non-cell-coupled Harvestable components on every live map into CommunityData.PersistedHarvestables before 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.IsRuntimeSpawned nodes — scene-authored Tree.prefab children reload with the scene, so persisting+restoring them would double-spawn. IsRuntimeSpawned is a server-only flag set by DevSpawnModule.TryInstantiateHarvestable (dev placement) and by MapController.RestoreSavedHarvestables (round-trip), never replicated.
  • Restore is now issued for every map shape:
    • Wilderness: RespawnDynamicMapsDeferred first calls ApplyWildernessMapSizing (after region reparent, before restore — see gotcha below), then checks comm.HarvestablesSeededRestoreSavedHarvestables from PersistedHarvestables (else fresh scatter). Destroyed trees stay gone.
  • Gotcha fixed (2026-06-14): the reload path (RespawnDynamicMapsDeferred) previously did NOT resize the box / clamp / re-init the grid like CreateWildernessMap did — it instantiated the chunk prefab and left the authored (25u) BoxCollider while RestoreSavedHarvestables re-spawned trees at their saved full-radius (DefaultRadius) positions → "wilderness box collider small, harvestables + terrain spawned OUTSIDE the box" on world reload. Fixed by extracting ApplyWildernessMapSizing and calling it from both paths. Same omission also made fresh-scatter-on-reload produce a tiny grove and Hibernate's OverlapBox (reads _mapTrigger.bounds) miss out-of-box NPCs/trees.
    • Non-wilderness dynamic maps: RespawnDynamicMapsDeferred else-branch restores when PersistedHarvestables.Count > 0.
    • Predefined maps (never WakeUp()): GameLauncher restores them explicitly right after SpawnSavedBuildings(), gated on MapRegistry.GetCommunity(mc.MapId).PersistedHarvestables.Count > 0.
  • 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 (BuildingPlacementManagerSetOwnerCommunity + CommunityManager.SyncCommunityToMapData) works unchanged because the grove's CommunityData is leaderless.
  • MapType.Wilderness is 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 from IsEmptyOrphan pruning.
  • WildernessZoneDefId : string — used by reload path to re-attach WildernessZoneController.
  • 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 by PopulateWildernessZones(). WildernessZoneManager.SpawnZone is 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 ActivePlayers list, and displays macro-simulation metrics from HibernationData (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 into BTCond_HasScheduledActivity for Sleep+GoHome (replaces the old BTAction_Idle stub). It drives the goal via the SHARED actions (rule #22): CharacterEnterBuildingActionCharacterAction_SleepOnFurniture(bed) / CharacterAction_Sleep(ground) / BTAction_Wander(Home) → CharacterLeaveInteriorAction. Data = server-only, on BuildingInteriorRegistry.
  • GATE entry on Building.SupportsInterior, NOT Building.HasInterior. Interiors lazy-spawn → HasInterior is 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> on BuildingInteriorRegistry (IsStowed/MarkStowed/MarkSpawnedInInterior/MarkLive). NOT serialized — RebuildDwellIndexFromRecords() rebuilds it from every record's StowedNpcs at the end of RestoreState. Any new code that bulk-enumerates NPCs to serialize/despawn them MUST continue past IsStowed(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) — atomic StowedNpcs.AddMarkStowedDespawn(true). Spawn: BuildingInteriorRegistry.SpawnStowedNpcsInto(record, interiorMap) called from BuildingInteriorSpawner AFTER InteriorPersistence.Restore (furniture live) — MacroSimulator.AdvanceStowedNpc catch-up → MapController.SpawnNpcFromData(npcData, goalPos) → queue seat action → restore-then-clear. Release: the hourly TimeManager.OnHourChanged sweep on the registry → respawn at InteriorRecord.ExteriorDoorPosition when NpcDwell.ShouldRelease.
  • SpawnNpcFromData / CaptureNpcToData (on MapController) are the reusable per-NPC NGO-correct spawn/capture (extracted from SpawnNPCsFromSnapshot/SnapshotActiveNPCs). Spawn ordering is LOAD-BEARING: identity NetworkVars BEFORE Spawn(true), ImportProfile AFTER — never reorder (half-spawn → silent client-sync break).
  • Position symmetry. Stow stores npc.transform.localPosition relative to the interior MapController root; spawn inverts with interiorMap.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 DwellGoalKind value (+ any DwellGoal fields), (b) a BTAction_GoHome branch, (c) a SpawnStowedNpcsInto spawn-at-goal branch. Spec: docs/superpowers/specs/2026-06-10-npc-interior-dwell-engine-design.md.
  • Verify: pure helpers (NpcDwell, BuildDwellIndex, AdvanceStowedNpc, the HibernatedNPCData round-trip) are EditMode-covered in Assets/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.OverlapBox alone — 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 by CharacterMapTracker.CurrentMapID and/or a transform-bounds.Contains completeness 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/homeBuilding aren't replicated or in the profile, so GetHomeBuilding()/GetAssignedBed() return null after a stow→respawn. Re-derive from the surviving replicated side (Building._ownerIds / InteriorRecord.RoomResidency) via BuildingInteriorRegistry.TryRelinkHome on 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 on IsInteriorSpawned: 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 CharacterAction does NOT run OnCancel, so held resources (the bed slot / EnterSleep state) are never released. To make a sleeping NPC walk, explicitly WAKE first (BedFurniture.ReleaseSlotExitSleep, ground-sleep fallback Character.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 → CaptureToRecord writes a SAVE COPY on the save path (no despawn/no index-mark; the in-session release sweep skips unmarked copies).
Install via CLI
npx skills add https://github.com/Silac1995/My-World-Isekai-Unity --skill world-system
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator