name: dev-mode description: Host-only god-mode developer tool. F3 toggle (editor/dev), /devmode on|off in chat (release). Modules: Spawn (click-to-spawn NPCs with full configuration), Select (click-to-select Characters + IDevAction plug-in; first action assigns building ownership), Game Speed (preset + custom Time.timeScale via GameSpeedController).
Dev Mode System
1. Purpose
Dev Mode is a host-only, god-mode developer tool that layers a togglable admin panel and input-gate over the normal gameplay loop. It exists so developers — and the host of a multiplayer session when testing — can spawn NPCs, poke at state, and iterate on content without restarting the session or hacking around player-controlled input. The very first slice delivers the toggle/gate infrastructure, the chat command surface, and a single module: Dev Spawn (click-to-spawn fully configured NPCs). Future modules (freecam, item grant, teleport, time-of-day, Assign Job) plug into the same registry. Dev Mode never ships to clients and is explicitly locked behind build flags + a chat command in release builds.
2. Activation Rules
| Build / Context | Unlocked at Awake? | F3 Toggle | /devmode on|off |
Notes |
|---|---|---|---|---|
| Unity Editor | Yes | Yes | Yes | Always unlocked for developer iteration |
DEVELOPMENT_BUILD |
Yes | Yes | Yes | Unlocked automatically via #if UNITY_EDITOR || DEVELOPMENT_BUILD |
| Release build | No | No (locked) | Yes (unlocks + enables) | Host must explicitly type /devmode on once to unlock the session |
| Client (any build) | N/A | No | Logs "host-only" and no-ops | Dev Mode is server-authoritative and host-only |
Unlock vs. Enable:
- Unlock is a session-level gate. Once unlocked, the host can freely toggle.
- Enable is the live panel state.
/devmode offcallsDisable()(keeps session unlocked).Lock()is a full teardown that re-locks the session.
3. Public API
DevModeManager.Instance — singleton, host-only. Clients never see it do anything.
Properties
| Property | Type | Meaning |
|---|---|---|
IsUnlocked |
bool |
Session gate. True in editor/dev builds or after /devmode on in release. |
IsEnabled |
bool |
Current panel state. True while the dev panel is open and input is suppressed. |
SuppressPlayerInput |
static bool |
Global read used by PlayerController and PlayerInteractionDetector to suppress gameplay action inputs. Mirrors IsEnabled on the active instance. WASD movement is still allowed (god-mode flying); only right-click move, TAB target, Space attack, and E interact are blocked. |
GodModeMovementSpeed |
static const float |
WASD movement speed (units/second) used by PlayerController while dev mode is active. Default 17f. Edit the constant to tune. |
Events
| Event | Signature | When it fires |
|---|---|---|
OnDevModeChanged |
Action<bool isEnabled> |
Whenever IsEnabled flips. All dev-mode modules subscribe to react (show/hide panel, clear ghost visuals, etc.). |
Methods
| Method | Behavior |
|---|---|
Unlock() |
Sets IsUnlocked = true. Called automatically in editor/dev builds; called by /devmode on in release. |
Lock() |
Full teardown. Forces IsEnabled = false, fires OnDevModeChanged(false), sets IsUnlocked = false. Used when the host explicitly wants to re-lock the session. |
TryEnable() |
No-op on clients. On host: requires IsUnlocked, otherwise logs a warning. Sets IsEnabled = true and fires event. |
Disable() |
Sets IsEnabled = false, fires event. Keeps session unlocked — this is what /devmode off uses so the host doesn't have to re-unlock. |
TryToggle() |
Flips IsEnabled via TryEnable() / Disable(). Bound to F3. |
4. Chat Commands
Entry point: DevChatCommands.Handle(string rawInput). Called from UI_ChatBar whenever a chat message starts with /.
| Command | Effect |
|---|---|
/devmode on |
Host-only. Calls Unlock() then TryEnable(). On a client, logs a host-only warning and swallows. |
/devmode off |
Host-only. Calls Disable() (does NOT re-lock). |
Unknown /command |
Logs a warning (Unknown dev command) and swallows — never crashes, never passes through to chat. |
5. Input Gating Contract
While DevModeManager.IsEnabled == true, the two input-reading components selectively suppress gameplay actions but keep movement live:
PlayerController.Update()— reads WASD into_inputDiras usual, then skips the gameplay-action block (right-click move, TAB target, combat-command auto-assignment, Space attack) whenSuppressPlayerInputis true._isCrouchingis also forced false. The character keeps moving via WASD;Move()swaps the speed argument toDevModeManager.GodModeMovementSpeedso god-mode flying feels brisk.PlayerInteractionDetector.Update()— early-outs after the proximity refresh. Nearby-target tracking still updates (so the prompt restores instantly when you exit dev mode), but the E-key press path is blocked.CameraFollow.LateUpdate()— also readsSuppressPlayerInput(out of necessity, not as a gate). When dev mode is on, the scroll-wheel zoom drops its upper clamp (Mathf.Max(0f, ...)instead ofMathf.Clamp01) and the offset interpolation switches toMathf.LerpUnclampedso the camera can pull back without limit. Exiting dev mode re-applies the clamp;_targetZoomsnaps back to[0, 1]and the camera smoothly returns to the normal zoom range.
SuppressPlayerInput is a static on DevModeManager so hot-path Updates don't dereference the instance every frame.
6. Panel & Module Registry Pattern
DevModePanel is the panel root, loaded lazily from Resources/UI/DevModePanel on first enable. It owns a ContentRoot Transform under which each module GameObject lives as a child.
Collapse / show toggle (2026-06-13)
The panel root also carries a CollapseButton — a [-] Hide Dev Panel / [+] Show Dev Panel toggle that hides/shows ContentRoot without exiting dev mode. This is distinct from F3: F3 fully toggles dev mode (input gate + god-mode WASD flying); the collapse button only hides the UI so the host can see the scene underneath while staying in god mode.
Key invariants if you ever re-author it:
- The button MUST be a sibling of
ContentRoot(a direct child of the panel-root Canvas), never a child ofContentRoot— otherwise it vanishes with the content it's meant to bring back. It's at sibling index 0, top-left anchor(0,1), pos(10,-8), size180×30;ContentRootsits atanchoredPosition.y = -44to leave a header strip. - Wiring: two optional SerializeFields on
DevModePanel—_collapseButton(Button) +_collapseLabel(TMP_Text). Both null-tolerant (prefabs authored before this field existed still work).StartwiresonClick → ToggleContentCollapsed;OnDestroyremoves it. - Visibility math lives in
ApplyContentVisibility(devModeEnabled)→_contentRoot.SetActive(devModeEnabled && !_contentCollapsed).HandleDevModeChanged(true)resets_contentCollapsed = falseso a fresh enable always starts expanded (the collapse state is within-session UI only — never persisted, no NetworkVariable; dev mode is host-only client-local so rule #19b doesn't apply). - Label uses ASCII
[-]/[+]markers, NOT geometric ▼/▶ glyphs (not guaranteed in LiberationSans).
Registration is self-service — there is no central RegisterModule(...) API. Each module:
- Is a
MonoBehaviouron a child ofContentRootin theDevModePanelprefab. - Subscribes to
DevModeManager.OnDevModeChangedin its ownOnEnable/Start. - Unsubscribes in
OnDisable/OnDestroy. - Shows/hides its own UI in response to the event.
To add a new module:
- Create a new
MonoBehaviourinAssets/Scripts/Debug/DevMode/Modules/. - Subscribe to
DevModeManager.OnDevModeChangedand implement show/hide. - In the
DevModePanelprefab, add a child GameObject underContentRootwith your script attached. - Wire
[SerializeField]refs (dropdowns, buttons, etc.). - Unsubscribe cleanly in
OnDisable/OnDestroy.
No edit to DevModeManager or DevModePanel is required to add a module.
Click arbitration
DevModeManager exposes a single-slot click consumer: ActiveClickConsumer (MonoBehaviour), OnClickConsumerChanged (event), SetClickConsumer(x), ClearClickConsumer(x). Armed dev modules MUST claim the slot when arming and release when disarming, and MUST gate their click loop on ActiveClickConsumer == this. Subscribing to OnClickConsumerChanged lets a module auto-disarm when another claims the slot — so arming Select flips Spawn off, and vice versa.
7. Dev Spawn Module Details
DevSpawnModule — the first shipping module. Lets the host click anywhere on the Environment layer to spawn fully configured NPCs, drop ItemSO instances, or instantiate HarvestableSO resource nodes (crops / trees / ore veins). Mode is selected by a Character / Item / Harvestable sub-tab bar at the top of the Spawn panel.
Configuration UI
The Spawn panel is a stack of: SubTabBar (Character / Item / Harvestable buttons) → active sub-panel (CharacterSubPanel or ItemSubPanel or HarvestableSubPanel) → shared Label_Count + CountField + ArmedToggle. The three sub-panels are toggled via SetActive; only one is visible at a time. Count and Armed live OUTSIDE the sub-panels because they apply to every mode.
Character sub-tab fields (CharacterSubPanel):
| Field | Widget |
|---|---|
| Race | TMP Dropdown |
| Prefab | TMP Dropdown (filtered by race) |
| Personality | TMP Dropdown |
| Behavioral Trait | TMP Dropdown |
| Combat Styles | Multi-entry row list (DevSpawnRow per entry — combat style dropdown + level input) |
| Skills | Multi-entry row list (DevSpawnRow per entry — skill dropdown + level input) |
Item sub-tab fields (ItemSubPanel):
| Field | Widget |
|---|---|
| Item | TMP Dropdown — every ItemSO under Resources/Data/Item sorted by ItemName. No sentinel; the sub-tab itself selects mode, so index 0 is a real item. |
Harvestable sub-tab fields (HarvestableSubPanel):
| Field | Widget |
|---|---|
| Harvestable | TMP Dropdown — every HarvestableSO (incl. CropSO and TreeHarvestableSO subclasses) found recursively under Resources/Data/ via Resources.LoadAll<HarvestableSO>("Data"), sorted by DisplayName. Entries without a HarvestablePrefab are filtered out at load time (they can't be instantiated). Shipping entries: Crop_Wheat, Crop_Flower, AppleTreeSO, HarvestableSO_OreNode. |
Ghost-placement UX (Harvestable sub-tab only). When the user arms the Spawn toggle while the Harvestable sub-tab is active, DevSpawnModule spawns a stripped clone of the selected SO's HarvestablePrefab as a follow-cursor ghost — mirrors CropPlacementManager's UX so dev-spawned harvestables line up with the production crop placement grid. The ghost snaps to the nearest TerrainCellGrid cell of the MapController under the cursor, tints green on a valid grid cell / yellow off-grid (god-mode allows off-grid spawn). LMB confirms a single grid-snapped spawn at the ghost position then rebuilds the ghost for chain-placement; RMB cancels via disarm; ESC disarms via the global shortcut; Space+LMB shortcut still works and scatters N grid-snapped copies (each independently snapped to its containing cell). Switching off the Harvestable sub-tab, changing the dropdown selection, disarming, or closing dev mode all clear the ghost. Ghost interference is stripped via the canonical pattern: NetworkObject.enabled = false + Rigidbody.isKinematic = true + disable every Collider / NavMeshObstacle / Harvestable / HarvestableNetSync component recursively + move the whole tree onto the Ignore Raycast layer.
Shared (always visible regardless of sub-tab):
| Field | Widget |
|---|---|
| Count | TMP InputField (integer, default 1) |
| Armed | Toggle |
Click flow
- Host clicks on an
Environment-layer collider. Ray is cast from the mouse. - Dispatch:
SpawnAt(anchor)reads_activeSubTab. IfItem, the click routes toSpawnItemBatch(anchor, _items[_itemDropdown.value]). IfHarvestable, the click routes toSpawnHarvestableBatch(anchor, _harvestables[_harvestableDropdown.value]). Otherwise the character path runs. - All three paths share the same scatter formula: for
N = count, the scatter radius =4 * sqrt(N)Unity units (per project rule 32, 11 units = 1.67 m, so ~0.6 m per unit of radius). Individual offsets are random within the disk. - Character path:
SpawnManager.SpawnCharacter(...)is invoked with the configured race/prefab/personality/trait/armed flag. - Item path:
SpawnManager.SpawnItem(item, pos)is invoked per spawn. The dev-mode wrapper adds an explicitNetworkManager.Singleton.IsServercheck before the loop (clearer error than SpawnManager's internal check) and wraps each per-spawn call intry/catchso one bad item doesn't abort the batch. No combat styles / skills / personality apply on the item path. - Harvestable path: two surfaces share the same per-instance spawn helper (
TryInstantiateHarvestable):Instantiate(so.HarvestablePrefab) → Harvestable.InitializeAtStage(so, startStage: int.MaxValue, startDepleted: false, cellX: -1, cellZ: -1) → NetworkObject.Spawn(true).int.MaxValueis clamped tocrop.DaysToMatureinternally so crop-aware SOs spawn fully mature;cellX = -1means free-positioned (no cell coupling, noFarmGrowthSystem.RegisterHarvestablecall, no plow / growth-tick dependency). Both surfaces grid-snap their target position viaSnapPositionToGrid(resolvesMapController.GetMapAtPosition→TerrainCellGrid.WorldToGrid→GridToWorld, falls back to raw XZ when off-grid). Surfaces: (a)ConfirmHarvestableGhostSpawn(single, called from the LMB-confirm in the armed Update loop — position is already grid-snapped on the ghost itself), and (b)SpawnHarvestableBatch(scatter, called from Space+LMB shortcut + the legacy armed-click path — per-instance grid snap inside the scatter loop). Each iteration istry/catch-wrapped. The instance is "wild scenery you can pick or destroy immediately" — every SO subtype (HarvestableSO,CropSO,TreeHarvestableSO) takes this same path, so a dev-spawned apple tree, wheat plant, or ore vein all spawn as mature, free-positioned, immediately-harvestable nodes. - Dev-mode extras (combat styles + levels, skills + levels) are passed to
SpawnManagervia a server-onlyDictionary<ulong, PendingDevConfig>keyed on NetworkObjectId. Character path only. SpawnManager.ApplyDevExtras(...)fires post-spawn on the server, applying combat styles viaCharacterCombat.UnlockCombatStyle(style, level)and skills via the existingCharacterSkillsAPI.
Why a pending-config dict? SpawnCharacter is an async network spawn — we don't have the instance yet when we configure it. The dict is populated on the main thread before spawn and drained in the spawn callback by NetworkObjectId.
Item catalog caching. _items is loaded once in LoadCatalogs (called from Start) via Resources.LoadAll<ItemSO>("Data/Item"), sorted alphabetically by ItemName, and never mutated at click time. Adding new ItemSO assets requires re-entering play mode for them to appear in the dropdown.
Harvestable catalog caching. _harvestables is loaded once in LoadCatalogs via Resources.LoadAll<MWI.Interactables.HarvestableSO>("Data") (recursive — picks up CropSO and TreeHarvestableSO subclasses via polymorphism), filtered to drop entries with a null HarvestablePrefab, then sorted alphabetically by DisplayName. Adding new HarvestableSO assets requires re-entering play mode for them to appear in the dropdown.
Why no cell-coupling on the dev-mode harvestable path? The user-facing crop placement flow (CharacterAction_PlaceCrop → FarmGrowthSystem.SpawnHarvestableAt) plows the cell, anchors to (cellX, cellZ), and registers with FarmGrowthSystem so the daily tick advances growth. The dev-mode path intentionally skips that: dropping a crop in dev mode is for testing harvest behavior, not testing growth. Free-positioned + instant-mature gives "I want to pick this in 2 seconds" semantics — no plowing, no day-skip, no FarmGrowthSystem dependency. Use the production crop-plant flow when you need to test growth.
Spawn panel layout contract — DO NOT REGRESS. The Spawn panel uses Unity Auto Layout (nested VLG/HLG) end-to-end. Two contracts must hold or the layout collapses:
- Every direct child of
ContentRootand ofSpawnTabthat hosts anotherLayoutGroupmust carry aLayoutElement.LayoutGroupitself reportsflexibleHeight=-1and a preferred height derived from its own children — when those children also stretch via anchors, the chain returns 0 and the parent VLG redistributes the empty space unpredictably (in the original Spawn-tab regression, the topTabBarended up consuming the whole panel). The fix shipped in the prefab: add aLayoutElementwith explicitMinHeight/PreferredHeightandFlexibleHeight=0onTabBar(36) andSubTabBar(32) so they stay thin, andFlexibleHeight=1onCharacterSubPanel/ItemSubPanelso the active one takes the remaining vertical space. CharacterSubPanelandItemSubPanelmust use top-stretch anchors(0,1) → (1,1)with pivot(0.5, 1), NOT center-stretch(0,0) → (1,1).SpawnTab's VLG runs withChildControlHeight=1so it actively sets the children's heights. Center-stretch anchors withSizeDelta (0,0)make the panel report a rect height equal to the parent (chaos). Top-stretch with a realSizeDelta.yis what the VLG expects.
Adding a fourth sub-tab (e.g., "Furniture") = create a sibling *SubPanel GameObject following the ItemSubPanel / HarvestableSubPanel template (top-stretch anchors, VLG, LayoutElement with FlexibleHeight=1), add a button to SubTabBar, register it in DevSpawnModule._*SubPanel / _*SubTabButton, and extend the SpawnSubTab enum + SpawnAt dispatch. The canonical MCP-Roslyn recipe used for the 2026-05-18 Harvestable sub-tab: PrefabUtility.LoadPrefabContents → Object.Instantiate(SubTab_Item, SubTabBar) + SetSiblingIndex(SubTab_Item.GetSiblingIndex() + 1) → relabel TMP → Object.Instantiate(ItemSubPanel, SpawnTab) + sibling index + SetActive(false) → rename child Label_Item and Dropdown_Item to the new names → new SerializedObject(spawnModule) + FindProperty(...).objectReferenceValue = ... for each new SerializeField → ApplyModifiedPropertiesWithoutUndo → PrefabUtility.SaveAsPrefabAsset → PrefabUtility.UnloadPrefabContents.
7.1 Placement-aware sub-tab pattern (MANDATORY for every new dev-spawn surface that drops a world entity)
Rule: any future Spawn sub-tab that drops a world entity that exists on the TerrainCellGrid (Building, Furniture-in-world, NPC-in-formation, dynamic ore patch, weather marker, decal, …) MUST mirror the ghost-placement + grid-snap pattern enshrined by the 2026-05-18 Harvestable sub-tab. Click-and-spawn-at-raw-hit-point is only acceptable for non-spatial drops (Item: just falls; Character: pathfinds to walkable mesh). Anything that should "land on a specific cell" goes through ghost + grid snap. No exceptions for "dev tool, simple is fine" — the harvestable shipping pass proved the convention is small enough to mirror exactly.
The seven canonical pieces — replicate verbatim, not approximated:
Per-SubTab serialized state on
DevSpawnModule:private GameObject _xxxGhost;— the live ghost instance (null when inactive).private <SO type> _xxxGhostSO;— the SO the current ghost was built from (soHandleXxxDropdownChangedcan detect a swap).private Vector3 _xxxGhostSnappedPos;— last grid-snapped XZ + raycast Y. The confirm-click path reads this directly so the ghost's visible position IS the spawn position.private bool _xxxGhostIsOnGrid;+int _xxxGhostCellX, _xxxGhostCellZ;+MapController _xxxGhostMap;+TerrainCellGrid _xxxGhostGrid;— diagnostic state for the confirm log and validation.private bool _warnedNoCameraGhost, _warnedRayMissGhost;— first-time-warn-once flags so the verbose log doesn't spam.
Lifecycle hooks (every one of these must clear the ghost; only the first two also build it):
HandleArmedChanged(bool armed):if (armed && _activeSubTab == SpawnSubTab.Xxx) EnsureXxxGhost(); else ClearXxxGhost();Show<Xxx>SubTab(): if armed, callEnsureXxxGhost()so a sub-tab switch re-spawns the ghost without re-toggling Armed.Show<Other>SubTab()(every OTHER sub-tab show method): callClearXxxGhost()so leaving the sub-tab destroys the ghost.HandleXxxDropdownChanged(int): if_xxxGhost != nullcallEnsureXxxGhost()to rebuild from the new SO.HandleDevModeChanged(bool enabled): if!enabledcallClearXxxGhost()(defensive — dev-mode-off should always nuke ghosts).OnDestroy/UnwireListeners: callClearXxxGhost()defensively.
EnsureXxxGhost()builder — pattern (factor into a private method):ClearXxxGhost()first (always rebuild from scratch).- Read selected SO from dropdown + catalog list; null-check; null-check the SO's prefab.
_xxxGhostSO = so; _xxxGhost = Instantiate(so.<PrefabField>); _xxxGhost.name = "Dev<Xxx>Ghost_" + so.Id;DisableGhostInterference(_xxxGhost)— see piece 6.TintGhost(_xxxGhost, 1f, 1f, 1f, 0.7f)neutral untilUpdateXxxGhostPositionruns.- Reset
_warnedNoCameraGhost = _warnedRayMissGhost = false. - Call
UpdateXxxGhostPosition()once so the ghost appears at the cursor on the very first frame (no flicker at world origin).
UpdateXxxGhostPosition()per-frame mover — called fromUpdate()inside the Harvestable / Xxx sub-tab branch (NOT fromLateUpdate; the raycast must run before the click handler):- Bail if
_xxxGhost == null(defensive) orCamera.main == null(warn-once via_warnedNoCameraGhost). Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit, 500f, _environmentLayerMask). If miss, warn-once via_warnedRayMissGhostand leave the ghost at its last position.- Reset
_warnedRayMissGhost = falseonce a valid hit returns. - Default
finalPos = hit.point; onGrid = false; cellX = cellZ = -1;. var map = MapController.GetMapAtPosition(hit.point);— null → off-grid (yellow tint, still spawnable in god mode).- If map:
EnsureGridInitialized(map);(see piece 5), getgrid = map.GetComponent<TerrainCellGrid>(). Ifgrid.WorldToGrid(hit.point, out cellX, out cellZ)→var snapped = grid.GridToWorld(cellX, cellZ); snapped.y = hit.point.y; finalPos = snapped; onGrid = true;. - CRITICAL: preserve
hit.point.y, notgrid.GridToWorld(...).y. The grid plane Y is usually 0; the actual ground surface Y is on the raycast hit. Mixing them sinks ghosts into terrain or floats them above it. - Assign to
_xxxGhost.transform.position+ write back_xxxGhostSnappedPos / _xxxGhostIsOnGrid / _xxxGhostMap / _xxxGhostGrid / _xxxGhostCellX / _xxxGhostCellZ. - Tint: green
(0.6, 1, 0.6, 0.75)when on grid, yellow(1, 1, 0.4, 0.7)when off grid. If the new entity type has stricter validation (footprint overlap, terrain type, ownership), tint red and block the confirm. For a building dev-spawn: green on grid AND footprint clear AND on valid terrain, yellow on grid but god-mode-acceptable (footprint overlap), red off grid AND/OR off-map (still spawnable but visually flagged).
- Bail if
EnsureGridInitialized(MapController map)static helper — already in DevSpawnModule.cs. Copy-of-CropPlacementManager defensive bootstrap. Do not re-implement per-sub-tab; reuse the existing static method.DisableGhostInterference(GameObject ghost)static helper — already in DevSpawnModule.cs. When adding a new entity type, extend the existing helper to also disable the entity's specific scripts (e.g. for buildings: disableBuilding,BuildingTaskManager,BuildingLogisticsManager, everyFurniturebaked into the prefab, etc.) — otherwise the entity'sAwake → Updatepoll will hammer on an unspawned NetworkObject and NPE. The pattern: every component on the entity that does NetworkVariable / NetworkObject lookups in itsUpdateMUST be in the disable list. Don't trust "the NetworkObject is disabled so the NetVar reads will fail gracefully" — many of our components ToString-stringify the NetVar value into a log path that throws if the NGO sentinel is uninitialised.ConfirmXxxGhostSpawn()LMB-confirm + chain-rebuild path — pattern:- Read
_xxxGhostSO; null-check; validate via sharedValidate<Xxx>Spawn(so)(server-only check, prefab non-null check). - Spawn at
_xxxGhostSnappedPosvia the sharedTryInstantiate<Xxx>(so, pos, parentMap, out instance)helper — passing the cached_xxxGhostMapso the spawn re-parents under the MapController. - Compose a one-line log with the cell info (or "off-grid" / "free-positioned" / "off-grid + off-map (scene root)" depending on the path taken).
- Call
EnsureXxxGhost()again at the end so the ghost rebuilds for the next placement — this is the chain-spawn behavior that lets a dev drop multiple copies without re-arming. MirrorsCropPlacementManager's implicit chain-rebuild minus the seed-consume gate.
- Read
TryInstantiate<Xxx>(so, pos, parentMap, out instance)shared per-instance helper — MUST re-parent the spawn under theMapController'sNetworkObject:if (go.TryGetComponent<NetworkObject>(out var netObj) && !netObj.IsSpawned) { netObj.Spawn(true); // CRITICAL: re-parent under the map's NetworkObject so the spawn lives inside the // map's hibernation scope. Without this the harvestable / building / NPC sits at // scene root, is ignored by MapController.Hibernate, and never serialises when the // map sleeps. worldPositionStays:true keeps the visual fixed across the re-parent. // Mirrors FarmGrowthSystem.SpawnHarvestableAt's final block verbatim. if (parentMap != null && parentMap.TryGetComponent<NetworkObject>(out var mapNetObj)) { if (!netObj.TrySetParent(mapNetObj, worldPositionStays: true)) Debug.LogWarning($"[DevSpawn] TrySetParent failed for '{so.name}' under map '{parentMap.name}' — falling back to scene root."); } }Pass
parentMap = nullto leave the spawn at scene root (off-map fallback). The scatter path threads the map through viaSnapPositionToGrid(scatteredPos, out MapController scatterMap). The ghost-confirm path reads_xxxGhostMap(already cached byUpdateXxxGhostPosition).
The scatter path (Space+LMB) must ALSO grid-snap. Don't let the global Space+LMB shortcut fall through to raw hit-point spawning when ghost-mode applies to the sub-tab. Per-instance call to SnapPositionToGrid(scatteredPos) inside the scatter loop — each scattered copy lands on its own cell. The 2026-05-18 Harvestable scatter path is the reference implementation; clone its shape exactly.
For a future Building dev-spawn specifically — the building's BuildingSO.GridFootprintCells (Vector2Int) defines its footprint; snap to the anchor cell and verify the full footprint is clear via the same IsFootprintOccupied-style predicate that BuildingPlacementManager uses for the player flow. Don't reinvent the validation — call into the existing predicates. The ghost should show the full footprint outline (BuildingPlacementManager's _footprintOutlineLine LineRenderer pattern), tinted to match grid-occupancy validity. Dev god mode still allows spawning a building that overlaps an existing one (red tint warning, no hard block) — that's the whole point of "dev mode bypasses validation". If you find yourself adding a hard block for "out of community range" or "tier requirement not met", you're recreating the player flow — stop and check the rule.
Tracking: new placement-aware sub-tabs are listed in wiki/systems/dev-mode.md Open Questions / TODO until shipped. Mark them shipped with a change-log entry + a sentence in this section if the rules ever need extending.
Shipped (2026-05-31): Wilderness sub-tab. Spawns a MapType.Wilderness MapController via MapRegistry.CreateWildernessMap(hit, def, devBypassLimits:true). Click-to-place, NOT ghost+grid-snap — a wilderness map is a coarse area placement spanning many cells, not a single grid-cell entity, so it falls under the §7.1 carve-out (treated like the non-spatial Item/Character drops) rather than the grid-snap mandate. Dropdown sourced from WorldSettingsData.WildernessZoneDefs; each label shows the def's crop counts (AppleGrove — 8× apple_tree). devBypassLimits:true skips density caps + MinSep (dev god mode — spam-spawn for testing). UI nodes: SubTab_Wilderness button + WildernessSubPanel + Dropdown_Wilderness in DevModePanel.prefab. Mirrors the Item sub-tab plumbing (enum value, Show method, SpawnAt branch, SpawnWildernessZone helper).
Live crop composer (2026-05-31). The Wilderness sub-panel also has an editable Crops list — reuses DevSpawnRow + an AddCropButton + CropsContainer (cloned from the Combat/Skills list structure; mirrors AddCombatRow/BuildCombatList). Each "Add Crop" row = a harvestable dropdown + a count field (the row's Level field). On spawn, SpawnWildernessZone(anchor, template) reads the rows: if any exist, it builds a runtime (unsaved) WildernessZoneDef (ZoneTypeId="DevCustom") that borrows MapChunkPrefab + DefaultRadius + ContentMinSpacing from the dropdown-selected template def and swaps in the custom crop list; with no rows it spawns the selected def as-is. This lets a dev compose arbitrary mixes (apple + wheat + flower in any counts) without authoring an SO per combo. The runtime def isn't registered in WorldSettingsData, but that's fine — its trees persist by state (CommunityData.PersistedHarvestables), not by re-scatter (which needs the registered def). New fields: _wildernessCropsContainer (Transform) + _addWildernessCropButton (Button) + _wildernessCropRows (List<DevSpawnRow>).
Ghost placement + settable size + overlap (2026-05-31). The Wilderness sub-tab now uses a ghost: a flat LineRenderer circle of the chosen radius follows the cursor — green when clear + inside a Region, red when it would overlap a map or sits off-Region. LMB confirms only when green (RMB/ESC cancels; ghost chains for repeat placement). A _wildernessRadiusField (TMP_InputField) sets the zone radius → CreateWildernessMap resizes the map BoxCollider and re-inits the TerrainCellGrid to the new bounds, so ground + foliage rebuild (both subscribe to TerrainCellGrid.OnInitialized; grid.Initialize fires it) — empty cells render the default biome ground (same path MapController.WakeUp uses). Overlap is enforced ALWAYS (even dev) via MapRegistry.WouldWildernessOverlap(pos, radius, out conflictId) (XZ bounds-intersection): CreateWildernessMap hard-blocks it, the ghost tints red. devBypassLimits now skips only the count caps, never overlap — no map-controller can override another. This is the §7.1 ghost convention adapted to a coarse radius-area (circle outline, not grid-snap, since a zone spans many cells). New fields: _wildernessRadiusField + _wildernessGhost/_wildernessGhostLine/_wildernessGhostPos/_wildernessGhostValid/_wildernessGhostRadius. The spawn always builds a runtime def (radius from the field, crops from rows-or-template, chunk/biome from the dropdown template). Shift + scroll while the ghost is up resizes the radius live (proportional ~10%/notch, clamped 5–1000, written back to the radius field so spawn + ghost stay in sync) — CameraFollow zeroes scroll while Shift is held (both zoom reads) so it never double-zooms; plain scroll stays camera zoom.
Select Tab
Click-to-select for Characters + pluggable actions via the IDevAction interface.
DevSelectionModule
Attached to the SelectTab GameObject in DevModePanel.prefab. Public API:
InteractableObject SelectedInteractable { get; }— the currently selected interactable (any type), or null. Generalized from the original Character-only API.Character SelectedCharacter { get; }— back-compat convenience populated whenever the interactable resolves to a Character. ExistingIDevActionconsumers keep working unchanged.event Action<InteractableObject> OnInteractableSelectionChanged— fires on any interactable selection change.DevInspectModulesubscribes here.event Action OnSelectionChanged— back-compat event; fires whenSelectedCharacterchanges.void SetSelectedInteractable(InteractableObject)/void SetSelectedCharacter(Character)— replaces the selection. The character overload routes throughSetSelectedInteractableonce it has resolved aCharacterInteractable.void ClearSelection()— sets selection to null.bool TrySelectAtCursor(out string label)— interior raycast against_selectableLayerMask(defaultRigidBody + Furniture + Harvestable). Backs the armed Select toggle and the global Ctrl+Click shortcut.Harvestableis included so anyHarvestable(crop or wilderness) on layer index 15 — namedHarvestablein the Tags & Layers list — can be picked. Wilderness prefabs (Tree.prefab,Gatherable.prefab) currently sit on theDefaultlayer — move them ontoHarvestable(or another layer in the mask) to make them selectable too.bool TrySelectBuildingAtCursor(out string label)— building raycast against_buildingLayerMask(defaultBuilding). Backs the global Alt+Click shortcut. Bypasses the interior pick when the user explicitly wants the building shell, even when furniture or characters sit along the same ray. Special case (2026-05-08): when the resolvedInteractableObjectis aBuildingInteractable(added 2026-05-06 by the cooperative construction loop, lives on the Building's root GameObject), walk up to itsBuildingand callSetSelectedBuilding(...)instead ofSetSelectedInteractable(...). Without this branch,OnBuildingSelectionChangedwould never fire and the Inspect tab would route to noIInspectorView. Any futureInteractableObjectsubclass added directly on a Building's root GameObject must extend the same special case.
Selection is cleared automatically on SceneManager.sceneUnloaded and on DevModeManager.OnDevModeChanged(false) — prevents stale references.
Click flow (dual mask, two entry points): armed toggle (legacy) and Ctrl+Click both call TrySelectAtCursor, which raycasts the interior mask (_selectableLayerMask, default RigidBody + Furniture + Harvestable); Alt+Click calls TrySelectBuildingAtCursor, which raycasts the building mask (_buildingLayerMask, default Building). Both paths walk parents via GetComponentInParent<InteractableObject>() first, falling back to GetComponentInParent<Character>() when the collider's hierarchy has no interactable wrapper. The two-mask split exists because building shells physically enclose their interior contents — a single-pass raycast against (RigidBody | Furniture | Building) would always hit the building first, blocking selection of the chest, bed, or NPC inside. The serialized fields are [FormerlySerializedAs("_characterLayerMask")] _selectableLayerMask and _buildingLayerMask; both auto-default at runtime when left at zero, with BuildMask tolerating any layer name missing from Tags & Layers.
IDevAction
Plug-in interface for Select-tab actions. Each action is a MonoBehaviour parented under the Select tab's ActionsContainer.
public interface IDevAction
{
string Label { get; }
bool IsAvailable(DevSelectionModule sel);
void Execute(DevSelectionModule sel);
}
Action recipe:
- Create
MyDevAction : MonoBehaviour, IDevActionunderAssets/Scripts/Debug/DevMode/Modules/Actions/. - Hold
[SerializeField] DevSelectionModule _selection; [SerializeField] Button _button; [SerializeField] TMP_Text _buttonLabel;. - In
Start, wire the button click toOnButtonClickedand subscribe to_selection.OnSelectionChangedto refresh the button's interactable state viaIsAvailable. - If the action needs to consume additional clicks (e.g., pick a second target), use
DevModeManager.SetClickConsumer(this)while armed and the standard click-loop pattern. - Add the action GameObject as a child of the SelectTab's
ActionsContainerin the prefab.
DevActionAssignBuilding (first action)
Enabled when a Character is selected. On Execute, claims the click slot and waits for the next LayerMask.GetMask("Building") hit. Dispatches polymorphically:
CommercialBuilding→SetOwner(character, null)(makes character the boss).ResidentialBuilding→SetOwner(character)(sets primary owner; character also becomes resident).
ESC cancels. Another module claiming the click slot also cancels. Selection is preserved after success so further actions can chain.
Game Speed Module
DevGameSpeedModule — dev-side surface for the networked GameSpeedController. Mirrors the production UI_GameSpeedController preset buttons (0× / 1× / 2× / 4× / 8×) and adds a free-form custom-speed input field so devs can stress-test slow-mo (0.25×) or extreme fast-forward (16×+) without rebuilding.
Fields (DevModePanel.prefab → Speed tab content):
| Field | Widget | Speed |
|---|---|---|
| Pause | Button |
0× |
| Normal | Button |
1× |
| Fast | Button |
2× |
| Super Fast | Button |
4× |
| Giga | Button |
8× |
| Custom value | TMP_InputField |
any positive float (parsed with InvariantCulture so 0.5 works regardless of OS locale) |
| Apply Custom | Button |
reads the input field |
| Current Speed | TMP_Text |
read-only label, updated from OnSpeedChanged |
| Active color | Color (Inspector) |
tint applied to the active preset button |
| Inactive color | Color (Inspector) |
tint applied to the inactive preset buttons |
Flow
- Every click (preset or Apply Custom) routes through
GameSpeedController.Instance.RequestSpeedChange(speed). RequestSpeedChangehandles the server-vs-client split internally: the host writes_serverTimeScale.Value, a client routes via[Rpc(SendTo.Server)]. The dev module makes no assumption about who is calling it — the controller decides.- The module subscribes to
OnSpeedChangedinOnEnableand unsubscribes inOnDisable, so each tab activation reattaches the listener and refreshes the visual state from the currentTime.timeScale. - Fallback path: when
GameSpeedController.Instanceis null (solo scene playback with no networked DayNightCycle prefab, or the prefab hasn't spawned yet), the module writesTime.timeScaledirectly and refreshes its own visuals.RequestSpeedChangeitself does the same when!IsSpawned; the module's branch covers the case where the singleton doesn't exist at all. - Custom-value parsing is locale-safe (
InvariantCulture) so0.5,1.5,16all work on every locale. Bad input logs a warning and leaves speed unchanged. - Speed clamps at
0(no negative values, same contract asGameSpeedController.RequestSpeedChange).
Why this lives in dev mode instead of always-on UI — the production UI_GameSpeedController is part of the player HUD with a fixed 5-button vocabulary. The dev module exists so iteration can run outside that vocabulary (slow-mo for visual inspection, 16× for stress-testing tick catch-up), without polluting the player HUD or risking a player accidentally locking the session into a broken speed. Project rule #26 still holds: any animation inside the dev panel itself must use Time.unscaledDeltaTime so it keeps working at 0× / 8×.
Prefab wiring
Already wired in Assets/Resources/UI/DevModePanel.prefab (2026-05-13):
ContentRoot/TabBar/SpeedTabButton— new tab button (label "Speed"), cloned fromTimeSkipTabButton.ContentRoot/SpeedTab— content GameObject withVerticalLayoutGroup(cloned fromTimeSkipTab); children:Header,PauseButton,NormalButton,FastButton,SuperFastButton,GigaButton,Label_Custom,CustomField(TMP_InputField,ContentType.DecimalNumber, placeholder "e.g. 0.25, 1.5, 16"),ApplyCustomButton,CurrentSpeedLabel.DevGameSpeedModuleattached toSpeedTabwith all[SerializeField]refs assigned.- New
TabEntryappended toDevModePanel._tabssoDevModePanel.SwitchTabpicks it up automatically.
If you ever need to rebuild it from scratch (e.g. after a destructive prefab merge), the recipe is:
- Clone
TimeSkipTabButtonunderContentRoot/TabBar, renameSpeedTabButton, change its label to "Speed". - Clone
TimeSkipTabunderContentRoot, renameSpeedTab. Remove the clonedDevTimeSkipModuleand delete its child UI. - Attach
DevGameSpeedModuleto the newSpeedTab. - Clone
TimeSkipTab/SkipButton6× →PauseButton,NormalButton,FastButton,SuperFastButton,GigaButton,ApplyCustomButton. Update each label. - Clone
TimeSkipTab/HoursInput→CustomField(setContentType.DecimalNumber). - Clone
TimeSkipTab/StatusLabel3× →Header,Label_Custom,CurrentSpeedLabel. - Wire all
[SerializeField]refs onDevGameSpeedModule. - Append a new entry to
DevModePanel._tabswithTabButton = SpeedTabButton.Button,Content = SpeedTab.
8. Integration Points
Files touched by the dev-mode slice:
| File | Role |
|---|---|
Assets/Scripts/Debug/DevMode/DevModeManager.cs |
Singleton — state, events, input gate read |
Assets/Scripts/Debug/DevMode/DevModePanel.cs |
Panel prefab root, lazy-loads from Resources/UI/DevModePanel |
Assets/Scripts/Debug/DevMode/DevChatCommands.cs |
/devmode on|off parser; single Handle(rawInput) entry point |
Assets/Scripts/Debug/DevMode/Modules/DevSpawnModule.cs |
Click-to-spawn module UI + click handler |
Assets/Scripts/Debug/DevMode/Modules/DevSpawnRow.cs |
Multi-entry row (combat style or skill + level) |
Assets/Scripts/Debug/DevMode/Modules/DevGameSpeedModule.cs |
Game-speed tab: preset buttons + custom value, routed through GameSpeedController |
Assets/Scripts/DayNightCycle/GameSpeedController.cs |
Server-authoritative Time.timeScale + day/time sync (consumed by the dev module via RequestSpeedChange / OnSpeedChanged) |
Assets/Resources/UI/DevModePanel.prefab |
Panel prefab with ContentRoot and modules as children |
Assets/Resources/UI/DevSpawnRow.prefab |
Reusable row prefab for combat style / skill lists |
Assets/Scripts/SpawnManager.cs |
Extended SpawnCharacter signature + PendingDevConfig dict + ApplyDevExtras server path |
Assets/Scripts/Character/CharacterCombat/CharacterCombat.cs |
UnlockCombatStyle(style, level) overload used by dev mode |
Assets/Scripts/UI/UI_ChatBar.cs |
Routes /-prefixed messages to DevChatCommands.Handle |
Assets/Scripts/Character/CharacterControllers/PlayerController.cs |
Input gate — checks SuppressPlayerInput |
Assets/Scripts/Character/PlayerInteractionDetector.cs |
Input gate — early-out on SuppressPlayerInput |
9. Known Limitations
Explicit list of what this first slice does not cover. These are follow-up work, not bugs.
- Personality IS applied server-side on both networked and offline spawns (fixed post-review —
PendingDevConfig.Personalitynow carries the dev-picked value intoInitializeSpawnedCharacter). Behavioral trait replication to late-joining / non-host clients is still a known gap — the server applies the value on the spawned Character, but no dedicatedNetworkVariablereplicates it across all clients. A late-joiner sees defaults until the next full save round-trip. Follow-up slice. - Combat style live sync — only the server applies styles via
UnlockCombatStyle(style, level). Reconnecting clients rebuild state from save data. A liveNetworkList<>-based equivalent for combat styles is future work. - Skills do replicate —
CharacterSkillsalready ownsNetworkList<NetworkSkillSyncData>, so skill levels granted by dev-mode propagate correctly to all clients. No additional work needed. - Jobs are deliberately excluded from this slice. Assigning a job requires a
CommercialBuildingworkplace; it will ship as its own "Assign Job" module that lets the host target a building after spawning. - Freecam, sim-pause, invulnerability, item grant, teleport — all future modules. None of them exist yet.
- Client dev-mode — out of scope. Dev Mode is host-only today. Giving clients any of this power needs a separate design pass for trust and replication.
- Panel has no ScrollView — plain
Transformcontainers. Long combat-style / skill lists will overflow vertically. A scroll pass is deferred. - Prefab's TMP Dropdowns and InputFields use minimal default visuals — no custom arrow sprite, no custom checkmark. Visual polish is deferred; functionality is the priority for this slice.
- No visual selection indicator — the selected character is shown by label only in the panel. Follow-up slice can add a world-space outline (shader-based per rule 25) or a UI marker.
- No undo on ownership assignment — the new owner replaces the previous via
SetOwner's existing semantics. No confirmation dialog. - Character-first flow only — "click building first, then assign a character to it" is deferred.
- No multi-character selection — one at a time.
- No exclude-self filter — clicking on the host's own character selects it. Add a toggle if this becomes annoying.
- Worker/resident/job actions — deferred. Assign Building sets ownership only.
- Item selection + actions — not in this slice.
10. Extension Notes (Follow-Up Modules)
Planned modules, each to be added as a new child GameObject under DevModePanel.ContentRoot with its script subscribing to OnDevModeChanged:
| Module | Notes |
|---|---|
| Freecam | Detach camera from player, WASD + mouse look, speed slider. Must not interact with Time.timeScale. |
| NPC selection / edit | Click existing NPC -> panel showing needs, stats, inventory; edit live. |
| Item grant | Dropdown + count -> add directly to target inventory via CharacterAction. |
| Teleport | Click on map or enter coords -> teleport selected character. |
| Time-of-day slider | Drives TimeManager.CurrentTime01 directly. |
| Assign Job | Select existing NPC, then click CommercialBuilding to assign. |
All of them follow the same contract: one MonoBehaviour per tab, self-register by subscribing to DevModeManager.OnDevModeChanged, unsubscribe in OnDisable/OnDestroy, no edits to DevModeManager or DevModePanel required.
11. Character City Founding Sub-Tab (2026-05-18)
CharacterCityFoundingSubTab — 11th sub-tab on CharacterInspectorView ("Founding" in the tab bar). Dedicated playtest harness for the Plan 4 city-founding loop (community creation → AB placement → tier-up → drifter migration → join requests) without authoring full content or waiting for the natural in-game cadence. Lives at Assets/Scripts/Debug/DevMode/Inspect/SubTabs/CharacterCityFoundingSubTab.cs. Wrapped in #if UNITY_EDITOR || DEVELOPMENT_BUILD end-to-end.
Why widgets instead of text
Existing CharacterSubTabs (Identity / Stats / Skills / Needs / AI / Combat / Social / Economy / Knowledge / Inventory) all output formatted text via the abstract RenderContent(Character) → string. The City Founding tab is interactive (buttons, input fields) so the 2026-05-18 commit virtualised CharacterSubTab.Refresh and CharacterCityFoundingSubTab overrides it directly to render programmatic UGUI widgets — mirroring the BuildingConsoleManagementSubTab pattern. RenderContent returns empty (satisfies the abstract base); the inherited _content TMP_Text is intentionally unwired on this variant.
Widgets parent under a [SerializeField] private RectTransform _widgetRoot field (wired to the inner Viewport/Content RectTransform that carries the inherited VerticalLayoutGroup + ContentSizeFitter). Falls back to the script's own transform when null so the sub-tab also works on a flat (non-scrolling) panel.
Sections (top to bottom)
| Section | Visibility | What it does |
|---|---|---|
| DEV banner | always | Red banner — flags this surface as bypassing production gates |
| Refresh button | always | Re-runs RebuildAll(); useful for re-reading live state (treasury balance, member count) without triggering a mutator |
| Status header | always | Shows Character name, CurrentCommunity (name + level), Citizenship — color-coded |
| Create Community | CurrentCommunity == null |
Optional name input (default "DebugCity"). Calls CharacterCommunity.CreateCommunity(name) — same path Task_CreateCommunity uses, so the founder auto-receives the AB blueprint |
| Ambition_FoundACity | CharacterAmbition != null |
Loads Resources/Data/Ambitions/Ambition_FoundACity and calls CharacterAmbition.SetAmbition(so). Adds a "Clear Ambition" button when one is already active |
| Community readout | CurrentCommunity != null |
Diagnostic only: name / level / IsChartered / member count / leader count / primary leader / AB ref / AB treasury (CurrencyId.Default) |
| Force-Promote | CurrentCommunity != null && AB != null |
−1/+1 tier buttons. Routes through AdministrativeBuilding.DevForceChangeCommunityLevel(int delta) — bypasses Community.TryPromoteLevel's population / treasury / required-building gates |
| Grant Treasury | CurrentCommunity != null && AB != null |
Designer-supplied amount input (default 1000). Calls CommercialBuilding.DevForceCreditTreasury(int) which resolves currency via enclosing map's NativeCurrency |
| Submit Join Request | CurrentCommunity == null && Citizenship == null && ≥1 chartered AB in scene |
One row per candidate chartered AB. Click calls AdministrativeBuilding.SubmitJoinRequestServerRpc(applicantNetId) — identical to the drifter ↔ JoinRequestDesk path |
| Time | always | Live Day / Hour / Phase readout + count input + [DEV] Force NewDay button. Calls MWI.Time.TimeManager.DevForceNewDay(count), which increments CurrentDay + fires OnNewDay once per day so DrifterMigrationSystem / FarmGrowthSystem / ambition deadlines see the same event volume |
New DevForce* methods this sub-tab depends on
| Method | Located on | Behaviour |
|---|---|---|
AdministrativeBuilding.DevForceChangeCommunityLevel(int delta) |
Assets/Scripts/World/Buildings/CommercialBuildings/AdministrativeBuilding.cs |
Host-only + DevAssertHostAndDevMode-gated. Clamps to CommunityLevel enum bounds. Calls OwnerCommunity.ChangeLevel((CommunityLevel)clamped) — fires the same change log as production tier-up |
CommercialBuilding.DevForceCreditTreasury(int amount) |
Assets/Scripts/World/Buildings/CommercialBuilding.cs |
Host-only + DevAssertHostAndDevMode-gated. Resolves currency from MapController.GetMapAtPosition(transform.position).NativeCurrency (CurrencyId.Default fallback). Routes through the canonical CreditTreasury(currency, amount, reason) path |
MWI.Time.TimeManager.DevForceNewDay(int count = 1) |
Assets/Scripts/DayNightCycle/TimeManager.cs |
Host-only (NetworkManager.Singleton.IsServer) + DevMode-gated. Bumps CurrentDay by count and fires OnNewDay once per day. Phase/hour untouched |
Prefab wiring
Assets/Resources/UI/DevModePanel.prefab → ContentRoot/InspectContent/Views/CharacterInspectorView:
TabBar/Btn_CityFounding— button cloned fromBtn_Inventory, label changed to "Founding".SubTabContents/CityFounding— content GameObject cloned fromInventory.InventorySubTabcomponent replaced byCharacterCityFoundingSubTab. Inherited_contentTMP_Text removed (would render an empty band otherwise). The script's_widgetRootfield wired toViewport/ContentRectTransform.CharacterInspectorView._subTabsextended from 10 → 11 entries; the new entry is(TabButton = Btn_CityFounding's Button, Content = CityFounding GO, Tab = CharacterCityFoundingSubTab MonoBehaviour).
Authority + network model
Dev Mode is host-only (gated by DevModeManager.TryEnable IsServer check). Every mutator on this sub-tab either:
- Calls a
DevForce*method whose first line asserts host + DevMode and emits an audit log via the inheritedDevAssertHostAndDevMode, OR - Calls a public POCO method on
CharacterCommunity/Community/CharacterAmbitionwhose contract is "server-only by caller convention" — safe because dev mode runs on the server.
No new replication channels introduced. State changes flow through the existing paths (Community is a POCO, not a NetworkBehaviour; OnNewDay is a host-only event whose subscribers all run on the server; SubmitJoinRequestServerRpc already handles ApplicantNetId replication through NetworkList<JoinRequest>).
Adding a new section
Append BuildXxxSection(_bound); to RebuildAll() and implement BuildXxxSection(Character c). Use the inherited MakeRow / MakeLabel / MakeHeader / MakeButton / MakeInput helpers. Capture mutator targets into local variables before passing into lambdas (don't close over _bound directly — it can change between button-click rebuilds). End mutator callbacks with a RebuildAll() call so the readout reflects the new state immediately.
For state-mutating buttons that talk to a NetworkBehaviour, either call an existing DevForce* helper or add a new one — never poke the production path directly without the host + DevMode assertion.
12. WeatherFront Tab (2026-05-22)
DevWeatherFrontModule — fourth main tab on the DevMode panel ("Weather"). Master/detail layout:
- Master (left, ~38%) — scrollable list of every
Region.AllRegionsentry, sorted byRegionId. Each region is an expandable row showing liveWeatherFronts (active region) orWeatherFrontSnapshots (hibernated region — read-only with a[HIBERNATED]badge).[+] Add Frontbutton on the region header spawns a new front;[×]on each live front row despawns it. - Detail (right, ~62%) — when a live front is selected, an 8-input property editor (Type / LocalWindDirection / LocalWindStrength / Radius / Intensity / TemperatureModifier / RemainingLifetime / CloudCount) with live
DevForceSet*writes on every change. Plus a[Despawn]button. - World marker — magenta
LineRendererring at the selected front's radius, updates each frame fromfront.transform.position+front.Radius.Value. Auto-hides on deselect / despawn / DevMode-off.
Add Front spawn position
The [+] button computes spawn position as:
PlayerController.LocalPlayer.transform.positionif it exists ANDregion.Contains(pos)(uses the region'sBoxColliderbounds).- Else fallback:
region.transform.position + Random.insideUnitCircle * 10f(Y from the region transform), with aDebug.LogWarningnaming the region.
Reasoning: spawning at the player position is the dev's intent ("test a front passing over me"); fallback prevents the silent-despawn footgun where a front spawned outside its region's BoxCollider auto-expires on the next WeatherFront.Update bounds check.
Hibernated regions
Hibernated regions hold zero live WeatherFront GameObjects — only WeatherFrontSnapshot[] data. The dev tab shows snapshot rows greyed-out and labels them (read-only). [+] Add Front is disabled on a hibernated region (must wake first).
Important context (2026-05-22): the production Region.WakeUp(currentTime) and Region.Hibernate(currentTime) methods have zero callers in the C# codebase today — there is no player-proximity / map-activation wake system wired. A Region's _isHibernating field defaults to true (Region.cs:23) and stays true forever unless RestoreState reads it back from saved data. Smoke-test surfaced this on the first dev playtest: TestArea showed [HIBERNATED] permanently even with the player inside.
To unblock dev iteration without waiting for the upstream lifecycle to ship, the tab exposes a [Wake] / [Hibernate] toggle button on each region row (next to [+] Add Front). Clicking routes to Region.DevForceWakeUp() or Region.DevForceHibernate() — both host-only, DevAssertHostAndDevMode-gated, piggybacking the existing WakeUp(currentTime) / Hibernate(currentTime) paths verbatim. DevForceWakeUp uses _lastHibernationTime as the currentTime so the catch-up math (front fast-forward + new-front spawn for elapsed hibernation duration) produces a zero-elapsed result. DevForceHibernate reads world time from MWI.Time.TimeManager.Instance (with a 0.0 fallback when TimeManager isn't in the scene).
When the production proximity-wake system eventually lands, the dev toggle stays useful as a manual override (force a region into either state for save/load round-trip testing, NetVar replication checks across host↔client, etc.).
New DevForce* methods this tab depends on
| Method | Located on | Behaviour |
|---|---|---|
Region.DevForceSpawnFront(WeatherType, Vector3 pos, Vector2 wind, float windStrength, float radius, float intensity, float tempMod, float lifetime, int cloudCount) |
Assets/Scripts/World/MapSystem/Region.cs |
Host-only + DevAssertHostAndDevMode-gated. Rejects on hibernated region. Piggybacks SpawnRandomFront's NGO Spawn() + Initialize + _activeFronts.Add verbatim, then re-parents the spawned NetworkObject under the Region's own NetworkObject via ParentFrontUnderRegion (mirrors the canonical TryInstantiateHarvestable pattern — worldPositionStays:true preserves the visual position). Returns the spawned WeatherFront (null on failure). |
Region.DevDespawnFront(WeatherFront) |
same | Host-only + gated. Calls existing OnFrontExpired + NetworkObject.Despawn(true). Idempotent on null / already-despawned. |
Region.DevForceWakeUp() |
same | Host-only + gated. Routes through existing WakeUp(_lastHibernationTime) for zero-elapsed catch-up. Idempotent on already-awake region. |
Region.DevForceHibernate() |
same | Host-only + gated. Routes through existing Hibernate(currentTime) using TimeManager's day + day fraction. Idempotent on already-hibernated region. |
WeatherFront.DevForceSetType / SetLocalWindDirection / SetLocalWindStrength / SetRadius / SetIntensity / SetTemperatureModifier / SetRemainingLifetime / SetCloudCount |
Assets/Scripts/Weather/WeatherFront.cs |
Host-only + gated. Direct NetVar .Value writes. NGO auto-replicates to all clients. |
New CloudCount field
New NetworkVariable<int> CloudCount = new(0); on WeatherFront. Default 0. Region.SpawnRandomFront picks a per-type default via Region.DefaultCloudCountFor(WeatherType) (0/6/10/10 for Clear/Cloudy/Rain/Snow). Hibernation round-trips the value via the extended WeatherFrontSnapshot.CloudCount field. Visual layer (cloud sprites / ground shadow projectors) is deferred — this is data-only for now.
Prefab wiring
Assets/Resources/UI/DevModePanel.prefab:
ContentRoot/TabBar/WeatherFrontTabButton— clone of any sibling tab button, label "Weather".ContentRoot/WeatherFrontTab— content GameObject withHorizontalLayoutGroup; left half is aScrollViewwithVerticalLayoutGroupcontent (master); right half is aVerticalLayoutGrouppanel (detail).DevWeatherFrontModuleattached toWeatherFrontTab,[SerializeField]refs wired:_contentRoot = WeatherFrontTab,_masterScrollContent = ScrollView/Viewport/Content,_detailRoot = right panel,_refreshButton = top Refresh button.- New
TabEntry { TabButton = WeatherFrontTabButton.Button, Content = WeatherFrontTab }appended toDevModePanel._tabs.
DevWidgets static class (lifted from CharacterCityFoundingSubTab)
This sub-tab is the second consumer of the widget helpers originally written for CharacterCityFoundingSubTab. The 2026-05-22 commit lifts them into a shared static DevWidgets class at Assets/Scripts/Debug/DevMode/Widgets/DevWidgets.cs (namespace MWI.DevModeWidgets — renamed from the initial MWI.Debug.DevMode to avoid shadowing UnityEngine.Debug across the project's namespace MWI.* files). 9 factories:
| Factory | Returns | Use |
|---|---|---|
MakeRow(Transform parent) |
GameObject |
HorizontalLayoutGroup row container |
MakeHeader(string text, Transform parent) |
GameObject |
14pt bold orange section header |
MakeLabel(string text, Transform parent) |
GameObject |
12pt white rich-text label |
MakeButton(string label, Action onClick, Transform parent, float minWidth=100) |
Button |
Dark-red button with try-catch'd click handler |
MakeInput(string placeholder, string initial, Transform parent, float minWidth=100) |
TMP_InputField |
Dark grey input |
MakeFloatField(float initial, Action<float> onChanged, Transform parent, float minWidth=80) |
TMP_InputField |
Float-typed input, InvariantCulture parse, no-op on invalid |
MakeIntField(int initial, Action<int> onChanged, Transform parent, float minWidth=60) |
TMP_InputField |
Int-typed input |
MakeVector2Field(Vector2 initial, Action<Vector2> onChanged, Transform parent, float fieldMinWidth=60) |
GameObject (the row) |
Two FloatFields side-by-side → Vector2 callback |
MakeDropdown(IList<string> options, int initialIndex, Action<int> onChanged, Transform parent, float minWidth=120) |
TMP_Dropdown |
TMP dropdown with string options; builds the full Template internally (see gotcha below) |
MakeToggle(string label, bool initial, Action<bool> onChanged, Transform parent, float minWidth=80) |
Toggle |
Toggle + inline label |
Stateless — callers manage their own List<GameObject> tracking registry (used by the panel's Clear* cleanup on rebuild). CharacterCityFoundingSubTab refactor preserves the original instance-method API as thin wrappers so its call sites stay clean.
Gotcha — code-added TMP_Dropdown has no Template (fixed 2026-05-30). A TMP_Dropdown created via AddComponent (not the Editor's "Create > UI > Dropdown") has no template, so clicking it logs "The dropdown template is not assigned..." in SetupTemplate() and the list never opens. MakeDropdown now builds the standard Template → Viewport(Mask) → Content → Item(Toggle){Background, Checkmark, Label} hierarchy via BuildDropdownTemplate and wires dd.template + dd.itemText (template starts inactive; TMP clones it on Show()). This is why earlier dev UIs (e.g. the WeatherFront type picker) were worked-around as mutex buttons — that workaround is no longer necessary for new dropdowns. Any future hand-built TMP_Dropdown outside this helper must do the same.
Authority + network model
Dev Mode is host-only by DevModeManager.TryEnable construction. Every mutator either:
- Calls a
DevForce*method whose first line isif (!DevAssertHostAndDevMode("Name")) return;— inlineIsServer + DevModeManager.IsEnabledcheck + audit log line. MirrorsCommercialBuilding.DevAssertHostAndDevMode. - Reads a NetVar (
.Value) — no auth needed.
NetVar replication is via NGO's built-in server-write / client-read permissions. Late-joiner correctness: NGO replays NetworkVariable current values on connect, so a late-joining client sees the current CloudCount and every other field without extra plumbing. No OnValueChanged subscribers exist for any WeatherFront NetVar today — the future cloud visual layer will add one in WeatherFront.OnNetworkSpawn.
Adding a new property
Want to expose a new server-writable field on WeatherFront?
- Add
public NetworkVariable<T> X = new(default);onWeatherFront. - Add
public void DevForceSetX(T v) { if (!DevAssertHostAndDevMode(...)) return; X.Value = v; }wrapped in the dev-build#if. - Append a labeled input row to
DevWeatherFrontModule.BuildDetailEditorcallingDevForceSetXfrom the matchingMakeXxxFieldcallback. - If the field is hibernation-relevant, add a matching field to
WeatherFrontSnapshotand thread it throughRegion.Hibernate / WakeUpandRegion.SpawnRandomFront.
Mirrors the exact shape used for the CloudCount addition.
13. Character Abilities Sub-Tab (2026-05-29)
CharacterAbilitiesSubTab — 13th sub-tab on CharacterInspectorView ("Abilities"). Host-only fast-path to teach + equip any authored AbilitySO to the inspected character, bypassing the mentorship / book learning loop. Built to playtest abilities (e.g. the new directional Fireball) without authoring content. Lives at Assets/Scripts/Debug/DevMode/Inspect/SubTabs/CharacterAbilitiesSubTab.cs, wrapped in #if UNITY_EDITOR || DEVELOPMENT_BUILD. Widget-based (overrides Refresh, renders via DevWidgets under _widgetRoot), mirroring CharacterCityFoundingSubTab.
Sections
- DEV banner + Refresh.
- Status (character name, known-ability count).
- Grant Ability: dropdown of every
AbilitySOunderResources/Data/Abilities(recursive — picks up theSpells/subfolder — sorted by name) + a type/cost readout; Learn (no equip); Learn & Equip → Active (active-slot dropdown 1-6) for spell/physical abilities; Learn & Equip → Passive (passive-slot dropdown 1-4) shown only when the selected ability is aPassiveAbilitySO. - Active Slots (1-6) + Passive Slots readout, each occupied slot with a Clear button; Known Abilities list.
DevForce* methods (on CharacterAbilities, dev-build #if)
DevForceLearnAbility(AbilitySO), DevForceEquipActiveSlot(int, AbilitySO) (learn-if-needed + equip; rejects passives), DevForceEquipPassiveSlot(int, AbilitySO) (rejects non-passives), DevForceClearActiveSlot(int), DevForceClearPassiveSlot(int). Each gated by CharacterAbilities.DevAssertHostAndDevMode (inline IsServer + DevModeManager.Instance.IsEnabled + audit log — mirrors the CommercialBuilding shape, but inline since CharacterAbilities is its own NetworkBehaviour). Equip writes hit the existing server-only NetworkList slot sync, so they replicate to all clients (host-only dev mode = always server). Catalog cached per-rebuild via Resources.LoadAll<AbilitySO>("Data/Abilities"); new ability assets need a play-mode re-enter to appear in the dropdown.
Prefab wiring
Assets/Resources/UI/DevModePanel.prefab → ContentRoot/InspectContent/Views/CharacterInspectorView: cloned TabBar/Btn_CityFounding→Btn_Abilities + SubTabContents/CityFounding→Abilities (the proven widget template — already has Viewport/Content + no _content TMP), swapped CharacterCityFoundingSubTab→CharacterAbilitiesSubTab, wired _widgetRoot→Viewport/Content, appended the 13th _subTabs entry (TabButton/Content/Tab). Authored via idempotent Roslyn surgery (skips if an Abilities content already exists). Same recipe as the CityFounding sub-tab (§11).
Ability Progression section (2026-05-30)
The same sub-tab gained an "Ability Progression" section listing each known projectile spell (AbilityInstance.Progression != null): a Tier · Lvl · XP · unspent header, +100 XP / Set Level (input) / Set Tier (dropdown), per-attribute −/+ rows (Damage/Speed/Accuracy = 1 pt, Projectiles = 20 pt), color swatches (Red/Green/Blue/Purple/Clear) shown only at Professional tier, and Respec + NPC Auto-Spend. New host+dev-gated DevForce* on CharacterAbilities: DevForceSetAbilityLevel (respecs first if the new level can't fund the spent points), DevForceGrantAbilityXP, DevForceSetAbilityTier, DevForceSpendAbilityPoint/DevForceRefundAbilityPoint, DevForceRespecAbility, DevForceSetAbilityColor/DevForceClearAbilityColor, DevForceAutoSpendAbility (NPC random spend + Professional color roll). Each calls UpdateNetworkProgress(inst) so the change replicates via the second progression NetworkList. Model + cast effects: combat SKILL §9.J + [[combat-abilities]].
Scaling-stat readout (2026-05-30): the tab shows which stat impacts each ability (DescribeScaling) — on the selected ability in Grant + per-ability in Known Abilities. Spells: Scales with: <ScalingStat> ×<StatMultiplier> (+ Magical Power) (e.g. Fireball → Intelligence); physical abilities: equipped weapon ×<DamageMultiplier> (+ Physical Power); passives: none. Mirrors CharacterSpellCastAction.ComputeBaseSpellPower. Read-only label (no data change).
14. Character Skill Progression Sub-Tab — "Skills+" (2026-05-30)
CharacterSkillProgressionSubTab — 14th sub-tab on CharacterInspectorView ("Skills+"; distinct from the read-only "SkillsTraits" tab). Host-only mutator surface for the (already-modelled) skill XP/level/tier system — the "same for skills" half of the ability-progression request. Lives at Assets/Scripts/Debug/DevMode/Inspect/SubTabs/CharacterSkillProgressionSubTab.cs, #if UNITY_EDITOR || DEVELOPMENT_BUILD, widget-based (mirrors CharacterCityFoundingSubTab).
Sections: DEV banner + Refresh; per known skill (Leadership / Locksmith / Tailoring …) a Tier · Lvl · XP header + +100 XP / Set Level / Set Tier; an Add Skill picker (dropdown of every Resources/Data/Skills SkillSO the character lacks + [DEV] Add (Lvl 1)).
Stat-bonus readout (2026-05-30): each skill row shows what stats it gives bonuses (DescribeSkillBonuses): Grants: Lv<n> +<value> <StatToBoost>, … from SkillSO.LevelBonuses (the flat stat points the skill grants at level-up — e.g. Leadership → Charisma), plus a grey Proficiency from: <SecondaryStatType> (<ProficiencyPerPoint>/pt) line from SkillSO.StatInfluences (the inverse — which stats feed the skill's proficiency). Read-only label.
Backend: new SkillInstance.SetLevel(int) (clamp 1..MAX_LEVEL, zero current-level XP, fire OnLevelUp so RecalculateAllSkillBonuses runs) + host-gated CharacterSkills.DevForce* (DevForceAddSkill, DevForceGrantSkillXP, DevForceSetSkillLevel, DevForceSetSkillTier) resyncing the existing NetworkSkillSyncData list, so changes replicate to clients/late-joiners with no new replication channel.
Prefab wiring (DevModePanel.prefab → CharacterInspectorView, idempotent Roslyn — same recipe as §11/§13): cloned Btn_CityFounding→Btn_SkillProgression (label "Skills+") + CityFounding content→SkillProgression, swapped the component to CharacterSkillProgressionSubTab, wired _widgetRoot→Viewport/Content, appended the 14th _subTabs entry. Verified post-wire: _subTabs 13→14, last entry = (Btn_SkillProgression, SkillProgression, CharacterSkillProgressionSubTab, widgetRoot=Content).