Document history — every version from v6 to v29
MONDAINS.COM Koster’s Ecological Economy System
MONDAINS.COM
Koster’s Ecological Economy
A Closed-Loop Resource System for RunUO 2.2
Document version: v17 — Adds EcoRabbit (9.3.3), extends the weather/season growth-multiplier coupling from grass-only to EcoOrchardNode/EcoTreeNode/EcoFarmPlot (9.3.2), ships all four AI-Town follow-ups L.9 had flagged as unbuilt - theft/mugging, region resource stress, IDOC-collapse memory, repeat-customer tallying, and conversation-summary folding (L.11/L.12) - and adds Appendix M documenting the SimPC housing-fairness investigation and toolset that fixed a real population imbalance leaving three of six Eco-Chain regions with no shopper traffic.
Document version: v19 — Adds Appendix O: the Wood chain's Carpentry trader (closing the loop Woodcutting opened), a full new Ore chain (EcoOreVein, Mining, Blacksmithing), DragonTownHooks.cs (dragon activity feeding AI-Town gossip), and the mood-drift / wealth-anomaly findings from the 2026-08-09 pass. Table C.3 updated with every job shipped since v16 (Orcharding, Woodcutting, PieBaking, CiderMaking, Carpentry, Mining, Blacksmithing).
Document version: v22 — Adds Appendix P: the 2026-08-11 orc raid-pacing and militia-response pass. Region-health-aware raid softening, an outcome-driven raid interval, and personality-driven raid participation close the “raiding too much, too uncoupled from the world” gap; a new temporary militia-response system (EcoOrcMilitia.cs / EcoOrcMilitiaService.cs) gives a sustained-raid region a visible, self-resolving town-defense event. Both status pages and the command gump now surface all of it.
Document version: v23 — Adds Appendix Q: NPC relationships, goal-driven behavior, and goal-weighted activity/commitment tracking (Phase 2B / 3A / 3B Tier 1–2), consolidated here for the first time from standalone session notes — includes an honest status read on what is confirmed live versus merely code-complete. Adds Appendix R: the 2026-08-18 belief-driven militia trigger (Layer 2) — militia now also respond to a region's collective protective goals, fearful mood, and raid-memory saturation, not just raid count — plus the RunUO 2.2/Mono C# 4.0 compatibility fixes found while shipping it. Adds Appendices S through V: AI-Town flavor NPCs & autonomous quest runners, the Invasion system, SimPC Wilderness Conquest Nodes, and the Eco-Chain roaming dragon — four already-shipped, actively-used systems a full DEV-folder audit found with no prose documentation anywhere in this document. Appendix D (Known Issues) gains four new open items surfaced by the same audit.
Document version: v24 — Adds Appendix X: the 2026-08-19 home-bed and nightly pub-visit pass — every UltimateNPC now auto-provisions and sleeps in a real EcoFarmBed each night and makes one evening trip to a shared pub, and the BuyHouse goal (Appendix Q.3) can finally complete instead of only weighting activity scores. Documents the Trammel-vs-Felucca tavern lookup bug found and fixed along the way, and the resulting teleport-based, single-destination redesign. Appends Appendix Y: a Visual Reference section rendering “The Living World of Sosaria” infographic (2026-08-19) as images at the end of the document, each panel mapped back to the appendix it summarizes.
Document version: v25 — Adds Appendix Z: the 2026-08-19 real-house pass that follows on from Appendix X’s bed/pub work — UltimateNPCs now bootstrap a real small house near home (reusing SimPC’s own house-deed catalog) instead of a bare cot in an open field, with an automatic communal fallback that shares an existing neighbor’s house when a crowded cluster has no room for a house of its own. Documents three separate bugs found and fixed during same-day testing — an NPC unable to path back out through its own house door, an over-eager rescue check that then trapped it in a loop right outside that same door, and a wander-anchor bug that caused the same loop for always-working activity types like Shepherding — plus a new Real Houses panel on the staff status page for watching it live. Status: deployed to the dev shard and under active real-time testing as of this revision; not yet confirmed stable unattended.
Document version: v26 — Adds Appendix AA: the 2026-08-23 overnight pass — SimPC-as-Player Move 1 (reputation/recognition extended to SimPC, including a real CS0103 production bug found and fixed the same night and a disclosed gap in this project's own compile-verification methodology), the SimPCEcoShoppingActivity trader-gap fix (five fully-built trader types — Mining, Fishing, Fishmongering, Blacksmithing, Carpentry — were invisible to SimPC's shopping loop despite working purchase paths existing for all five), the StaffWebStatus.cs Orchard/Wood panel's missing Carpenter count, and a full IDOCCommandGump audit against every live CommandSystem.Register call that added 49 previously-undocumented commands. Also fixes Appendix D's stale UltimateNPCActivityKey enum listing (was missing Carpentry and Fishmongering) and Table C.3 (now documents all 15 jobs, adding the previously-undocumented Fishing and Fishmongering rows). Adds Z.11: three more housing/bed bugs found and fixed, and MaxBedsPerHouse raised from 5 to 15. Status: authored and shipped unattended overnight per owner instruction, after the owner confirmed the shard's DEV build was moved fully to production — not yet reviewed by the owner.
Document version: v27 - Adds Appendix AB: the 2026-08-24/25 pass, the largest behavioural change to UltimateNPC since the class was written. The bed and housing subsystem of Appendices X and Z is deliberately RETIRED behind a single flag; jobs are now permanent; the !UsesDayNightCycle branch is removed so all 65 NPCs follow one identical daily cycle for the first time; and the pub that unification produces is turned into a real social venue with relationship-building and cross-region gossip, reported daily to Discord by a new chronicle. Documents eight bugs found and fixed - three of them pre-existing and load-bearing, including a farmer freeze that had silently frozen NPCs out of the entire day/night cycle - plus a full IDOCCommandGump rebuild. Appendices X and Z are marked superseded, C.3's heading corrected, and Appendix D's open items reconciled. Opens with a verification failure: a full day of the previous session's work was described in detail in its own completion log and had never reached disk at all.
Document version: v29 — Adds Appendix AD: the 2026-08-31/09-01 pass — the Yew ecology collapse traced past a grass-fill retreatment to EcoRabbit’s uncounted grazing load and closed with [EcoNodeFill; a week-long lag investigation that root-caused the three status/web-export timers slipping under their own load, a SimPCHouseDecorationService reflection/geometry spike, and a real TryEvaluateGoals() unit-mismatch bug (TotalMinutes < 1.0 instead of the documented TotalHours < 6.0) — then honestly tracked what that fix did and did not explain, closing with PROD’s severe hitches identified as the blocking World Save and a still-unexplained 68-second DEV freeze filed open; a stale PROD AI-NPC API key found and rotated; WinHeart/FindLove and ProtectFamily/PrepareForWar wired to real advancement paths against Appendix AC.11’s open goal-advancement gap; and six web-export files corrected from a stale “Daily auto-export” label to their real 15/60-minute cadence, widened to 3 hours.
Previously, v16 — Adds AI-Town (Appendix L): a Stanford "Generative Agents" architecture for UltimateNPC - a persistent, importance-scored memory stream, periodic reflection that synthesizes real experience into a standing belief, and one-line persona injection that makes those beliefs shape actual conversation. Confirmed live with real, individualized results - shepherds tracking specific repeat customers by name and changing behavior because of it, not generic dialogue.
*Based on Raph Koster’s 1997 original design & Zachary Simpson’s 1999 economic analysis* Implemented June 2026 — v6: Farming, Raiders & Full Dashboards Live
The Reference
What the system is, why the 1997 original failed, how this one solves it, and every file, timer and command in it. Start here.
Introduction
In 1997, Raph Koster — lead designer of Ultima Online — designed a closed-loop ecological resource system for the game’s world. Sheep would eat grass, grass would regrow, wolves would hunt sheep, and players could only harvest the overflow of a naturally self-regulating food chain. It was the most sophisticated player economy ever attempted in an MMO at the time. It was also cut before launch.
This document records the design philosophy behind that system, why it failed in 1997, the solution we implemented to fix that failure, and a full technical reference for the RunUO 2.2 implementation running on mondains.com.
The system has grown well past the original two-tier proof of concept. The full Eco-Chain now runs a farmed grain economy and grass → sheep → wolves → a roaming apex dragon → the regional resource pool → three downstream trade NPCs (weaver, tanner, cook) → the player economy, plus a parallel raider branch (orcs) that steals from the pool and tramples the fields rather than hunting outright. Every tier is self-managing — population, harvest, and trade all run on their own timers with no spawner items required — and a GM can see the entire live state of any region from a single in-game dashboard or the staff web page. Section 9 is the current roadmap and status of every tier; section 10 covers the tooling built to observe it all.
Ultima Online went on to suffer catastrophic economic problems — gold inflation, item overproduction, no meaningful player-to-player trade. Zachary Simpson’s 1999 paper “The In-game Economics of Ultima Online” catalogued every failure. Most of them trace back to the removal of Koster’s ecology.
The Original Failure: UO’s Economy
2.1What Went Wrong
Zachary Simpson’s 1999 academic paper documented six core failures in Ultima Online’s launch economy:
| Problem | Cause & Effect |
|---|---|
| Gold inflation | Gold entered the economy through monster drops with no meaningful sink. Prices rose indefinitely. |
| Skill overproduction | Skills improved by doing — players mass-produced cheap goods to train, flooding the market. |
| No player trade | Players sold to NPC vendors rather than each other. Player-to-player markets never developed. |
| Hoarding | Durable items never degraded. Players accumulated vast stockpiles with no pressure to spend. |
| NPC price failure | NPC vendors bought and sold at fixed prices, creating arbitrage loops that drained the economy. |
| No scarcity | Resources were effectively unlimited — respawn was fast and global, so demand was never meaningful. |
The root cause of most of these failures was the removal of Koster’s ecological resource system. Without it, resources became effectively infinite, supply always outpaced demand, and gold had nowhere meaningful to go.
Koster’s Original Vision
3.1The Closed-Loop Chain
Koster’s design was a self-regulating biological food chain. Each trophic level (grass → sheep → wolf) feeds the next, and players harvest only the overflow from that self-regulating system:
Grass → Sheep → Wolf → Pool → Player Player (nodes) (herbivore) (predator) (overflow (harvest) (harvest) tap)
Every link in the chain was governed by four system variables Koster defined for each creature and resource:
| Variable | Description |
|---|---|
| Max amount | The ceiling — how much of a resource can exist at a location. Prevents infinite accumulation. |
| Regrowth rate | How fast a resource replenishes per tick. Determines recovery speed after grazing. |
| Stomach size | How much an animal can eat before it is full. Controls how long between feeding events. |
| Bite size | How much is consumed per eating action. Determines impact on the resource per feeding. |
| Minimum amount | The exhaustion floor — below this, the resource is depleted and animals move on. |
| Aversion | Predator/prey relationship tags. Wolves hunt sheep; sheep flee from wolves. |
3.2Why It Failed in 1997
Koster’s ecology was cut before UO launched because of one insurmountable problem: infinite player demand. Players harvested resources at a rate the ecological chain could never match. A thousand players shearing sheep could drain the wool supply in minutes — far faster than sheep could eat grass to replenish it.
Players consumed directly from the ecological pool. As demand scaled with the player population, the ecology collapsed. More players = faster collapse, no recovery. The system had no buffer between ecology and demand.
The Solution: The Overflow Tap Pattern
4.1The Core Insight
The fix is architectural. Instead of players consuming from the ecological pool, the ecology fills a separate Regional Resource Pool — the “overflow tap” — and players consume from that pool. The ecology and player demand are permanently decoupled:
ECOLOGY LOOP → REGIONAL → PLAYER DEMAND Grass grows → Sheep RESOURCE EcoShearingTool → eat → Wool produced POOL Wool wool from pool Wolves hunt → Sheep pool — fills EcoBoneKnife → die → Meat produced as sheep eat meat & hides from (Self-regulating, grass Meat pool Empty pool closed loop)* pool — fills = wait for as wolves kill ecology to sheep Hides recover.* pool — fills on kills & carving *(Buffer between ecology & demand)*
4.2Harvest Pressure
Each regional pool tracks harvest pressure (0–100%). Pressure rises each time a player finds the pool empty. High pressure applies a soft yield penalty as a warning before full drought — a gradient of consequences rather than a binary on/off state:
| Pool state | Player experience |
|---|---|
| Pool ≥ 60% full | Full yield (3–5 units). Green message: ecology thriving. |
| Pool 25–60% full | Reduced yield (1–3 units). Yellow message: under stress. |
| Pool <** 25% full** | Minimal yield (1 unit). Red message: struggling. |
| Pool empty | Zero yield. Drought message. Must wait for ecology to recover. |
| High pressure >50% | Ecology hint shown: pressure %, yield penalty, recovery advice. |
System Architecture
5.1File Overview
The original nine files below (Scripts/Mobiles/) are the core grass → sheep → wolf loop and haven’t changed since v1. Everything built since lives alongside them, grouped by the layer it belongs to — see section 9 for design and 9.4.1/9.5.1/10.1 for setup detail on each.
Core loop:
| File | Purpose |
|---|---|
| EcoGrassNode.cs | The producer. Invisible grass patches with tick-based regrowth. Base of the food chain. |
| RegionalResourcePool.cs | The overflow tap. Per-region resource buffers that the ecology fills and players drain. |
| EcoSheep.cs | Herbivore. Hunts grass, produces wool into pool. Notifies wolf on death. |
| EcoWolf.cs | Predator. Hunts EcoSheep when hungry, deposits meat/hides into pool on kill. |
| EcoShearingTool.cs | Player tool. Targets EcoSheep, draws wool from the regional pool. |
| EcoBoneKnife.cs | Player tool. Targets EcoWolf (20-tile range), draws meat & hides from the regional pool. |
| EcoAnimalAI.cs | AI extensions: EcoAnimalAI (hunger base), EcoSheepAI, EcoPredatorAI. Compiled but unused by current creatures (OnThink pattern used instead). |
| EcologySystem.cs | Central manager. Ticks grass, tracks metrics, auto-respawns creatures, persists pool state. |
| EcoCommands.cs | GM commands: [EcoStatus, [EcoSpawn, [EcoSpawnWolf, [EcoNodeFill, and more. |
Apex predator (9.1):
| File | Purpose |
|---|---|
| EcoDragon.cs | Apex predator. Single roaming world boss, hunts wolves and sheep across every active region. |
| claude_DragonHeart.cs | Rare reagent dropped by the dragon on death. |
| EcoDragonCommands.cs | GM commands: [DragonStatus, [DragonSpawn, [DragonKill. |
World seeding (9.1):
| File | Purpose |
|---|---|
| EcoWorldSeeder.cs | One-shot seeding commands: [EcoSeedWorld, [EcoSeedRegion, [EcoUnseedAll, [EcoSeedShepherds, and more. |
Downstream trades — phase 1, section 9.4 (✓ shipped):
| File | Purpose |
|---|---|
| EcoShepherdNPC.cs | First downstream consumer. Withdraws a small capped amount from the pool on a timer, sells accumulated stock. |
| EcoTraderNPC.cs | Abstract base for the three trade NPCs below — shared harvest → convert → sell engine. |
| EcoWeaverTrader.cs | Wool → Cloth, 2:1. |
| EcoTannerTrader.cs | Hides → Leather, 2:1. |
| EcoCookTrader.cs | Meat → Sausages, 3:1. |
| EcoMillerTrader.cs | Grain → Bread, 3:1. Added to close the loop once EcoFarmerNPC/EcoFarmPlot started depositing Grain into the pool with nothing drawing it back down. |
| EcoTraderCommands.cs | GM commands: [EcoTraderTick, [EcoTraderStatus, [EcoSeedTraders, [EcoUnseedTraders. |
Farming — section 9.3.1 (✓ shipped):
| File | Purpose |
|---|---|
| EcoFarmPlot.cs | Placeable field. Five growth stages on its own timer, tending, harvest to Grain pool or direct player crop items. |
| EcoFarmerNPC.cs | SmartNPC subclass. Daily commute to a claimed plot, tend/harvest, sell stock via gump. Stuck-recovery with teleport fallback. |
| EcoFarmSeeder.cs | GM commands: [EcoSeedFarms, [EcoUnseedFarms, [EcoListFarmRegions, [EcoFarmTick, [EcoPlotTick, [EcoFarmStatus, [EcoFarmLocate. |
Raiders — phase 2, section 9.5 (✓ shipped):
| File | Purpose |
|---|---|
| EcoOrc.cs | Per-region raider. Competes with wolves for sheep, and separately raids the pool / grass nodes directly (theft, not predation). Built on the shard’s base Orc.cs. |
| EcoOrcCommands.cs | GM commands: [EcoSpawnOrc, [EcoOrcTick, [EcoOrcStatus. |
Observability — section 10 (✓ shipped):
| File | Purpose |
|---|---|
| EcoDashboardGump.cs | In-game GM page: [EcoDashboard. Every region’s full state in one navigable gump. |
| StaffWebStatus.cs | Staff-only web page mirroring the dashboard’s data plus general server vitals, IDOC, deaths, and error logs. |
Retired: EcoOrcWarband.cs / EcoOrcRaider.cs / EcoOrcWarbandCommands.cs — an earlier, separate roaming-band orc system that predated the Eco-Chain roadmap and didn’t interact with the resource pool at all. Removed in favour of EcoOrc.cs (9.5) to stop two independent orc systems spawning against each other; UnifiedWebStatus.cs’s map markers and “Orc Raiders” panel were repointed at EcoOrc accordingly.
5.2The Timer System
Two timers drive the system:
EcologyTimer (every 30 seconds)
Calls EcologyTick() on every EcoGrassNode — regrows CurrentAmount by RegrowthRate up to MaxAmount.
Resets per-tick grass consumption and wool/predation counters.
Updates AverageGrassFill metric per region.
PopulationTimer (every 5 minutes)
Counts sheep and wolves per region against target ratios.
Spawns up to 2 sheep per tick if population below target AND grass fill >= 30%.
Spawns up to 1 wolf per tick if wolves below target AND enough sheep to sustain them.
Logs ecosystem warnings (overgrazing, predator overload, no predators).
No spawner items required — the ecology manages its own population.
3 sheep per grass node, 1 wolf per 5 sheep. A 5×5 node grid (25 nodes) will sustain ~75 sheep and ~15 wolves over time. Drought prevention: if grass fill drops below 30%, sheep respawn pauses until the land recovers.
5.3Sheep Hunger Flow
EcoSheep.OnThink() fires every ~0.4 seconds and runs this state machine:
OnThink() fires every ~0.4 seconds (PassiveSpeed)
- Sync body graphic: woolly (0xCF) or shorn (0xDF) based on NextWoolTime
- Skip ecology if tamed or summoned (pets are exempt)
- Decrement hunger every 8 ticks (~3.2 seconds)
- If hunger == 0: apply starvation damage every 80 ticks (~32 seconds)
- If in combat OR hunger > HungerThreshold (25): clear target, return
- Validate cached grass node target (discard if deleted or exhausted)
- FindNearest non-exhausted EcoGrassNode within 30 tiles
- If within EatingRadius (2 tiles): a. Consume(BiteSize=15) from node b. Restore hunger: min(80, hunger + consumed) c. Anchor Home to this node’s location (first eat only) d. Accumulate m_GrassConsumedSinceWool += consumed e. Every 20 grass units: deposit 1 wool into RegionalResourcePool — deposits to GRASS NODE’s region, not the sheep’s region key
- If not close enough: GetDirectionTo(node) + Move(direction)
5.4Wolf Hunt Flow
EcoWolf.OnThink() uses the same hunger pattern, but targets EcoSheep instead of grass nodes:
OnThink() fires every ~0.4 seconds (PassiveSpeed)
- Skip ecology if tamed or summoned (pets are exempt)
- Decrement hunger every 15 ticks (~6 seconds) — wolves go longer between meals
- If hunger == 0: apply starvation damage every 100 ticks
- If hunger > HungerThreshold (40): clear prey target, return (wander freely)
- If Combatant is already an EcoSheep: return (let AI_Melee handle the fight)
- If in combat with anything else (e.g. player): return (self-defence takes priority)
- Validate cached sheep target (discard if dead/deleted/tamed)
- EcologySystem.FindNearestSheep() within 50 tiles — searches ALL sheep across all regions, skips tamed pets
- Set Combatant = targetSheep — AI_Melee handles the chase, pathfinding, and combat automatically On EcoSheep death (EcoSheep.OnDeath): - Checks Aggressors list for any EcoWolf - Calls wolf.OnPreyKilled(this) → Restores wolf hunger by MealRestored (70 units) → EcologySystem.OnSheepKilledByPredator() fires → Deposits 5 Meat + 3 Hides into the SHEEP’s region pool
5.5Home Range & Territory
Without home range limits, creatures drift indefinitely and eventually starve when they wander beyond their food source’s search radius. RunUO’s BaseCreature has a built-in Home + RangeHome system that the base AI wander code uses to guide creatures back if they drift too far:
| Creature | RangeHome / Behaviour |
|---|---|
| EcoSheep | 20 tiles. Home set to spawn location. On first successful eat, Home is re-anchored to that grass node’s location (fixes sheep placed without a spawn command). |
| EcoWolf | 40 tiles. Home set to spawn location. Wolves patrol a wider territory, reflecting natural pack ranging behaviour. |
| Auto-spawn | Both PopulationTimer and [EcoSpawn / [EcoSpawnWolf commands set Home = spawn location immediately after MoveToWorld(). |
Changing RegionKey via [Props on a sheep now correctly moves it between registry buckets (the setter calls DeregisterSheep + RegisterSheep). Previously, using [Add EcoSheep without a region parameter created sheep in the “default” bucket that never appeared in [EcoStatus for the intended region.
File Reference
6.1EcoGrassNode.cs
The producer at the base of the food chain. Each node is a hidden Item (ItemID 1 — null art, zero collision) placed at a world location.
| Property / Method | Description |
|---|---|
| MaxAmount | Maximum grass units this patch can hold. Default: 100. |
| CurrentAmount | Current available units. Clamped to [0, MaxAmount]. |
| RegrowthRate | Units restored per ecology tick (every 30 s). Default: 5. |
| MinAmount | Exhaustion floor. Below this, IsExhausted = true. Default: 10. |
| RegionKey | String tag linking this node to its RegionalResourcePool. |
| IsExhausted | True when CurrentAmount ≤ MinAmount. Animals path away. |
| FillRatio | CurrentAmount / MaxAmount as 0.0–1.0. Used for health reporting. |
| EcologyTick() | Called by EcologyTimer. Grows by RegrowthRate. Returns amount grown. |
| Consume(biteSize, animal) | Herbivore takes a bite. Fires OnGrassConsumed. Returns 0 if exhausted. |
| FindNearest(map, pt, r) | Static. Returns nearest non-exhausted node within r tiles on same map. |
| AllNodes (static) | Global list. EcologySystem iterates every tick. Auto-maintained on spawn/delete. |
6.2RegionalResourcePool.cs
The overflow tap. One pool per region key, tracking seven resource types. The ecology deposits; players withdraw via tools.
| Resource | Filled by |
|---|---|
| Wool | EcoSheep eating grass (20 grass = 1 wool deposited) |
| Meat | EcoWolf kills EcoSheep (5 meat per kill) / EcoWolf OnCarve (3 meat) |
| Hides | EcoWolf kills EcoSheep (3 hides per kill) / OnCarve of wolf corpse (2 hides) |
| Grain | Future: EcoFarmer NPC or player harvesting |
| Fruit | Future: EcoOrchard nodes |
| Wood | Future: EcoTree nodes |
| Ore | Future: EcoMineNode |
Default pool capacities: Wool 500, Meat 300, Hides 300, Grain 400, Fruit 200, Wood 600, Ore 400. The Withdraw() method applies a harvest pressure penalty: at 100% pressure, yield is halved. HarvestPressure is serialised and persists across server restarts.
6.3EcoSheep.cs
Subclass of BaseCreature + ICarvable. Uses AIType.AI_Animal with all ecological logic in OnThink().
| Constant | Value / Effect |
|---|---|
| StomachSize | 80 — maximum hunger units |
| BiteSize | 15 — grass consumed per eating action |
| HungerThreshold | 25 — starts seeking food below this level |
| HungerDecayInterval | 8 ticks (~3.2 seconds per hunger point) |
| GrassPerWool | 20 — grass units consumed to produce 1 wool in the pool |
| FoodSearchRadius | 30 tiles — how far to search for grass nodes |
| EatingRadius | 2 tiles — must be this close to eat |
| RangeHome | 20 tiles — how far the sheep may wander from its Home anchor |
| StarveTickInterval | 80 ticks (~32 seconds between starvation damage) |
| StarveDamage | 5 HP per starvation event |
Key behaviours: ICarvable.Carve() (shearing a live sheep) draws wool from the regional pool. OnCarve() (butchering the corpse) deposits meat and hides to the pool. OnDeath() checks the Aggressors list and notifies any EcoWolf that made the kill via wolf.OnPreyKilled(). RegionKey setter updates the sheep’s registry bucket so [Props changes take effect immediately.
6.4EcoWolf.cs
Subclass of BaseCreature. Uses AIType.AI_Melee with FightMode.Aggressor (won’t randomly aggress players) and all predator logic in OnThink(). The wolf does not hurt players unless attacked.
| Constant | Value / Effect |
|---|---|
| StomachSize | 120 — wolves can eat more per meal than sheep |
| HungerThreshold | 40 — starts hunting below this level |
| HungerDecayInterval | 15 ticks (~6 seconds per hunger point) — slower decay than sheep |
| MealRestored | 70 — hunger units restored per successful sheep kill |
| FoodSearchRadius | 50 tiles — wolves range further than sheep graze |
| RangeHome | 40 tiles — wider territory than sheep |
| StarveTickInterval | 100 ticks |
| StarveDamage | 5 HP per starvation event |
On a successful kill: EcoSheep.OnDeath() calls wolf.OnPreyKilled(prey), which restores hunger and fires EcologySystem.OnSheepKilledByPredator(). That method deposits 5 Meat + 3 Hides into the sheep’s region pool (not the wolf’s), so a wolf that hunts in Britain fills Britain’s pool regardless of where the wolf was originally tagged. OnCarve() of a wolf corpse adds 3 Meat + 2 Hides to the wolf’s own region pool.
6.5EcoShearingTool.cs
Blessed item (scissors graphic, soft green hue, 50 uses). Player double-clicks → target cursor (2-tile range) → targets an EcoSheep → draws wool from the sheep’s regional pool.
| Pool state | Yield & message |
|---|---|
| ≥ 60% full | 3–5 wool, green: “The local flock is thriving.” |
| 25–60% full | 1–3 wool, yellow: “The local flock is under some stress.” |
| <** 25% full** | 1 wool, red: “The local flock is struggling.” |
| Empty | 0 wool, red drought message. Ecology hint shown. |
| Skill bonus | +1 wool at Tailoring or Herding ≥ 80. +1 on Felucca. |
6.6EcoBoneKnife.cs
Blessed item (skinning knife graphic, earthy brown hue, 30 uses). Player double-clicks → target cursor (20-tile range) → targets a live EcoWolf → draws meat and hides from that wolf’s regional pool. The 20-tile range means players can target a wolf safely from a distance without engaging in combat.
| Pool state | Yield & message |
|---|---|
| ≥ 60% full | 3–5 meat + 2–3 hides, green: “The local pack has been hunting well.” |
| 25–60% full | 1–3 meat + 1–2 hides, yellow: “Under some pressure.” |
| <** 25% full** | 1 meat + 1 hide, red: “The pack is struggling to find prey.” |
| Empty | Zero yield. Ecology hint. Wait for wolves to hunt more sheep. |
| Skill bonus | +1 of each at Tracking or Herding ≥ 80. +1 of each on Felucca. |
| Skill gain | Trains Tracking (0–50) and Herding (0–25). |
The bone knife teaches the food chain directly: if wolves have been killing sheep, the pool fills and the knife yields well. If the sheep population has collapsed (too many wolves), the pool empties and the knife returns the drought message — the player sees the consequence of predator overload.
6.7EcologySystem.cs
The central manager. Exists as a single hidden Item on the Internal map. Auto-created by EcologySystemLoader on WorldLoad. Persists all RegionalResourcePool states in its serialised data.
Static event receivers allow any component to report without a hard reference:
OnGrassConsumed(node, amount, consumer) — called by EcoGrassNode.Consume()
OnWoolProduced(sheep, units, regionKey) — called by EcoSheep.OnThink()
OnSheepKilledByPredator(wolf, sheep, regionKey) — called by EcoWolf.OnPreyKilled()
RegisterSheep / DeregisterSheep — called on EcoSheep construction, death, deletion, deserialization
RegisterWolf / DeregisterWolf — called on EcoWolf construction, death, deletion, deserialization
FindNearestSheep(map, origin, radius) — used by EcoWolf.OnThink() to acquire prey
GM Command Reference
Core loop:
| Command | Description |
|---|---|
| [EcoStatus | Full health report: grass fill, sheep & wolf counts, pool levels, predation stats, pressure. |
| [EcoTick | Force an immediate ecology tick — regrows all grass nodes now. |
| [EcoPool | Shows current resource pool levels for all regions with ASCII fill bars. |
| [EcoPressure | Shows harvest pressure per region: green < 30%, yellow 30–70%, red > 70%. |
| [EcoNodeFill <region> | Places a 5×5 grid of EcoGrassNodes (10-tile spacing) at your location. |
| [EcoNode <region> | Places a single EcoGrassNode at your feet. |
| [EcoSpawn <region>** ****<****n****>** | Spawns N EcoSheep (max 50) in a 5-tile radius at your location, tagged to region. Sets Home automatically. |
| [EcoSpawnWolf <region>** ****<****n****>** | Spawns N EcoWolves (max 20) in a 10-tile radius. Warns if count > 5. Sets Home automatically. |
| [EcoFill <region> | Fills all resource pools for a region to max. Use for testing tools. |
| [EcoReset <region> | Drains all pools to zero. Simulates a drought for testing. |
| [EcoPurge [region] | Culls eco mobiles down to configured caps. All regions if omitted. |
| [EcoCaps | Shows the configured population caps per region and globally. |
World seeding:
| Command | Description |
|---|---|
| [EcoSeedWorld | Seeds every configured region at once (nodes + sheep + wolves). |
| [EcoSeedRegion <region> | Seeds a single named region. |
| [EcoSeedMap <mapName> | Seeds every region entry targeting one facet. |
| [EcoUnseedRegion <region> | Removes all eco nodes + creatures from one region. |
| [EcoUnseedAll | Removes every eco node and creature from the world. |
| [EcoListRegions | Lists all configured seed regions and their targets. |
| [EcoSeedShepherds [mapName] | Places one EcoShepherdNPC per active region, skipping regions that already have one. |
| [EcoUnseedShepherds [region] | Removes shepherds — all, or one region. |
Farming (9.3.1):
| Command | Description |
|---|---|
| [EcoSeedFarms [mapName] | Places plots + farmers for every configured region. Defaults to Felucca. |
| [EcoUnseedFarms [region] | Removes plots + farmers — all, or one region. |
| [EcoListFarmRegions | Lists configured farm seed entries. |
| [EcoFarmTick [region] | Forces an immediate tend/harvest/plant pulse, ignoring TendInterval. |
| [EcoPlotTick [region] | Forces a plot forward one growth stage immediately, ignoring StageDuration. |
| [EcoFarmStatus [region] | Read-only overview: plot/farmer counts by state, stock and harvest totals. |
| [EcoFarmLocate <name> | Finds a specific farmer by partial name — exact map, coordinates, state, stuck-pulse count. |
Apex dragon:
| Command | Description |
|---|---|
| [DragonStatus | Location, state, hunger, kill count. |
| [DragonSpawn [region] | Force-spawns a dragon now (Administrator). Random active region if omitted. |
| [DragonKill | Deletes the current dragon immediately (Administrator). |
Downstream trades (9.4):
| Command | Description |
|---|---|
| [EcoTraderTick | Targets a weaver/tanner/cook and forces an immediate harvest+convert cycle. |
| [EcoTraderStatus | Lists every trader with region, stock, and next harvest time. |
| [EcoSeedTraders [mapName] | Places a weaver, tanner, and cook per active region, anchored on that region’s shepherd. Skips regions with no shepherd yet. |
| [EcoUnseedTraders [region] | Removes trader NPCs — all, or one region. |
| [EcoShepherdTick | Forces a shepherd’s harvest cycle immediately, bypassing HarvestInterval. |
Raiders (9.5):
| Command | Description |
|---|---|
| [EcoSpawnOrc <region>** [n]** | Spawns N EcoOrcs (max 20) tagged to a region. Default 2. |
| [EcoOrcTick | Targets an orc and forces an immediate raid, bypassing RaidInterval. |
| [EcoOrcStatus | Lists every orc with region, hunger, raid count, and last raid result. |
Observability (section 10):
| Command | Description |
|---|---|
| [EcoDashboard | Opens the in-game GM dashboard — every region’s full state in one page. |
Always use [EcoSpawn, [EcoSpawnWolf, and [EcoSpawnOrc rather than [Add EcoSheep/EcoWolf/EcoOrc — these commands set the Home anchor and region key correctly. If you used [Add, change RegionKey via [Props] and the creature will re-register in the correct bucket immediately.
Installation & Setup
8.1Files
Drop all 9 .cs files into **RunUO**. No other folder changes are required. RunUO auto-compiles on startup. You should see [EcologySystem] Initialised. in the server console.
8.2Setting up a Region
//
- Stand in an open area (e.g. near Britain farms) [EcoNodeFill britain // Places 5x5 grid = 25 grass nodes //
- Seed the herbivore population [EcoSpawn britain 15 // Spawn 15 sheep (tagged to britain, Home set) //
- Seed the predator population (optional for testing) [EcoSpawnWolf britain 3 // Spawn 3 wolves (1 per 5 sheep = healthy ratio) //
- Wait 2-5 minutes, then check the ecology is running [EcoStatus // Should show grass consumed, wool produced //
- Test the player harvest tools [EcoFill britain // Optionally fill pools immediately for testing [Add EcoShearingTool // Test shearing: target any EcoSheep [Add EcoBoneKnife // Test hunting harvest: target any EcoWolf //
- Let it run — population auto-manages every 5 minutes // No spawner items needed
8.3Recommended Setup Ratios
| What | Recommended amount |
|---|---|
| Grass nodes | 25 per region (5×5 grid, [EcoNodeFill) — covers a medium-sized farming area |
| Sheep | 75 per region (3× node count) — the system will maintain this population automatically |
| Wolves | 15 per region (1 per 5 sheep) — the system will maintain this automatically |
| Player tools | EcoShearingTool (50 uses) for wool; EcoBoneKnife (30 uses) for meat/hides |
| Regions | One region per major city/area. Use the city name as the key: britain, vesper, trinsic, etc. |
The Eco-Chain — Roadmap
Everything from here on is one system with one name: the Eco-Chain. It is the same closed-loop pattern proven by grass → sheep → wolf, extended in both directions — backward to what governs the producer layer, and forward to what happens to wool, meat, and hides after they leave the pool — plus one parallel branch (raiders) that isn’t a straight predator link at all.
9.1Completed — The Full Chain Is Live
Every tier of the Eco-Chain diagrammed in 9.2 is now built and running, including both the forward extension (9.4) and the raider branch (9.5):
| ✓ | EcoGrassNode — producer / grass layer |
|---|---|
| ✓ | EcoSheep — herbivore / wool production |
| ✓ | EcoWolf — predator / meat + hides production |
| ✓ | EcoDragon — apex predator. Roams every active region, hunts wolves and sheep, drops a big one-time windfall + a rare DragonHeart on death |
| ✓ | RegionalResourcePool — overflow tap for all resource types |
| ✓ | EcoShearingTool — player harvest of wool |
| ✓ | EcoBoneKnife — player harvest of meat + hides |
| ✓ | EcoShepherdNPC — the first downstream consumer. Withdraws a small, capped amount from the pool on a timer and sells the accumulated stock to players like an ordinary vendor |
| ✓ | EcoWeaverTrader / EcoTannerTrader / EcoCookTrader — phase 1 of the forward extension (9.4). Draw Wool/Hides/Meat from the pool, convert to Cloth/Leather/Sausages, sell at a harvest-pressure-driven price |
| ✓ | EcoOrc — phase 2, the raider branch (9.5). Competes with wolves for sheep, and separately raids the pool / grass nodes directly |
| ✓ | EcoFarmPlot / EcoFarmerNPC — the farming system (9.3.1). A parallel producer with its own crop economy (Wheat/Cotton), deposits into the pool’s Grain bucket. Seeded across all six active regions |
| ✓ | EcoRabbit — second, smaller herbivore below sheep (9.3.3). Self-managed population via breeding rather than a new EcologySystem tier; small Meat/Hides deposit on carve |
| ✓ | EcologySystem — auto-respawn, population management, health reporting |
| ✓ | Home range / territory — creatures stay in their area |
| ✓ | EcoDashboardGump + StaffWebStatus.cs — full observability, in-game and on the web (section 10) |
Still open: non-animal resource nodes (9.6), and the wider Simpson economy layer (9.7). EcoRabbit shipped this pass - see 9.3.3. The EcoGrassNode regrowth hookup that was open as of v12 is done - see 9.3.2 and Appendix I - and that same weather/season coupling was extended to EcoOrchardNode, EcoTreeNode, and EcoFarmPlot this pass too (previously grass-only), also covered in 9.3.2. See 9.8 for the current build order.
9.2The Full Chain
Two things to notice: the chain is no longer purely a straight predator line (orcs are a raider branch, not another animal link — see 9.5), and it no longer dead-ends at the harvest tools (9.4 closes the loop the original design left open).
9.3Backward — Before the Grass
Grass nodes are already Koster’s producer layer, so “backward” means what governs the producer, not a new organism above it in the chain.
- Season & weather coupling — foundation built, grass hookup still open. WorldConditions.cs now exposes IsRaining/IsNight/CurrentSeason as real queryable state (previously Weather.cs and the day/night clock were one-way, client-visual only, with no season concept at all). EcoGrassNode.RegrowthRate itself is not yet wired to it — see 9.3.2 for the full implementation reference and the one-line hookup that remains.
9.3.2 Weather & Season — Implementation Reference
Weather and day/night already existed but were one-way: Weather.cs pushed rain/snow packets straight to nearby clients, and the in-game clock only ever drove screen darkness via LightCycle. Neither exposed queryable state to any other system. Season did not exist at all. This pass adds a query layer on top without changing how weather or day/night actually behave.
Files (Scripts/Custom/, alongside the rest):
| File | Purpose |
|---|---|
| Weather.cs (patched) | Two public accessors added — Active and IsExtremeTemperature — previously private fields with no way to query current rain state from outside the class. Nothing else in the file changed. |
| WorldSeasonAnchor.cs | Tiny invisible persisted singleton item (ItemID 1, not visible/movable) that anchors the season clock in real time — default 7 real days per season, a 28-day in-fiction "year" — so season survives server restarts. Found-or-created once at boot. |
| WorldConditions.cs | The query API: IsRaining(map, loc) / Temperature(map, loc) read Weather.cs’s moving rain cells; IsNight(map, loc) / NightFactor(map, loc) read the same Clock LightCycle itself uses; CurrentSeason / SeasonProgress derive from the anchor. GetGrowthMultiplier(map, loc) combines season + rain into the one number Eco-Chain would consume. |
Commands:
| Command | Description |
|---|---|
| [WorldConditions | Shows current rain/night/season state and the combined growth multiplier at your location. |
| [WorldSeasonSet <season> | Forces the current season immediately by shifting the anchor. |
| [WorldSeasonLength <days> | Sets how many real days a season lasts — default 7, useful set to 1 while testing. |
Done as of this pass: EcoGrassNode.EcologyTick() now multiplies RegrowthRate by WorldConditions.GetGrowthMultiplier(Map, Location) every 30-second tick - Spring 1.35x, Summer 1.10x, Autumn 0.85x, Winter 0.35x, +1.25x on top if it’s raining right there. Floored at 1 unit/tick regardless of how harsh conditions get, deliberately - see Appendix I for why that floor matters. SimPC behaviour coupling (farmers avoiding storms, thieves prowling after dark) is still open, unrelated to this hookup.
Extended this pass to the non-grass producers. Grass was the only node type actually reading the multiplier as of the 2026-08-05 handoff - EcoOrchardNode, EcoTreeNode, and EcoFarmPlot all had flat regrowth regardless of season or rain. All three now call the same WorldConditions.GetGrowthMultiplier(Map, Location) at each node/plot’s own location and apply it to their own regrowth/growth-stage-advancement rate, with the same floor-at-1 protection Appendix I already documents for grass. No changes to WorldConditions.cs itself were needed - this was purely wiring the existing query API into three more producer files.
EcoFarmerNPC — ✓ shipped, and grew well past the original one-line pitch. See 9.3.1.
EcoRabbit — ✓ shipped. See 9.3.3 for what was actually built - a self-managed, breeding-based population rather than a new EcologySystem tier.
9.3.3 EcoRabbit — Implementation Reference
Built this pass as the second, smaller herbivore the roadmap called for - deliberately scoped as pure “texture” rather than a new tier: no trade, predator, or pool logic downstream depends on rabbit output existing, so nothing needed sizing around it.
| File | Purpose |
|---|---|
| EcoRabbit.cs (new) | BaseCreature, “a rabbit corpse”. Movement/food-seeking reuses EcoSheep’s proven PathFollower-based approach (an EcoRabbitMovementAI : AnimalAI) rather than a hand-rolled heuristic. Rabbits flee at a higher HP threshold than sheep and never fight back. |
Design choices, and why:
Self-managed population, not EcologySystem. Wiring a texture species into EcologySystem.cs’s central population tick would mean every future animal type touching the same core file. Instead EcoRabbit keeps its own private per-region registry and a soft cap (18 per region, 150 global) entirely local to the class.
Breeding instead of a spawn timer. A well-fed, untamed rabbit checks once every 3 real minutes for a 35% chance to produce a kit nearby, gated by the regional/global caps - fully self-sustaining after the initial seed, with no GM command or spawner needed to keep the population topped up.
Modest economic footprint. OnCarve deposits a small Meat + Hides amount into RegionalResourcePool - the same mechanism EcoSheep.OnCarve uses, scaled down since nothing downstream is sized around rabbit output. No loot pack (GenerateLoot is a deliberate no-op) - not meant to be a farming target the way EcoSheep/EcoWolf are.
Hunger/food-seeking mirrors EcoSheep’s v1.6 approach, with rabbit-appropriate tuning: smaller stomach, smaller bites, and a tighter home range than sheep - grazes more often, costs the grass less per visit.
Placed initially via the same world-seeding pass that seeds the rest of a region’s ecology (or a manual per-region spawn), after which breeding takes over with no further GM action needed.
9.3.1 Farming — Implementation Reference
the original pitch above was “a producer-side mirror of EcoShepherdNPC… tends a cluster of grass nodes.” What actually got built in a separate session is bigger than that: a full standalone farming system with real plantable plots, five growth stages, and its own crop item economy (Wheat/Cotton), rather than a farmer that just buffs existing grass nodes. It still plugs into the same chain — harvests deposit into RegionalResourcePool’s Grain bucket, exactly where the pool has reserved a slot for this since v1 — but it’s a parallel producer, not a modifier on the existing one.
Files (Scripts/Customs/, alongside EcoWorldSeeder):
| File | Purpose |
|---|---|
| EcoFarmPlot.cs | Placeable field. Five growth stages (Empty → Planted → Sprouting → Growing → Ripe) on its own independent Timer — grows whether anyone’s nearby or not. Tending speeds the current stage and boosts eventual yield. Ripe plots can be harvested to an EcoFarmerNPC’s stock, or double-clicked directly by a player for real WheatSheaf/Cotton items. |
| EcoFarmerNPC.cs | Built on SmartNPC — AI persona/chat plus an actual daily job. Sleeps at home, commutes to its claimed plot at dawn (PathFollower, same technique as the quest-runner NPCs), tends/harvests through the day, commutes home at night. Sells accumulated stock via its own gump. |
| EcoFarmSeeder.cs | GM seeding commands — see below. |
Daily routine (EcoFarmerNPC): Sleeping → TravelingToWork → Working → TravelingHome, gated on the same 21:00–06:00 night window SmartNPC.IsNightTime() already uses elsewhere. A farmer auto-claims the nearest unclaimed EcoFarmPlot within WorkSearchRadius (default 20 tiles) of home the first time it wakes — no manual pairing needed unless PlotLocation is set explicitly via [props.
Stuck-recovery (fixed): travel now tracks the closest distance achieved toward its destination each pulse, the same safety-valve technique EcoSheep.cs uses for its own food-seeking travel — PathFollower handles real routing, this only exists for a genuinely unreachable destination. After StuckPulseThreshold pulses (default 10) with no progress, the farmer teleports to a safe spot near the destination rather than staying stranded mid-commute with no recovery path. Serialised so a GM-tuned threshold survives a restart.
Commands:
| Command | Description |
|---|---|
| [EcoSeedFarms [mapName] | Places plots + farmers for every configured region. Defaults to Felucca. |
| [EcoUnseedFarms [region] | Removes plots + farmers. Omit region for everything. |
| [EcoListFarmRegions | Lists configured farm seed entries. |
| [EcoFarmTick [region] | Forces an immediate tend/harvest/plant pulse on one or all farmers, ignoring TendInterval. |
| [EcoPlotTick [region] | Forces one or all plots forward one growth stage immediately, ignoring StageDuration. |
| [EcoFarmStatus [region] | Read-only overview: plot counts by growth stage, farmer counts by state, stock and harvest totals. |
| [EcoFarmLocate <name> | Finds a specific farmer by partial name match — exact map, coordinates, state, and stuck-pulse count. The direct answer to “a farmer’s gone quiet, where actually is it.” |
Seeded regions: all six active regions now have farm entries — britain, trinsic, and vesper originally; cove, skarabrae, and yew added afterward using the same confirmed-clear centre points EcoWorldSeeder.cs already uses for those regions’ grass/sheep/wolf seeding, rather than re-deriving fresh (and possibly housing-adjacent) coordinates. Town “go” points are a starting point, not verified open farmland — check placement after seeding and adjust via [props or drag if a plot lands somewhere awkward.
9.4Forward — After Wool, Meat, and Hides — ✓ Phase 1 Shipped
This is the piece the original design left unfinished: the chain used to dead-end at the player harvest tools. Phase 1 closes that loop.
the original plan below said to wire Weaver.cs / Tanner.cs / Cook.cs directly. The implementation took a different, safer path: three new NPC classes (EcoWeaverTrader / EcoTannerTrader / EcoCookTrader) instead of editing the shard’s existing vendor scripts. Those base classes are generic RunUO vendors used all over the world for ordinary shopping — editing them directly would mean every copy of that vendor anywhere on the shard suddenly behaves differently. An EcoTraderNPC is a distinct, opt-in fixture a GM places deliberately in an active ecology region, same as EcoShepherdNPC.
Weaver.cs draws Wool → stocks Cloth. (Shipped as EcoWeaverTrader — see 9.4.1.)
Tanner.cs draws Hides → stocks Leather. (Shipped as EcoTannerTrader.)
Cook.cs / Butcher.cs draws Meat → stocks cooked food. (Shipped as EcoCookTrader → Sausages. A near-identical EcoButcherTrader is a five-minute copy of EcoCookTrader once phase 1 is fully proven out.)
Miller/Baker draws Grain → stocks Bread. (Shipped as EcoMillerTrader, added after a soak test showed the region pools sitting at Grain 400/400 with nothing consuming it — EcoFarmerNPC/EcoFarmPlot deposit Grain, but Weaver/Tanner/Cook only ever covered Wool/Hides/Meat. This is that missing consumer.)
Real crafting integration — DefTailoring.cs (and the equivalent craft systems) recognising pool-sourced wool/cloth as a legitimate resource remains future work, not part of phase 1.
Harvest-pressure-driven pricing — the gold sink Simpson’s paper says the game always needed. ✓ Shipped. Each trader’s sell price scales up to +50% as its source pool’s HarvestPressure climbs toward 100% — the first real economic signal tied to ecology health, arriving with phase 1 rather than waiting for 9.7.
9.4.1 Phase 1 Implementation Reference
Files (Scripts/Mobiles/, alongside the original nine):
| File | Purpose |
|---|---|
| EcoTraderNPC.cs | Abstract base. Shared harvest → convert → sell engine, generalised from EcoShepherdNPC. |
| EcoWeaverTrader.cs | Wool → Cloth, 2:1, base price 6gp. |
| EcoTannerTrader.cs | Hides → Leather, 2:1, base price 8gp. |
| EcoCookTrader.cs | Meat → Sausages, 3:1, base price 5gp. |
| EcoTraderCommands.cs | GM commands — see below. |
Conversion & pricing:
| Trader | Draws | Ratio | Produces | Base price |
|---|---|---|---|---|
| EcoWeaverTrader | Wool | 2 : 1 | Cloth | 6gp |
| EcoTannerTrader | Hides | 2 : 1 | Leather | 8gp |
| EcoCookTrader | Meat | 3 : 1 | Sausages | 5gp |
| EcoMillerTrader | Grain | 3 : 1 | Bread | 4gp |
Leftover raw units that don’t add up to a whole finished unit carry over to the next cycle (SourceBuffer) rather than being lost. Shelf space is 30 finished units per trader; harvest interval defaults to 2 real hours, jittered on placement so multiple traders don’t all fire on the same tick.
Commands:
| Command | Description |
|---|---|
| [EcoTraderTick | Targets an EcoTraderNPC and forces an immediate harvest+convert cycle — bypasses the 2-hour wait for testing. |
| [EcoTraderStatus | Lists every trader with region, stock, and next harvest time. |
| [EcoSeedTraders [mapName] | Places one weaver, tanner, and cook per active ecology region, anchored on that region’s existing EcoShepherdNPC. Skips a region with no shepherd yet (run [EcoSeedShepherds first). Safe to re-run. Correction: this description undersold what the command actually does — its own [Description] attribute and EcoTraderCamp.cs’s source both confirm it places a full EcoTraderCamp (weaver+tanner+cook+miller together, anchored on the shepherd, with its own placement-override table for confirmed spots), not just the three traders this text originally implied. Verified directly against EcoTraderCommands.cs and EcoTraderCamp.cs, not assumed. |
| [EcoUnseedTraders [regionKey] | Removes trader NPCs — all of them, or just one region. |
9.5The Orc Raider Branch (Parallel, Not Linear) — ✓ Phase 2 Shipped
Orcs are deliberately not modelled as “just another wolf.” Wolves are a clean biological predator that only wants sheep. Orcs are a raider tier: they compete with wolves for sheep, and they raid the pool directly rather than a specific NPC’s stock (see the design note below on why). That’s a different kind of pressure on the system (theft vs. predation), and it turned out to double as a natural live-event hook once shipped: raid alerts go out over Discord and as a server-wide broadcast the moment one fires, built entirely on the shard’s existing DiscordRelay and World.Broadcast — no new infrastructure needed.
the original plan said orcs would raid “a EcoShepherdNPC’s accumulated stock” directly. The implementation raids the regional pool instead. EcoShepherdNPC’s stock counters are private with no public mutator, and adding one just for this would mean editing a file that’s been stable since phase 1 for a single caller. Raiding the pool achieves the same practical effect — less available for the shepherd/traders/players to draw on — without that risk, and keeps the theft mechanic reusable by any future consumer, not wired to one NPC type.
9.5.1 Phase 2 Implementation Reference
Files (alongside the rest, same folder):
| File | Purpose |
|---|---|
| EcoOrc.cs | Built on the shard’s base Orc.cs. Two independent behaviours: opportunistic predation (competes with EcoWolf for sheep, tuned to hunt less often) and scheduled raiding (loot the pool or trample grass nodes, independent of hunger). |
| EcoOrcCommands.cs | GM commands — see below. |
Predation tuning (deliberately weaker than EcoWolf so wolves stay primary):
| Constant | EcoWolf | EcoOrc |
|---|---|---|
| HungerDecayInterval | 15 ticks | 25 ticks — hunts less often |
| MealRestored | 70 | 55 — smaller meal |
| FoodSearchRadius | 50 tiles | 35 tiles — shorter reach |
Raiding: on a 40-minute timer (jittered), independent of hunger — 60% chance to raid the pool (loots 15–30% of whichever of Wool/Meat/Hides/Grain currently has stock, capped at 40 units, via the same Withdraw() everyone else uses but with nothing deposited back anywhere), otherwise tramples 1–3 grass nodes in the region by roughly half their current fill. Falls back to trampling if the pool had nothing worth taking. Grain was added as a fourth raidable resource once EcoFarmerNPC/EcoFarmPlot began depositing into the pool — before that fix, orcs raided a resource type that didn't exist yet on the farming side.
Kill detection is self-contained, the same pattern EcoDragon.cs uses: EcoSheep.OnDeath only notifies an EcoWolf attacker by type, so EcoOrc watches its own cached target each tick and notices when it dies rather than requiring a change to EcoSheep.cs.
Commands:
| Command | Description |
|---|---|
| [EcoSpawnOrc <region>** [n]** | Spawns N EcoOrcs (max 20), same pattern as [EcoSpawnWolf. |
| [EcoOrcTick | Targets an orc, forces an immediate raid — bypasses the 40-minute wait for testing. |
| [EcoOrcStatus | Lists every orc with region, hunger, raid count, and last raid result. |
Retired: an older, separate system — EcoOrcWarband.cs / EcoOrcRaider.cs / EcoOrcWarbandCommands.cs — predated this roadmap and ran independently: a single roaming band of 7 that migrated between regions and killed ecology outright with no pool interaction at all. Having both live meant two unrelated orc systems spawning against each other. Removed in favour of EcoOrc; UnifiedWebStatus.cs’s map markers and public “Orc Raiders” panel (previously “Orc Warband”) were repointed at EcoOrc.AllOrcs so the public site kept working with no loss of functionality — if anything the panel now shows real raid activity instead of just population/migration timing.
9.6Non-Animal Resource Nodes
Unchanged from the original roadmap — still future work, still exactly the copy-EcoGrassNode-with-different-constants pattern. The RegionalResourcePool already tracks Grain, Fruit, Wood, and Ore; each needs a producer node:
EcoOrchardNode — fruit trees. FillRatio decreases as players pick, regrows seasonally.
EcoTreeNode — wood source. Slower regrowth (trees take years). Chopping tool draws Wood from pool.
EcoMineNode — ore veins. Very slow regrowth. Mining tool draws Ore from pool. Quality tier optional.
9.7The Economy Layer (Simpson’s Fixes)
With meaningful resource scarcity established by the ecology, the Simpson economic fixes become viable. These remain the longer-term systems that depend on the Eco-Chain working correctly — Regional Pricing has already moved up into 9.4 above:
Regional Auction House — player-to-player trade for ecological resources between regions. Britain wool is more expensive during a Britain drought.
Vendor Buy Orders — players post purchase prices; NPC matches sellers to buyers. Prices form naturally from supply and demand rather than fixed NPC rates.
Gold-based skill training — skills cost gold to advance at trainers. Creates the primary gold sink UO always needed.
Item depreciation — weapons and armour degrade with use, creating sustained ongoing demand for craftable goods.
9.8Build Order
Agreed sequence — each phase gets seeded, watched with [EcoStatus, and verified before starting the next, the same incremental approach that took grass → sheep → wolf → dragon from design to live:
| Order | Phase | Why this slot |
|---|---|---|
| 1 — ✓ Done | Downstream trades (9.4) — EcoWeaverTrader, EcoTannerTrader, EcoCookTrader | Reused the EcoShepherdNPC pattern via a shared EcoTraderNPC base instead of editing shard-wide vendor classes, and closed the loop this document had flagged as unfinished since v1 |
| 2 — ✓ Done | EcoOrc raider tier (9.5) | Delivered the pool-raid/trample theft mechanic and, as a bonus, doubled as the fix for a pre-existing duplicate orc system (EcoOrcWarband) that was spawning independently |
| 3 | Season & weather coupling (9.3) | Smallest edit on the whole roadmap — one property on EcoGrassNode — for the largest ripple effect through every tier above it |
| 4 | EcoFarmerNPC + EcoRabbit (9.3) | Texture, not dependency — nothing else in the chain needs these, so they’re safe to build last |
| 5+ | Non-animal resource nodes (9.6), remaining economy layer (9.7) | Longer-term, depend on the chain above being stable first |
Observability & Tooling — ✓ Shipped
Every tier above eventually needs a GM to actually look at it — health checks, testing, and now live monitoring once things are running unattended in production. This section is the interface layer on top of everything in sections 5–9, not a new Eco-Chain tier itself.
10.1EcoDashboardGump — In-Game
[EcoDashboard opens a single navigable gump: an overview page listing every active region (grass fill, sheep, wolves, orcs, worst pool pressure, one click through to detail), and a full detail page per region — grass, population, whether the dragon is currently there, orc raid totals, the shepherd’s stock/prices, all three traders’ stock/prices, and every pool resource with exact numbers plus harvest pressure. Deliberately read-only; EcoControlGump already owns bulk actions (purge, unseed, seed world) so this doesn’t duplicate that.
Reads only existing public accessors — no changes needed to any file in sections 5–9 to build it. Notably uses RegionalResourcePool.AllPools.TryGetValue rather than GetOrCreate, specifically so opening the dashboard never silently creates a pool for a region with no activity yet.
10.2StaffWebStatus.cs — The Web Version
A second, staff-only web page alongside the player-facing status.html (UnifiedWebStatus.cs), writing to its own path (C:/inetpub/wwwroot/staff/status.html) on its own timer — a deliberate echo of the exact bug this shard already hit once with two independent timers overwriting the same file, avoided here by giving this its own file from the start.
Covers everything the dashboard gump does, plus general server vitals a GM wouldn’t otherwise see without the console: full online roster with access levels, IDOC collapse history (10.3), recent deaths, and a tail of the ecology/statuspage/console error logs so a crash is visible on the page before anyone has to remote into the box.
this file has none of its own — RunUO can write the HTML, it can’t gate who requests it over HTTP. The /staff/ folder needs to be locked down in IIS (Basic Auth or an IP allowlist) before the URL is shared with anyone; an obscure folder name alone is not a real control.
10.3Related Systems Built Alongside This
Two systems shipped in the same pass as the tooling above. They aren’t part of the Eco-Chain, but they share infrastructure with it and are worth knowing about from this document even though their own detail lives elsewhere:
- IDOCWatcher.cs — polls
BaseHouse.AllHousesfor the moment a house enters, is saved from, or falls out of IDOC status, and fires Discord + server-wide alerts using the sameDiscordRelay/World.Broadcastcalls the orc raid alerts use. Keeps a permanent collapse log (IDOCCollapseLogger) that section 10.2’s staff page reads directly. - ConsoleTeeLogger.cs — wraps
Console.Out/Console.Errorso every line ever printed by any script (not just ours) is also captured to a rolling daily log file, and the staff page’s error panel tails it. Built after a real permissions error from an unrelated third-party script (UnifiedWebStatusJson.cs) turned out to be invisible unless someone was watching the console at the exact second it happened.
“We had this beautiful ecological system… and we had to rip it all out.”
— Raph Koster, 2006 GDC postmortem on Ultima Online
It no longer needs to stay ripped out.
Page | RunUO 2.2 Ecology System | mondains.com
The Build Log
Thirty passes over the live shard — each a dated record of what was found, what broke, and what was done about it. Kept in appendix order so every cross-reference in the text still resolves; the date badge tells you when it actually happened. Superseded material is folded away rather than deleted — open it to read it.
SimPC — The Living Population Layer
A.1Where This Fits
Everything in the body of this document is about the biological half of a living world — grass regrowing, sheep grazing, wolves hunting, players harvesting the overflow. SimPC is the other half: the social one. Where the Eco-Chain gives Sosaria a self-regulating food chain, SimPC gives it a self-regulating population — simulated residents who settle, build houses, work real jobs, join guilds, spread rumors, get robbed, form parties, post bounties on each other, and grow their own local economy, all without a GM or a real player ever touching them.
The two systems were built independently but share the same underlying philosophy this whole document argues for: a world that runs itself, where scarcity, consequence, and behavior emerge from real systems rather than being scripted. Section A.6 below is the clearest single proof of that philosophy actually working.
SimPC was ported from a donor codebase and had been running for some time before this session. What follows documents one focused day of debugging and hardening — roughly two dozen real, traced-to-the-line bugs found and fixed, four new GM tools built, and one genuinely good surprise.
A.2What Was Actually Broken
Every fix below was diagnosed from real in-game evidence — debug narration, live dashboards, and direct code review — not guesswork. They fall into four groups.
A.2.1 Travel & Pathfinding
SimPC’s movement was never built on RunUO’s real pathfinding (PathFollower/MovementPath, the same system the caravan NPCs use) — just raw eight-direction bump-walking. Several bugs compounded on top of that gap:
| Problem | Root Cause | Fix |
|---|---|---|
| Sims wandered erratically, never really stuck but never arriving | Stuck-tick counter reset on any successful move, even a sideways bump that made no real progress toward the destination | Track actual distance closed per tick, not just “did a move succeed” |
| A sim mined from the middle of the ocean | Cached mine/fish/wood locations were validated by “is a resource nearby,” never by “is this exact tile actually dry land” | Reject water tiles at the source, in the shared stand-point resolver used by every worksite scan |
| Sims occasionally teleported to a random point anywhere on the map for ordinary work | The waypoint-teleport fallback (a uniform random point across the whole facet) was tried before the local nearby-scan, not after | Reordered: local scan first, waypoint only as a genuine last resort |
| A sim already stranded far from home kept searching for work from wherever it was stuck, finding another equally-far spot every time | No check for “is the sim itself currently far from home,” as distinct from “is the cached worksite far from home” | New stranded-guard: if the sim is far from home, send it home first, before searching for anything |
| A sim could get wedged near tricky terrain (rocks, a guild hall’s tight interior) and never close the last few tiles | Recall only triggered on raw distance or a stuck-tick counter that reset too easily | Added a 90-second wall-clock travel timeout as a backstop, independent of tick-by-tick progress |
A.2.2 Scheduling & Population
| Problem | Root Cause | Fix |
|---|---|---|
[SimPCMarketTick froze the server |
A “budget” parameter meant as a one-pass ceiling was implemented as a target count to keep looping toward; with only ~20 real sims and vendors, it ground through hundreds of thousands of iterations trying to reach a target of 100,000 | Scale the budget to the actual population size instead of a flat constant |
| Any sim standing near a player got its entire tick pipeline — rumors, theft checks, everything — re-run half a dozen times in one 2-second window | Same shape of bug: the director’s per-tick budget wasn’t capped to one pass over a small population, so it wrapped and reprocessed the same handful of sims repeatedly | Cap the director to a single lap of the current population per tick |
| Sims mysteriously stopped doing real work and drifted into Recovery/Idle for the entire time zone spanned the shard’s afternoon | A “late-night routine” override used the server’s raw UTC clock, not local time — for a shard run from UTC+10, UTC 3–5 a.m. lands squarely in the operator’s own testing afternoon | Use local time for the day/night routine check |
| Auto-population never spawned anyone, no matter how many cycles passed | The spawn-anchor search required an online character at exactly AccessLevel.Player — excluding any GM/staff account testing solo |
Accept any online player-controlled character as a valid anchor |
A.2.3 Housing, Security & Death
| Problem | Root Cause | Fix |
|---|---|---|
| Sims repeatedly reported being robbed with no actual thief | Any drop in stored resources — including the sim’s own normal spending — was flagged as theft | Only flag it if the house key was genuinely missing at the time; that’s the one real signal someone else got in |
| A sim died at the same coordinates, was revived, healed up, and walked straight back into whatever killed it — repeatedly | Nothing invalidated a cached work location after a death that happened right there | Death now forgets any cached mine/wood/fishing spot within 20 tiles of where it happened, forcing a fresh (hopefully safer) search |
| A guild hall’s residents kept “stealing” each other’s shared key in an endless loop — rotate, invalidate everyone else, they detect theft, rotate again, forever | Guild halls intentionally share one house and one key across every member (a real, working feature); the personal theft/key-rotation system assumed exclusive single-owner security and had no idea the ownership was shared | Guild-hall residents are excluded from the personal security cycle entirely — they already have their own hall-level defense system |
| A guild-hall resident could join successfully, but never resolve a later trip home — stuck in an endless “returning home” recall | HomeLocation was set to the raw structure anchor point of the hall, not a validated walkable interior tile (unlike the initial move-in, which correctly searched for a real spot) |
Home location now uses the same validated interior-point search as the initial placement |
| Wall art (paintings, portraits) sometimes ended up sitting in the middle of a room instead of near a wall | Decoration placement never distinguished wall-hung items from freestanding furniture — every item used the same open-floor logic | Wall art now biases toward spots with fewer open neighbors (naturally closer to walls/corners), reusing scoring data the placement system already computed |
| A sim could be seen pacing back and forth for no visible reason, saying nothing at all | RunUO’s own built-in creature AI (BaseCreature) defaults every mobile to a 10-tile idle-wander radius; nothing in SimPC ever turned it off, so it ran independently of — and invisibly to — the SimPC director. Barely noticeable in open terrain, obvious as aimless pacing in a tight interior |
Explicitly disable the built-in wander radius for every SimPC, going forward and (via a one-time repair command) for the existing population |
A.2.4 A Note on How These Compounded
Several of the fixes above unmasked the next problem rather than solving everything at once — worth recording honestly, because it’s a real pattern worth remembering for future work, not just a footnote. Fixing the stale-worksite-cache bug made the random-waypoint-teleport bug fire far more often, since the “need a new location” branch it lived behind used to rarely trigger. Fixing the false-positive theft detection didn’t stop the guild-hall key-rotation cascade, because a second, independent code path (inside the Recovery activity itself) called the same rotation logic and had to be patched separately. Each layer removed was necessary, but a single fix rarely turned out to be the whole story — systems this interconnected reward patience and re-checking with live data over assuming a fix is complete.
A.3New Tools Built
Four new commands, designed to make the system’s actual state visible rather than something that has to be inferred from scattered dumps:
| Command | What It Does |
|---|---|
[SimPCInfoDashboard |
Every current SimPC at once — location, distance from home, carried resources, stuck/fidelity status, wealth — in one paginated gump, with a click-through to the full per-sim detail view. The same information [SimPCInfo prints to chat, for the whole population simultaneously. |
[SimPCDebugAll |
Toggles debug narration ([simpc] chat lines) on every sim at once, instead of hunting one down at a time. |
[SimPCFixGuildHallHomes |
One-time repair: re-validates HomeLocation for every current guild-hall resident, replacing a bad pre-fix anchor point with a real walkable interior tile. |
[SimPCFixRangeHome |
One-time repair: disables the inherited creature idle-wander radius for every existing sim, complementing the constructor-level fix that only covers newly created ones. |
The existing in-game command reference ([IDOCCommands) and the staff web status page were both updated to match — the web page now carries a live per-sim table (location, resources, stuck status, wealth) alongside the population summary that was already there, so the same visibility is available without logging in.
A.4Existing SimPC Command Reference
For completeness, the full existing SimPC/HomedFarmer command surface — population control, role creation, dashboards, housing, economy, guilds/parties, and the rest — is documented separately in the IDOC command reference and reachable in-game via [IDOCCommands. That reference is kept current independently of this document; it is not duplicated here to avoid the two going out of sync.
A.5Current State
As of this session: population growing cleanly under normal AutoPopulation, real resource numbers accumulating and visible across every diagnostic view, sims routing home correctly after work, deaths logged accurately with real causes, and no known false-positive theft or runaway loops remaining. A population-scaling experiment (raising the cap toward several hundred sims) was proposed as a genuine stress test for a future session, not yet run to completion.
A.6The Bounty — Proof the System Works
Partway through this session, the Social Dashboard surfaced this, entirely on its own:
#1 Beorn Devonshire — 325g — predatory acts against other SimPCs
Nobody scripted that. No quest fired it, no GM posted it. A Thief-role sim behaved the way its role and traits pushed it to behave, the social system noticed the pattern, and it generated a real economic consequence for it — a bounty, with a real reward, sitting in the world for anyone to see.
That is the exact shape of the ecology’s own design philosophy, restated in social terms rather than biological ones. Section 3 of this document describes Koster’s grass-sheep-wolf chain as self-regulating — a system where scarcity and consequence emerge from real mechanics instead of being authored. A predatory sim generating its own bounty is the same principle one layer up: a society, not just an ecology, correcting itself.
“We had this beautiful ecological system… and we had to rip it all out.”
— Raph Koster, 2006 GDC postmortem on Ultima Online
It didn’t stay ripped out. And now it isn’t just grass and wolves that prove it — it’s the people, too.
Page | SimPC — The Living Population Layer | mondains.com
EcoCaravanNPC — The Trinsic Trade Route
A roaming trade NPC built on SmartNPC, walking a fixed route between two points with a cosmetic pack horse companion trailing behind it. Deliberately Trinsic-only at launch, not a placeholder for every region at once — CaravanRoutes only has a confirmed, walked entry for Trinsic; the other five regions are commented out in the same dictionary pending the same walk-and-log treatment, not because the system can't support them.
Movement went through three architectures before landing on the current one. PathFollower alone worked over short legs but stalled on longer ones. MovementPath was tried next. What actually stuck: Mobile.CurrentWayPoint under AI_Melee (not AI_Vendor — vendor AI doesn't drive waypoint movement the same way). The real bug behind repeated 'stuck' reports wasn't pathfinding at all — it was a tick-count-based stuck detector firing from ordinary timing noise, not genuine lack of progress. Replaced with a time-based detector (TimeSpan.FromSeconds(15)) that only trips on real stalls.
Files
| File | Purpose |
|---|---|
| EcoCaravanNPC.cs | The caravan itself. CurrentWayPoint-driven route walking under AI_Melee, cosmetic PackHorse companion maintained each OnThink, time-based stuck detection. |
| EcoWaypointLogger.cs | GM tool: walk a route in-game and log the exact coordinates as you go, rather than guessing from map data. Used to produce Trinsic's confirmed route. |
| EcoRouteScan.cs | GM tool: auto-generates a candidate route between two points via the real pathfinder, for regions not yet walked and logged by hand. |
| EcoCaravanCommands.cs | GM command registration — see that file directly for the current full command set. |
Pack horse: The pack horse is purely cosmetic — it trails the caravan's position each tick for visual effect and carries no independent logic or inventory of its own.
Extending to more regions: Only regions with a real, walked CaravanRoutes entry ever get a caravan placed — this alone is what keeps the system Trinsic-only today; no other gating exists elsewhere. Extending to another region is walk-and-log (EcoWaypointLogger) or auto-scan (EcoRouteScan), then uncomment that region's entry.
UltimateNPC — The Unified Living-World NPC
Built July 2026: the flagship NPC type combining SmartNPC's live AI conversation, a ported version of SimPC's personality/mood/scored-activity brain, and real participation in the trophic chain via RegionalResourcePool — all in one class. Replaces EcoFarmerNPC, EcoShepherdNPC, EcoWeaverTrader, EcoTannerTrader, EcoCookTrader, and EcoMillerTrader; all six have been migrated shard-wide as of this writing.
C.1Why a New Class, Not a Merge
C# has single inheritance, and the three systems being unified here were already three separate, non-overlapping BaseCreature lineages: SmartNPC (AI chat), SimulatedPlayerMobile (SimPC), and the plain-BaseCreature Eco-chain NPCs. None of those lineages can literally become each other. UltimateNPC inherits from SmartNPC — so AI chat, wander/focus, and speech routing come for free — then ports in SimPC's personality/mood/brain logic and the Eco-chain's resource-pool integration as fields and services on this one class, rather than trying to inherit from a second parent.
C.2Architecture
| Layer | Source | What it contributes |
|---|---|---|
| Base class | SmartNPC | Live AI-driven conversation, wander/focus AI, speech routing. |
| Personality | Ported from SimulatedPlayerMobile | Seven 0-100 dials (Industry, Greed, MarketSavvy, Lawfulness, RiskTolerance, Aggression, Cowardice), rolled once at creation. |
| Mood | Ported from SimulatedPlayerMobile | Fear/Anger/Desperation/Confidence/TrustInPlayers/RecentProfit pressures, decaying over time, driving a CurrentMood. |
| Reputation | New, simplified | SimReputationScore and PlayerReputationScore — deliberately excludes SimPC's fuller social layer (rumors, notice boards, marriage) for now. |
| Persona | New | Dynamic — built fresh from job, mood, and personality on every AI call, not one fixed string. |
| Economic pulse | Ported pattern from EcoShepherdNPC | A real Server.Timer independent of OnThink(), so work happens whether or not a player is nearby. |
C.3The Fifteen Jobs
Each job is a stateless singleton implementing a common activity interface (CanStart/Score/Begin/Tick/EconomicPulse/End) — a direct port of SimulatedPlayerBrain's activity-scoring shape onto this project's own Activity pattern.
| Job | Draws / Produces | Ratio | Personality lean |
|---|---|---|---|
| Farming | (deposits Grain) / Wheat, Cotton | — | Industry, Lawfulness |
| Shepherding | Wool/Meat/Hides / sells raw | — | Greed, MarketSavvy |
| Weaving | Wool / Cloth | 2:1 | Industry, MarketSavvy |
| Tanning | Hides / Leather | 2:1 | RiskTolerance, Greed |
| Cooking | Meat / Sausages | 3:1 | Lawfulness, Industry |
| Milling | Grain / Bread | 3:1 | Industry, Lawfulness |
| Orcharding | (deposits Fruit) / sells raw apples | — | Industry, Lawfulness |
| Woodcutting | (deposits Wood) / sells raw logs | — | Industry, RiskTolerance |
| PieBaking | Fruit / Apple Pies | 4:1 | Lawfulness, Industry |
| CiderMaking | Fruit / Cider | 5:1 | Greed, MarketSavvy |
| Carpentry | Wood / Boards | 2:1 | Industry, MarketSavvy |
| Mining | (deposits Ore) / sells raw ore | — | Industry, RiskTolerance |
| Blacksmithing | Ore / Ingots | 2:1 | Industry, RiskTolerance |
| Fishing | (deposits Fish) / sells raw fish | — | Industry, RiskTolerance |
| Fishmongering | Fish / Fish Steaks | 2:1 | Industry, Lawfulness |
Milling closes a real loop: EcoFarmPlot deposits Grain into the pool on every harvest, but nothing consumed it until Milling was added — the same gap EcoMillerTrader was originally built to fix (see Appendix note under 9.4.1), now closed on the UltimateNPC side too.
C.4The Brain and Region Assignment
UltimateNPCBrain scores every eligible job against the NPC's personality and assigns the highest scorer, re-evaluated only in 'home' contexts so a working NPC finishes its shift rather than jittering between jobs mid-task. Shepherding, Weaving, Tanning, Cooking, and Milling auto-claim the nearest active ecology region to home, judged by that region's nearest EcoGrassNode placement — a region can simultaneously host a Shepherd and a Weaver and a Tanner (independent customers of the same pool); only two NPCs doing the SAME specialty are blocked from sharing a region.
C.5Migration From the Old Eco-Chain NPCs
UltimateNPCMigration.cs converts an existing EcoFarmerNPC/EcoShepherdNPC/EcoWeaverTrader/EcoTannerTrader/EcoCookTrader/EcoMillerTrader into an equivalent UltimateNPC in place — same location, same claimed plot/region, same accumulated stock/prices/counters carried over — via [UNPCMigrateFarmer, [UNPCMigrateShepherd, [UNPCMigrateTrader (single-target) and [UNPCMigrateAllFarmers / [UNPCMigrateAllShepherds / [UNPCMigrateAllTraders (Administrator batch). All reachable via IDOCCommandGump under a dedicated UltimateNPC category. The old classes remain deployed with zero live instances rather than deleted outright — EcoOrc.cs, EcoCommands.cs, EcoWorldSeeder.cs, EcoFarmSeeder.cs, EcoTraderCamp.cs, EcoTraderCommands.cs, EcoDashboardGump.cs, and StaffWebStatus.cs all still reference them directly and would fail to compile if the classes were removed without those dependents being updated first. StaffWebStatus.cs has been updated (via a new UltimateNPCStatusHelpers.cs companion file) to count both old and new registries additively; the seed/tick commands and EcoOrc's raid targeting have not yet.
C.6Lessons Learned During Build & Test
Shepherding/Trading initially inherited Farming's Sleeping/Travel/Working state machine, which put them to sleep every night — silently halting their economic pulse until morning. Fixed via a UsesDayNightCycle flag; Shepherding/Weaving/Tanning/Cooking/Milling all override it false and stay permanently Working, matching how EcoShepherdNPC/EcoTraderNPC always worked.
The first version of Shepherding's region-auto-claim picked the first unclaimed active region from a set, not the nearest one to the NPC's home — a shepherd placed in Skara Brae was assigned to Britain's pool. Fixed by scoring candidate regions by distance to the nearest EcoGrassNode.
StaffWebStatus.cs's Farming/Shepherd panels read the old registries directly; once the population migrated, those registries read back empty and the page began under-reporting a fully healthy population as zero. Fixed via UltimateNPCStatusHelpers.cs, added additively.
Known Issues (Not Yet Fixed)
EcoBiomes.cs — confirmed orphaned dead code during a full Scripts-folder review (4,646 files, zero naming conflicts across all Eco-Chain classes otherwise). Contains a latent key-mismatch bug — “skara” vs “skarabrae” — that would misroute biome lookups if the file were ever wired back in. Left in place, unused, pending a decision to either fix and adopt it or remove it outright.
UltimateNPCGoalActivityWeighting.cs — the goal→activity affinity table references activity keys (Socializing, Tavern, Combat, Lumberjacking) that do not appear in the current UltimateNPCActivityKey enum (20 values as of 2026-08-23: None, Farming, Shepherding, Weaving, Tanning, Cooking, Milling, Orcharding, Woodcutting, PieBaking, CiderMaking, Mining, Fishing, Fishmonger, Blacksmithing, Carpentry, Fishmongering, Loitering, Trading, Crafting — note Fishmonger and Fishmongering are two distinct values, not a typo. This list itself was stale until the 2026-08-23 audit, missing both Carpentry and Fishmongering — see the addendum below.) Either the enum grew after the affinity table was written or roughly a third of the documented goal→activity bonuses are dead weight against the live activity set. Needs a direct diff against UltimateNPCActivity.cs before the table in Appendix Q.4 is trusted as functional.
Activity-type count is inconsistently reported as 18 (the enum, counting or excluding None) versus 19 (this document's own front-matter KEY STATS block, Section 1). Most likely an off-by-one over whether None counts as an activity, but unresolved against source as of v23.
UltimateNPC.cs's serialization version history has an undocumented gap: Phase 2B (Appendix Q.2) took it from v4 to v6; Phase 3B Tier 2 (Appendix Q.5) states the pre-Tier-2 version was v9, moving to v10. Nothing in available session notes explains what shipped at v7 or v8, or whether Phase 3A's goal list (Appendix Q.3) was one of those two increments. Reconcile against the live Serialize/Deserialize methods before writing new serialization code against this class.
UltimateNPCGoalsWebExport_2.cs exists alongside UltimateNPCGoalsWebExport.cs with no session note explaining whether it is a revision, a duplicate, or a distinct second export target. IDOCCommandGump.cs's [UNPCGoalsExport entry was wired to the export path as of the 2026-08-18 command-gump pass, but which physical file is authoritative was not independently confirmed — verify directly before removing either file.
2026-08-23 addendum — the same hardcoded-partial-roster pattern surfaced three separate times in one night, worth flagging as a recurring shape rather than three unrelated bugs: SimPCEcoShoppingActivity's vendor-discovery list was missing five of fifteen trader types (Mining, Fishing, Fishmongering, Blacksmithing, Carpentry — see Appendix AA), StaffWebStatus.cs's Orchard/Wood panel tallied four job types but not Carpenter, and this very appendix's own activity-key enum listing (above) was two values short. All three were silent — nothing errored, the missing types just never showed up anywhere. Worth a standing habit going forward: whenever a new UltimateNPC job/activity type ships, grep the whole Customs tree for every other hardcoded roster of job types before considering it done, not just the file the feature lives in. Also found the same night: UltimateNPCListWebExport.cs and UltimateNPCListWebExport_1.cs are duplicate files exporting the identical command ([UNPCListExport) — the same shape of issue already flagged above for UltimateNPCGoalsWebExport_2.cs, now confirmed as a second instance. Neither has been removed; only one needs to run. And a smaller instance of the same stale-comment pattern: UltimateNPCCommands.cs still comments MaxBedsPerHouse as "= 5", but the live constant in UltimateNPCHousingService.cs was raised to 15 as part of tonight's housing fixes (Appendix Z.11) — the comment was never updated alongside the code.
D.12026-08-25 addendum - two items resolved, three opened, one recurring shape confirmed again
RESOLVED: the activity-type count. The 18-versus-19 inconsistency flagged above was an off-by-one over None, but the real answer has three numbers, not two, which is why it never reconciled. Read directly from source this pass: UltimateNPCActivityKey declares 20 values (19 excluding None), while UltimateNPCActivityRegistry registers exactly 15 concrete activities. The four enum values with no registered activity behind them are Fishmonger (the legacy twin of Fishmongering), Loitering, Trading and Crafting. So the enum count, the playable-job count, and this document's front matter were all describing different things. Table C.3 documents the 15 that actually exist, and its heading - which still read "The Six Jobs" from the original build - has been corrected to match.
RESOLVED as moot: the MaxBedsPerHouse stale comment. UltimateNPCCommands.cs commenting the constant as "= 5" against a live value of 15 no longer matters, because the housing subsystem it belongs to is retired (Appendix Z.12). The comment is now stale about a system that is switched off. Left in place rather than corrected, since touching retired code to fix a comment carries more risk than the comment does.
STILL OPEN, but much less load-bearing: UltimateNPCGoalActivityWeighting.cs. The affinity table still references activity keys that do not exist, and the direct diff called for above has still not been done. Its blast radius shrank considerably this pass, though. With jobs now permanent (Appendix AB.2), goal-weighted activity selection only influences the very first job an NPC ever picks and any deliberate [UNPCRechooseJob afterwards - it no longer runs every morning for every NPC. A table that is a third dead weight matters much less when it fires once per NPC lifetime.
THE RECURRING SHAPE, CONFIRMED A FOURTH TIME. The 2026-08-23 addendum above flagged hardcoded partial rosters as a recurring pattern across three separate finds. The IDOCCommandGump audit this pass produced a fourth instance of exactly the same shape: six commands that had shipped and been working for some time - [UNPCBedCleanup, [UNPCFixHouseBeds, [UNPCAssignHouse, [UNPCPubTest, [UNPCListRegion and [UNPCNearby - had never been added to the gump at all, and nothing errored because a missing gump entry is silent by nature. The standing habit proposed above should be widened: whenever anything is registered - a job type, a trader type, a command - grep the whole Customs tree for every other hardcoded roster of that thing before considering it done.
NEWLY OPENED this pass, all three carried in Appendix AB.17 and repeated here so this appendix stays the single index of known issues. First, Mining, Woodcutting, Orcharding and Fishing compute their stock keep as Math.Min(x, room) without the Math.Max(0, ...) clamp that Farming has - if stock ever exceeds MaxStock the shelf snaps to 40 in a single pulse with no message, since the profit line is gated on keep > 0. Latent rather than live, because no migration path feeds those four jobs. Second, Phase 3A initialises twice: both NPCGoalsEngine_Phase3A.cs and Phase3A_Initialization_FIXED.cs define Initialize(), so RunUO calls both and two World_Load hooks are registered - visible in the startup log as a duplicated banner, probably harmless, possibly wasteful. Third, UltimateNPCPubSocialHooks prints nothing when its timer arms, unlike the chronicle, so [UNPCPubSocialCheck is currently the only way to confirm the pub social system started.
UNCHANGED and still open: the EcoBiomes.cs orphan, the UltimateNPC.cs serialization version gap at v7/v8, and the duplicate web-export files (UltimateNPCGoalsWebExport_2.cs, UltimateNPCListWebExport_1.cs). Nothing this pass touched any of them. Worth noting that the serialization gap became slightly less urgent for the same reason as the affinity table: this pass added no persisted field and bumped no version, deliberately, so the gap has not widened.
UltimateOrc — The Unified Raiding Horde
Built July 2026, same session as the UltimateOrc Horde request. Extends EcoOrc directly rather than replacing it - every bit of EcoOrc's proven raiding and predation behaviour (competing with EcoWolf for sheep, scheduled resource-pool theft, grass-node trampling, self-detected kills, always-active thinking, region-tagged idle-until-configured behaviour) is inherited untouched. This appendix covers only what UltimateOrc adds on top.
E.1Why Extend, Not Replace
Unlike UltimateNPC (which had to be a new SmartNPC-based lineage because C#'s single inheritance ruled out combining SmartNPC with SimPC directly), UltimateOrc faced no such wall: EcoOrc's own raiding logic didn't need porting or re-implementing, so UltimateOrc simply extends EcoOrc and layers new behaviour on top via composition - personality, mood, AI chat, and notoriety as fields and services, the same pattern used throughout this project, just applied to a hostile creature instead of a peaceful fixture.
E.2What's New
| Layer | What it adds |
|---|---|
| Personality & mood | Aggression, Greed, Cowardice, RiskTolerance dials plus Confidence/Anger mood, rolled per orc at creation. |
| Live AI chat | SmartSpeechEngine confirmed to be a fully standalone service (no SmartNPC inheritance required) - hand-implemented the same OnSpeech/HandlesOnSpeech pattern SmartNPC.cs uses. Dynamic persona reflects mood, Wanted status, and chieftain status. |
| Combat taunts | A canned bark (not a live AI call, to avoid spamming the AI service every fight) whenever an orc newly locks onto a player in combat. |
| Real mugging | A genuine gold-theft mechanic against nearby, unengaged players - separate from EcoOrc's own resource-pool raiding, which is untouched. Stolen gold physically lands in the orc's own backpack, so killing it recovers what was taken. |
| Notoriety & bounties | Kills and thefts accumulate into a score; past a threshold the orc becomes Wanted, gets a randomly-picked title, and posts a real bounty via a standalone board (UltimateOrcBountyBoard.cs) - deliberately NOT wired into SimPCBountyService, which is hard-typed to SimulatedPlayerMobile as both poster and target with lookups built entirely around sim identity. Bending it to accept a monster target would mean real changes to a currently-healthy, currently-live file for a mostly-cosmetic benefit. |
| Chieftain variant | Toggleable per-orc - tougher stats (roughly 1.8x Str, 2x Hits, 1.4x damage), a title, and a distinct hue, meant as a real rallying point and most-wanted target rather than just another interchangeable raider. |
E.3Bounded Roaming & Regional Reinforcement
Two real gaps were found and closed the same session, both from watching it run live rather than caught in advance.
EcoOrc never sets Home/RangeHome at all, and with AI_Melee's default wander logic that means a raider can drift arbitrarily far from wherever it was placed, with nothing pulling it back toward the region it's thematically tagged to - including into guarded towns it has no business near. UltimateOrc now captures Home/RangeHome (radius 22 tiles) on its first real tick, the same lazy-capture pattern UltimateNPC uses for its own home point, so raiders patrol a believable territory instead of wandering forever.
EcoOrc has no self-sustaining population logic at all - kill the raiders and they're simply gone until a GM remembers to re-run a spawn command. UltimateOrcRaidingPartyService.cs adds opt-in per-region reinforcement: a target headcount (default 3) and an anchor point set via [UOrcAutoRegion, checked on a real timer. A band still holding ground tops itself back up fairly promptly, one straggler at a time; a full wipe to zero goes properly quiet for a real 90-minute cooldown before anything reforms - a wiped region stays wiped for a while rather than an instant respawn undoing the kill. Off by default per region, the same deliberate caution SimPC's AutoPopulation uses, since this autonomously creates hostile creatures without further confirmation once enabled.
E.4Status Page Integration
Because UltimateOrc extends EcoOrc directly rather than being a separate lineage, every existing foreach (EcoOrc o in EcoOrc.AllOrcs) loop in StaffWebStatus.cs and UnifiedWebStatus.cs already includes UltimateOrcs automatically - population counts, map markers, and raid totals needed zero changes, a direct benefit of the inheritance choice in E.1. The one genuine addition: UnifiedWebStatus.cs's public “Raider Sightings” panel now also lists any currently-Wanted raiders by name, bounty, and last-seen region, pulled from UltimateOrc's own notoriety data.
E.5GM Command Reference
| Command | What it does |
|---|---|
| [UOrcSpawn <region> [count] | Spawns UltimateOrcs at your location tagged to a region. Same shape as [EcoSpawnOrc. |
| [UOrcList | Lists every UltimateOrc and its region/notoriety/chieftain status. |
| [UOrcInfo | Target one for a full detail report - personality, mood, notoriety, raid history. |
| [UOrcMakeChieftain | Target one to toggle chieftain status. |
| [UOrcForceWanted | Target one to force it past the notoriety threshold immediately, for testing the bounty board without waiting on real kills or thefts. |
| [UOrcAutoRegion <region> [target] | Enables automatic raiding-party reinforcement for a region, anchored on your current location. |
| [UOrcAutoRegionOff <region> | Disables reinforcement for a region. Existing orcs are untouched. |
| [UOrcAutoRegionStatus | Lists every auto-reinforced region, current headcount, target, and wipe-cooldown countdown. |
All of the above are also reachable through IDOCCommandGump under a dedicated UltimateOrc category, alongside SimPC, Eco-Chain, and UltimateNPC.
E.6Lesson From Testing
During testing, an orc engaged a player and was instantly destroyed by town guards - confirmed to be standard, correct RunUO behaviour (BaseCreature.cs has built-in guard-summoning logic for any hostile creature that attacks inside a GuardedRegion), not a bug in UltimateOrc's own code, and something that would have happened to a plain EcoOrc placed in the same spot. The practical takeaway folded into deployment guidance rather than code: pick anchor points for [UOrcAutoRegion somewhere genuinely wild, since that's now also where reinforcements keep reappearing, not just the initial spawn.
Seafaring — The Sea Voyage System
SimPCBoatingService.cs shipped for the original SimPC port fully stubbed — every method a no-op returning false or null. It was excluded from release rather than shipped broken. This pass replaced the stub with a real, RunUO 2.2-native implementation; no other SimPC file needed to change.
F.1Why the Original Approach Failed
The donor code assumed a ServUO-style advanced ship API — BaseBoat.Pilot, VirtualMount, ZSurface, TryLockdown — none of which exist on RunUO 2.2’s BaseBoat. RunUO 2.2 boats have no pilot/mount concept at all: BaseBoat.OneMove(Direction) is called directly, and Forward/Backward/Left/Right turn out to be fixed aliases for North/South/West/East rather than relative to the boat’s facing — confirmed directly from BaseBoat.cs. That made the fix simpler than the original design, not harder: SimPCSeaScanner’s existing DirectionToward() helper plugs straight into OneMove with zero adaptation.
F.2How It Actually Works
A voyage spawns a real boat (via the catalog service, which reflects real BaseBoat subclasses and was already fully built) near the sim’s home water, sails it toward open-ocean points a bounded few OneMove calls per tick at a time — never relying on BaseBoat’s own continuous-movement timer running unattended — and drops flavour loot into the hold based on voyage mode. Voyage state lives directly on SimulatedPlayerMobile (SeaBoatSerial, SeaHoldSerial, SeaVoyageEndUtc, SeaDestination, catch/kill/salvage counters) — that schema already existed, fully serialised, with the existing [SimPCSeaStatus command already reading it; it just sat unused while the service was stubbed. Because both the sim’s fields and the boat itself (an ordinary placed multi) are real serialised world state, an in-progress voyage now correctly survives a server restart.
Files (Scripts/Custom/SimulatedPlayers/):
| File | Purpose |
|---|---|
| SimPCBoatingService.cs (replaced) | The only file that changed. StartSeaVoyage / TickSeaVoyage / EndSeaVoyage drive a real BaseBoat directly via OneMove. Public method surface is unchanged from the stub, so every file that already called it compiles and runs with no changes. |
| SimPCBoatCatalogService.cs | Unchanged — already reflected real BaseBoat subclasses and could construct one per sim’s wealth tier. Was fully built, just had nothing calling it. |
| SimPCSeaScanner.cs / SimPCWaterScanner.cs | Unchanged — open-water point finding and DirectionToward() already existed and turned out to be exactly what OneMove needed. |
| SimPCBoatHold.cs | Unchanged — fallback loot container for boat classes with no real Hold. |
F.3Voyage Modes
| Mode | What it yields |
|---|---|
| Fishing | Fish into the hold; increments SeaVoyageCatches. Default lean for Fisher-role sims. |
| Salvage | Gold + occasional iron ingots; increments SeaVoyageSalvage. |
| MessageInBottle | Flavour gold stand-in for now — a real SOS-bottle harvest needs the shard’s actual treasure-map engine wired in, noted as follow-up work, not done here. Default lean for TreasureHunter-role sims. |
| MonsterHunting | Gold, with an occasional SeaVoyageKills increment — no real combat resolution yet. Default lean for Hunter/Bandit-role sims. |
| Patrol | Gold only. Default fallback for sims with no strong role lean. |
F.4GM Command Reference
These commands already existed in SimPCBoatingCommands.cs and previously reported "not supported" — they now work end to end against the real implementation.
| Command | Description |
|---|---|
| [SimPCSeaVoyage <sim> [mode] | Starts a voyage now, optionally forcing a specific mode. |
| [SimPCSeaStatus | Shows every sim currently at sea — mode, time remaining, catches/kills/salvage. |
| [SimPCSeaTick <sim> | Forces one voyage tick immediately, for testing. |
| [SimPCReturnFromSea <sim> | Ends a voyage immediately — transfers hold contents home, deletes the boat. |
| [SimPCBoatCatalog | Lists the boat classes/tiers available to the catalog service. |
F.5Known Limitations (v1)
Boats are ephemeral — spawned per voyage, deleted at the end after the hold empties into the sim’s backpack. Persistent, owned boats parked at home between voyages (wealth-gated tier via the catalog service, which already supports this) is the natural v2 once this is proven live, the same phased approach the Eco-Chain itself used. MonsterHunting mode has no real combat resolution. Movement is straight-line-toward-target with a simple perpendicular nudge if blocked — fine for open ocean, but a sim could plausibly get stuck bouncing near a tight coastline.
F.6Status
Implemented and compiling against the actual production class list; awaiting live test results on the shard as of this writing.
F.7Update (August 4) — The Real Root Causes, Found Live
F.5's "could plausibly get stuck bouncing near a tight coastline" turned out to understate the problem - live testing found voyaging sims capped at exactly one tile of movement per tick, in open water, with no coastline involved at all. Three fixes were needed before movement genuinely worked, each one revealing the next once it was live-tested rather than just re-read from source.
| Symptom | Root Cause | Fix |
|---|---|---|
| Boat consistently drifted wrong once it had moved even once | BaseBoat.Move() computes d = m_Facing + dir internally - the Direction passed to OneMove() is relative to the boat's current facing, not an absolute compass direction. DirectionToward() returns absolute compass directions, so every call after the boat's first (while still facing its construction-default North) was silently wrong. | Convert absolute to boat-relative immediately before every move call: (absoluteDir - boat.Facing) & 0x7. |
| A boat stuck against terrain never found an open route, even retrying every tick | Blocked-move handling tried the primary direction plus exactly one random perpendicular nudge, then gave up for the tick - never the other perpendicular, never straight back. | Try all four cardinal directions - primary, both perpendiculars, then reverse as a last resort - before giving up on that step. |
| Even after both fixes above, a boat still capped at exactly one tile per tick in open water - the actual headline bug | OneMove() doesn't move the boat - it calls StartMove(), which sets a pending direction and starts a MoveTimer that fires later, then returns true immediately once scheduling succeeds. A second OneMove() call before that timer fires cancels the pending one and schedules a new one (StartMove stops m_MoveTimer before starting a fresh one) - so a loop calling OneMove() multiple times per tick was cancelling every attempt but the last, regardless of MovesPerTick. | Call BaseBoat.Move() directly instead of OneMove()/StartMove() - the real synchronous, immediate-translation primitive MoveTimer itself calls once it fires. No scheduling, nothing to cancel, every iteration's move genuinely happens right away. Also passes message: false, since a real player's boat command prints "Aye aye sir." per order - not something 170+ simulated voyages should be repeating in a nearby player's chat log. |
Two smaller design fixes landed alongside the movement chain, both confirmed necessary from the same live session: the per-leg re-target during the active voyage was unbounded (any point anywhere on the map), which could walk a boat arbitrarily far from home over a long voyage and make the eventual return leg take an unpredictable amount of time - now bounded to MaxVoyageRadius (200 tiles) of home. And the mode activity (fishing/salvage/hunting rolls) previously kept firing during the return leg with no check for whether the voyage had already timed out - a boat "sailing hard for home" was still dropping fishing nets the whole way back; now gated on !returning.
F.7 Status: Confirmed live - after all three movement fixes, a voyaging sim was watched making real, correct-direction progress across multiple forced ticks in open water, closing out F.6's "awaiting live test results."
World History — The Chronicle of Sosaria
Two halves, built together: a one-time backfill mining data that already existed and needed no new logging (Account.Created, BaseHouse.BuiltOn), and a daily snapshot that starts recording from the day it’s deployed forward. Both feed a scrollable web page in the same visual language as the rest of the shard’s public-facing pages.
G.1Files
| File | Purpose |
|---|---|
| WorldHistoryService.cs | The engine. [WorldHistoryBackfill mines Account.Created + BaseHouse.BuiltOn, bucketed by month, into history_backfill.json. A daily timer captures accounts/houses/SimPCs/deaths/wolf kills/season into history_daily.json. Deliberately does not read Eco-Chain resource-pool totals or IDOC collapse counts — those files weren’t available to build against, and a wrong guess at their property names would have broken the whole file rather than just that one feature. |
| history.html | The public page — "The Chronicle of Sosaria." Deep History (cumulative accounts/houses since founding) up top, a scrubbable Daily Pulse timeline below it. No external dependencies, so it can’t break on a host that can’t reach a CDN. |
G.2Commands
| Command | Description |
|---|---|
| [WorldHistoryBackfill | Full regeneration, safe to re-run any time. |
| [WorldHistorySnapshot | Forces one day’s capture immediately, for testing. |
| [WorldHistoryStatus | Shows how many days have been recorded so far. |
G.3Related Hardening: CreatureWebDeathLogger
Wiring the daily death counter into this system surfaced a real, pre-existing production bug, confirmed directly against the live file rather than assumed: deathlog.html had grown to 9.4MB and was almost entirely null bytes — corrupted, not just stale. The cause was InjectIntoHtml having no locking at all around its read-modify-write of the file; two creature deaths close together (routine with eco-chain wolves, orc raids, and SimPCs all generating deaths) could collide badly enough to corrupt the file outright, not just lose an entry. This is also why the status page’s "Recent Slayings" panel had been silently empty — it reads deathlog.html directly, not the in-memory list.
Fixed with three changes, all in CreatureWebDeathLogger.cs: a lock around the entire read-modify-write in InjectIntoHtml; a hard cap (150 entries) so the file can’t grow unbounded again; and corruption self-healing in Initialize() — an existing file is now checked for a sane size and the feed-container marker before being trusted, and recreated fresh if it fails either check, rather than assuming existence means validity.
G.4Status
Confirmed live and working — weather/season, world history, and the CreatureWebDeathLogger fix were all verified in production during this pass.
Sim Biography — The Living of Sosaria
A code review before building this turned up that most of what a biography needs already existed — SimPCWorldLedger tracks relationships and deaths, SimPCSocialLedger tracks guilds/parties/bounties, and SimPCReputationService.Describe() already aggregates lifetime achievement counters. The actual gap was small: no birth timestamp, no death counter beyond a shard-wide rolling window, and no chronological life-event timeline. What got built is mostly an aggregator, not a new data layer.
H.1Files
| File | Purpose |
|---|---|
| SimLifeEventLedger.cs | New, deliberately small — only for the handful of moments nothing else tracks (birth, book written, house upgraded, guild joined). Same persisted-singleton pattern as the existing ledgers. |
| SimBiographyService.cs | Pure aggregator — pulls from SimPCWorldLedger, SimPCSocialLedger, and SimLifeEventLedger, plus a world scan for SimPCProceduralBook filtered by SourceSimID (no book registry exists, so this is a one-off scan, fine for an on-demand lookup). |
| SimPCBiographyGump.cs | In-game GM view — [SimPCBiography <name>. Books read inline via a small self-contained reader gump using the text already captured in the biography, not by re-finding the physical book item. |
| SimBiographyWebExport.cs | [SimPCBiographyExport writes every live sim’s biography to sim_biographies.json. Manual, not automatic — a full population scan (including a world scan per sim for books) isn’t something to run silently on a schedule. |
| biographies.html | The public page — "The Living of Sosaria." Searchable citizen cards, click through to full stats/guild/life events/deaths/books, books expand to their actual page text inline. |
H.2Commands
| Command | Description |
|---|---|
| [SimPCBiography <name> | Full in-game biography view for one sim. |
| [SimPCBiographyExport | Regenerates sim_biographies.json. Not automatic — re-run after anything that changes the underlying data (e.g. a book purge) or the web page stays stale. |
| [SimPCPurgeBlankBooks [confirm] | Finds every SimPC book with no real page content. Dry run by default, lists a preview; add confirm to actually delete. |
H.3Lessons From Testing
A real, standalone bug surfaced while building this, unrelated to the biography feature itself: SimPCProceduralBook.cs generated real prose for every book (title, subject, tone, a full procedurally-written poem/story/history) but never actually wrote it into the book item. RunUO 2.2’s BaseBook uses a structured Pages/BookPageInfo API rather than ServUO’s single BookString property, and the generated content was simply discarded — every SimPC book was blank when opened, in-game, independent of anything to do with biographies. Confirmed directly against the real BaseBook.cs before fixing: BaseBook pre-allocates a fixed page count (40) of empty BookPageInfo objects, and while BaseBook.Pages itself has no public setter, each individual BookPageInfo.Lines does — so the fix word-wraps the generated content (classic UO dimensions, 8 lines/page, ~30 chars/line) and fills those in-place. Confirmed fixed in-game via screenshot: a freshly-written book showed real, readable text.
A second, related bug followed immediately: the biography’s book extraction only skipped pages with Lines == null, not Lines.Length == 0 — so the ~35 empty pre-allocated pages past whatever the real content actually filled were exported as blank-string pages too, burying the real text under a wall of "page break" dividers with nothing between them on the web page, even though the in-game gump showed the same book correctly. Confirmed by direct screenshot comparison of the two views side by side before fixing. Fixed by skipping any page with zero lines entirely in SimBiographyService.ExtractBookPages, rather than exporting it as an empty page.
Existing books written before the fix stay permanently blank — there was never anywhere the generated text was stored, so there is nothing to retroactively recover. [SimPCPurgeBlankBooks was built for exactly this: identifies "blank" by checking the actual defect (every page has zero lines) rather than a fragile date cutoff, dry-runs by default.
H.4Status
Confirmed live and working end to end — book generation, in-game reading, the web biography page, and the blank-book purge were all verified in production during this pass.
Eco-Chain Balance & Season Integration
A live-shard review turned up that Britain, Vesper, Cove, and Skarabrae were all in genuinely bad shape - not randomly, but because of two exact mechanical traps in EcologySystem.cs’s population-tick logic. Both are now fixed, and the season system is wired into the parts of the world that were still purely cosmetic.
I.1The Trap, Diagnosed Exactly
Two independent gates govern regional recovery: sheep can only respawn when grass fill ≥ 30%, and wolves can only respawn when sheep count ≥ 10 (TargetSheepPerWolf × 2). A region that drops below either line on its own has no way back - there was no bootstrap path in the code. Confirmed against live data at the time: Cove (10% grass, 8 sheep, 0 wolves) was stuck on both gates at once - grass too low to grow more sheep, sheep count one short of the wolf-respawn line. Britain and Vesper (100% grass, 0 sheep, 4 wolves each) were the opposite failure: sheep respawn conditions were technically met, but an already-established wolf pack hunted down every new sheep faster than the 5-minute, 2-sheep-per-tick respawn trickle could replace them - a predator trap the code already half-detected (a "PREDATOR OVERLOAD" warning existed) but never acted on, console-only, invisible on any dashboard.
I.2EcoGrassFill — the Missing Rescue Tool
EcoFill (already existing) only ever touched RegionalResourcePool - the player-facing Wool/Meat/Hides buffer - and never the grass nodes that actually gate sheep respawn. There was no way to manually push a stuck region’s grass back over the 30% line at all. EcoGrassFill closes that gap: sets every grass node in a region to a given fill percentage (default 100%), the missing other half of the EcoFill/EcoReset pair.
| Command | Description |
|---|---|
| [EcoGrassFill <region> [percent] | Sets every grass node in a region to the given fill percentage (default 100). Rescues a region stuck below the sheep-respawn threshold. |
I.3Predator-Overload Auto-Correction
The existing PREDATOR OVERLOAD warning (wolves > 2× target AND sheep < target/3) is now a real correction, not just a console log. A region has to show the condition for 2 consecutive population ticks (~10 minutes at the default 5-minute interval - long enough to ignore a single transient blip, short enough that nothing sits pinned indefinitely the way Britain/Vesper did) before one wolf is actually removed and the region gets a fresh window to recover. Deploys automatically - no manual action needed once live.
I.4Season → the Actual Visual World
RunUO’s real visual season system - Map.Season plus the SeasonChange packet, which is what actually swaps tree/ground tile art so winter looks like winter - existed on the shard already (via the community ChangeSeason.cs GM command) but was completely disconnected from anything built this session. WorldSeason’s enum values were already numbered to match RunUO’s own convention (Spring=0/Summer=1/Autumn=2/Winter=3), so SeasonVisualSync.cs is a direct hookup, not a translation layer: on every season change it sets Map.Season for every facet and broadcasts the same packet + SendEverything() sequence ChangeSeason.cs already used manually. Triggers instantly from [WorldSeasonSet for testing, plus a 10-minute poll as a safety net for the natural calendar drift nothing else would otherwise detect. The pre-existing [ChangeSeason command still works exactly as before, but any manual change made with it now gets silently overwritten by the next poll, since it doesn’t touch the season anchor - expected, not a bug.
I.5Season → Grass Regrowth
The actual 9.3.2 hookup - see that section for the full description. Summarized here since it’s part of the same integration pass: EcoGrassNode.EcologyTick() multiplies its regrowth by WorldConditions.GetGrowthMultiplier(Map, Location) every tick, floored at 1 unit regardless of conditions specifically so weather alone can never recreate the exact trap described in I.1.
I.6Reference: Real-World Time Ratio
Confirmed directly from Clock.cs while investigating this: SecondsPerUOMinute = 5.0, meaning 1 in-game hour = 5 real minutes and a full 24-hour in-game day = 2 real hours. Combined with LightCycle.cs’s day/night windows, a real 2-hour play session sees roughly 80 real minutes of daylight, 20 of full night, and two 10-minute dawn/dusk transitions. WorldStart is a fixed epoch (September 1, 1997) that the whole calculation runs off DateTime.Now against - deterministic for every player and every map forever, with no way to speed up, slow down, or desync it without editing that constant directly.
I.7Status
Deployed this pass, not yet confirmed live - the eco-chain balance fixes and the season/grass/visual hookups are awaiting the same live verification the rest of this document’s appendices went through before being marked confirmed.
Orchard & Wood — the Fruit/Wood Chain
A third parallel chain alongside Grass/Sheep/Wool and Grain/Bread, following the same claim-and-harvest shape as Farming rather than the continuous-grazing shape of the original sheep/wolf system - EcoOrchardNode/EcoTreeNode are picked/felled by a dedicated worker, not grazed by an animal creature. RegionalResourcePool already had Fruit (cap 200) and Wood (cap 600) sub-pools sitting unused since the original design - this closes that gap with zero changes needed to the pool itself.
J.1Files
| File | Purpose |
|---|---|
| EcoOrchardNode.cs / EcoTreeNode.cs | The producers. Same regrowth-over-time shape as EcoGrassNode, with a real visible state change - ItemID swaps between bare/laden (orchard) or full/stump (tree) as CurrentAmount crosses the ripe/choppable threshold. Wood regrows deliberately slowest of anything in the eco-chain (RegrowthRate=1) per the original design doc’s own "trees take years" note. |
| UltimateNPCOrchardingActivity.cs / UltimateNPCWoodcuttingActivity.cs | Claim an active region (same region-level claim Shepherding uses, not a single-plot claim like Farming - these resources are scattered through a region like grass, not one fixed plot), pick/chop, deposit into the pool, sell some raw stock directly. |
| UltimateNPCPieBakingActivity.cs / UltimateNPCCiderMakingActivity.cs | Two independent converters both drawing from the same Fruit pool - a region can host a Baker and a Cidermaker simultaneously, same as it already hosts a Weaver and Tanner off separate pools. Output is the real stock ApplePie and Pitcher(BeverageType.Cider) - see J.3. |
| EcoOrchardWoodCommands.cs | Placement and status commands - see J.2. |
J.2Commands
| Command | Description |
|---|---|
| [EcoOrchardNode / [EcoOrchardNodeFill <region> | Place one node, or a 5x5 grid, at your location. |
| [EcoTreeNode / [EcoTreeNodeFill <region> | Same, for tree nodes. |
| [EcoOrchardStatus | Per-region report: node counts, ripe/choppable counts, pool levels, worker counts by role. |
| [UNPCRefreshTitles | One-time fix for NPCs that existed before the job-title system - see J.5. |
J.3Lessons From Testing
Confirmed via your own shared source before assuming anything: ApplePie is a real stock item (found via UnbakedApplePie.Cook() returning one), and Cider is a real BeverageType with full pitcher/glass support - both replaced custom placeholder items originally built without that confirmation. Building custom items first and correcting them once the real ones were confirmed was the right order of operations given the uncertainty at the time, not a mistake worth avoiding differently next time.
A real, fully confirmed bug: newly-seeded nodes sat with zero harvest activity indefinitely despite an assigned, active worker - all 23 orchard nodes in a live test stayed ripe with 0 picked, Fruit pool stuck at 0/200. Root cause was EcoOrchardNode.FindNearestRipe() / EcoTreeNode.FindNearestChoppable() filtering candidates with Map.CanFit() at the node’s own tile - a solid tree graphic occupying that exact spot made CanFit permanently return false there, so no node was ever considered reachable. UltimateNPCFarmingActivity’s GetWorkSiteLocation() uses no such check at all, which is what exposed the inconsistency. Removing the check (a worker doesn’t need to stand on an impassable tile, only walk near it) fixed it - confirmed directly afterward: 22 of 23 nodes picked, Fruit pool moved to 77/200 on the very next forced pulse.
Two UI bugs found once real purchases were actually being tested: the shared Trading sell gump (Weaving/Tanning/Cooking/Milling/PieBaking/CiderMaking) never named the item being sold - "In stock: 2 at 6 gold each" instead of "2 apple pies" - even though the display name was already sitting on the activity, unused. Separately, that same gump’s background was 220px tall but its trailing price-note text started at y=215 with 40px height, running to 255 - the note was visibly overflowing outside the panel. Both fixed together: background grown to 260px, and the stock line now reads the item’s real display name.
J.4Dashboard Visibility
Both the public and staff status pages got a matching "Orchard & Wood" panel, mirroring the existing Regional Ecology Status table’s exact styling rather than inventing a new look. The staff version goes further, breaking worker counts down by individual role (orchardist/woodcutter/baker/cidermaker) rather than just a total, matching the depth the existing Farming panel already has. This was a genuine gap before this pass - the whole chain had zero dashboard visibility, console-only via [EcoOrchardStatus.
J.5Related: NPC Job Titles
Every UltimateNPC displayed a hardcoded "the Villager" title regardless of its actual job, confirmed directly in the source - never updated after spawn. Fixed with a new TitleSuffix property alongside the existing Name/PersonaRole pattern each activity already supplies ("the Farmer", "the Shepherd", "the Weaver", and so on for all ten jobs including the four added this pass), applied wherever ActivityKey’s setter already runs. NPCs that existed before this fix don’t update on their own - Title is saved/loaded like any other Mobile property, and the setter only fires on a real job change, not a server restart - hence [UNPCRefreshTitles as a one-time, safe-to-repeat fix for anything spawned earlier.
J.6Status
Confirmed live and working end to end - node placement, harvesting, pool deposits, conversion, real purchases, and dashboard visibility were all directly verified in production during this pass, including watching the Fruit pool move from a confirmed-stuck 0/200 to 77/200 after the CanFit fix.
SimPC EcoShopping — Closing the Loop to a Real Player Economy
Every consumer this document describes so far - EcoShearingTool, EcoBoneKnife, the UltimateNPC traders - is a real player standing at a vendor. SimPCEcoShoppingActivity.cs is the other kind of consumer this world needed: a simulated resident who travels to an Eco-Chain vendor, buys real goods, carries them home, and restocks their own player vendor with them - the last mile connecting the ecology this whole document is about to an actual, browsable, player-facing marketplace. The activity itself, the vendor it stocks (SimPCManagedPlayerVendor.cs), and the automatic restock loop (SimPCVendorMarketService.cs) already existed; what this pass fixed was the connective tissue between them.
K.1The Starting Gap
SimPCEcoShoppingActivity already travelled to whichever Eco-Chain vendor had stock and completed a real purchase via the same TryDirectPurchase/TrySell methods a player's own gump uses - covering every vendor type this document describes (Shepherd, Farmer, Weaver, Tanner, Cook, Miller, Orchardist, Woodcutter, PieBaking, CiderMaking). But the goods it bought landed via AddToBackpack and had no path from there into GetStarterStorage() - the one place SimPCVendorMarketService's restock loop actually looks for sellable stock. A sim could complete a full shopping trip and the goods would simply sit in its backpack forever, invisible to its own vendor.
K.2What Was Actually Broken - In the Order Each Was Found
Six real, distinct problems, each one masking the next until the previous was fixed and live-tested - the same compounding pattern Appendix A.2.4 already documented for SimPC's other activities.
| Problem | Root Cause | Fix |
|---|---|---|
| 1. Goods never reached storage or the vendor | No code path moved purchased goods from Backpack into GetStarterStorage() - the missing step described in K.1. | Added CountCarriedEcoGoods/MoveEcoGoodsToHomeStorage to SimulatedInventoryService.cs (a sibling pair to the existing field-loot ones Hauling already uses, kept separate rather than widening those and changing Hauling's own behaviour) and a two-phase carry-then-deposit block in Tick(), the same shape SimPCHaulingActivity already uses. |
| 2. EcoShopping never got picked at all | Baseline score was 4 + MarketSavvy/6 (~11 for a typical sim) - confirmed via [SimPCActivityScores to be losing to nearly every other activity every single time (Hauling 60, Mining 59, MonsterHunting 50 for the same sim that scored 11 here). | Raised the baseline to 22 + MarketSavvy/6, putting it in the middle of the pack instead of the bottom. No role-specific bias added yet - a genuine Trader-leaning SimPCRolePlanner bias is the natural follow-up, deliberately left as a separate decision rather than bundled into this fix. |
| 3. A forced-into-EcoShopping sim reverted to its old job before ever leaving home | EcoShopping was never added to SimPCActivityVarietyService.WorkProfiles - the list SimPCWorkCommitmentService.ShouldStayWithCurrentWork() checks first (IsWorkProfile(key) == false returns immediately). With no commitment protection, a sim doing EcoShopping was re-evaluated and could be switched away on the very next brain tick, before it ever took a real step. It also meant the earlier baseline-score fix (problem 2) was never being compared on equal footing - AdjustActivityScore's compression/bias pipeline skips any key not in WorkProfiles and passes the raw score straight through. | Added SimPCActivityProfileKey.EcoShopping to WorkProfiles - one line. Confirmed live: after this, a force-started sim stayed committed and actually walked toward a vendor instead of snapping back to its previous job. |
| 4. A sim stuck oscillating between "stranded, returning home" and "travelling to vendor," never arriving | The generic SimulatedTravelService.IsSimStrandedFromHome check (250-tile threshold, tuned for locally-spawning work like mining/fishing) ran at the top of every tick with no concept of "currently on a deliberate errand." Eco-Chain vendors exist in only a handful of seeded regions on a map spanning thousands of tiles, so a sim's nearest vendor could legitimately be 700+ tiles from home - well past that threshold. Confirmed live: a sim would recall toward the vendor, get flagged stranded on the very next tick before a purchase could happen, recall home, then immediately re-attempt the identical too-far trip - forever. | First attempt (raising the vendor-selection distance cap to a new, more generous MaxVendorTripDistance) only moved the loop further out rather than removing it, since the mismatch between vendor-selection distance and the stranded-check's own threshold was the real problem, not the number itself. Correct fix: removed the stranded self-check from this activity entirely. SimulatedTravelService.TryTravelOrRecall already recalls a sim instantly once a trip crosses RecallDistance (confirmed directly from source: sim.Say("Kal Ort Por"), then a synchronous MoveToWorld) - a distant vendor was never actually a walking/reachability problem, so the activity has no need to second-guess its own travel calls. |
| 5. A sim kept withdrawing travel gold and repeating the exact same goal text from the exact same coordinates, indefinitely | Withdrawing gold set the goal text and ended the tick with a return, leaving the sim standing at home. CanSwitchActivity allows the brain to switch a sim to any other activity at any time while at home, commitment or not - so the very next brain evaluation could pull the sim into a different job before a second tick ever moved it, and the next time EcoShopping won again, Begin() ran fresh and the whole sequence restarted from the identical starting point. | Removed the early return after withdrawing gold; the tick now falls straight through into vendor lookup and the first travel command in the same call, so a real move is already issued (and the sim is no longer standing at home once it lands) before the brain gets another chance to intervene. |
| 6. The identical bug as #5, one step later: a sim carrying real purchased goods vanished from EcoShopping without the goods ever reaching storage | GoHomeOrRecall can complete an entire return trip in one synchronous call (the same RecallTo confirmed under problem 4), meaning a sim could already be standing at home, goods still in Backpack, the moment that call returned - but the code still just returned there, leaving the sim at home for a full tick before the deposit step could run. Same "at home = switchable" window as #5, this time costing a completed purchase rather than just a wasted trip. | Check IsAtHome again immediately after the recall call; if already home, fall straight through to the deposit step in the same tick instead of waiting for a separate one - the same atomic-tick pattern as the fix for #5, applied to the other end of the trip. |
K.3Files
| File | Purpose |
|---|---|
| SimPCEcoShoppingActivity.cs (fixed) | The activity itself. Score/CanStart/Begin/Tick as described in K.2; also gained SimPCEcoShoppingStats, a lightweight daily buy counter (transaction count, not a full per-resource ledger - SimPCMarketLedger already covers that) feeding the new staff dashboard panel in K.5. |
| SimulatedInventoryService.cs (additive) | New CountCarriedEcoGoods/MoveEcoGoodsToHomeStorage pair, covering Wool/RawRibs/Hides/Cloth/Leather/Sausage/BreadLoaf/ApplePie/Pitcher/Apple/Cotton/WheatSheaf/Log - every item type the Eco-Chain vendors sell. No existing method changed. |
| SimPCActivityVarietyService.cs (one line) | SimPCActivityProfileKey.EcoShopping added to the WorkProfiles array - see problem 3. |
| SimPCManagedPlayerVendor.cs / SimPCVendorMarketService.cs (unchanged) | Already existed, already correct - every sim's real PlayerVendor and its automatic restock-from-storage loop. Nothing here needed to change once goods actually started reaching GetStarterStorage(). |
K.4Commands
| Command | Description |
|---|---|
| [SimPCEcoShoppingStatus | New. Every sim currently buying from Eco-Chain vendors - phase (shopping/hauling home/stashing), location, goods carried - plus the shard-wide total sitting in home storage waiting for the restock loop. The single most useful diagnostic built this pass; splits "did a sim buy something" from "did the goods actually reach the vendor" instead of leaving that as one conflated question. |
| [SimPCActivityScores <sim> | Pre-existing. can=/score= for every registered activity on one sim - the direct way to confirm whether EcoShopping is actually competitive for that particular sim, rather than assuming a shared baseline score means a shared real chance of being picked. |
| [SimPCForceActivity <name> <profile> | Pre-existing. Forces a sim directly into a named activity, bypassing the scoring competition - the fast path for testing the loop mechanics without waiting on natural selection. Multi-word sim names need quoting ("Aeris Ridgway", not Aeris Ridgway) - the command splits arguments on whitespace with no awareness of multi-word names, a known gap not yet fixed. |
| [SimPCTick all | Pre-existing. Force-ticks the whole population immediately via SimulatedPlayerDirector.ForceTick, bypassing every cooldown - the same real scoring competition as the natural loop, just not waiting on the clock. Does not bypass SimPCWorkCommitmentService's at-home requirement to switch activities - a sim out in the field stays out in the field no matter how many times it's force-ticked. |
K.5Dashboard Visibility
StaffWebStatus.cs gained a new "SimPC Eco-Chain Purchases" panel, placed directly after Farming - buys today (resets at UTC midnight), buys all-time since last restart, and the current stats day. In-memory only, same as every other pass's own daily counters that haven't been given persistent storage - a restart resets it to zero along with the day itself. A future pass could persist this the same way WorldHistoryService.cs's daily snapshots already survive restarts, if the daily number needs to stay meaningful across a reboot.
K.6Known Follow-Ups, Not Done This Pass
Trader-leaning role bias - EcoShopping's baseline score (problem 2) competes flat for every sim regardless of role, the same gap Appendix F's Fisher/TreasureHunter Boating bias already solves for Seafaring. A SimPCRolePlanner bias for a Trader-leaning archetype is the natural next step, deliberately left separate from this pass's fixes.
Multi-word sim name parsing - SimPCForceActivity splits on whitespace with no quoting-aware fallback; quoting the name works around it today but the command itself hasn't been hardened.
Farming/Shepherding personal-shelf-cap gate - the same bug shape as Appendix J.3's confirmed Fruit-pool fix (a personal sell-shelf cap gating the shared pool deposit, not just the personal shelf) was found by inspection in UltimateNPCFarmingActivity.cs and is likely present in UltimateNPCShepherdingActivity.cs too. Orcharding and Woodcutting were fixed this pass; Farming and Shepherding were flagged but not yet touched, since nothing was actively broken there at time of writing (Grain pools were all reading healthy).
K.7Status
Confirmed live end to end - a sim was watched travelling to a vendor, buying real stock ("Buying from Ona. Carrying 5 eco good(s)"), hauling it home, depositing it (storage count moved from 0 to 5), and the restock loop landing it on the sim's own vendor as real, priced, purchasable merchandise ("5 Pile of Wool... Price: 203... Assorted Goods Stocked By Cuthred Silverfang"), confirmed by opening the vendor gump directly rather than inferring it from log lines.
AI-Town — Memory, Reflection, and the Thoughts of Sosaria
Where Appendix K closed the loop between the Eco-Chain's economy and a real player-facing marketplace, this appendix closes a different loop - between an UltimateNPC's actual lived experience and what it sounds like when you talk to it. Every UltimateNPC already had a dynamically-built persona (job, mood, greed) and real AI conversation via SmartSpeechEngine, but that persona was rebuilt fresh, from nothing, on every single call - a shepherd who'd sold wool to the same customer forty times had no more to say about it than one who'd never sold a single fleece. This is the fix: a genuine implementation of the architecture from Park et al.'s Stanford paper (Generative Agents: Interactive Simulacra of Human Behavior, 2023 - the academic origin of what's since become known as the "AI Town"/"Smallville" concept), scoped to what this shard actually needs rather than the paper's full apparatus.
L.1The Three Pieces
The paper's architecture has three layers; this implementation deliberately builds two of them in full and adapts the third to fit constraints already established elsewhere in this shard's AI work.
| Layer | The Paper's Version | What Was Actually Built |
|---|---|---|
| Memory stream | Every observation logged with a recency and importance score, importance rated by a dedicated AI call per memory. | Built in full via UltimateNPCMemoryStream.cs, except importance is set deterministically by event type at the AddMemory call site rather than AI-rated - avoids a second API call per routine event, consistent with the canned combat taunt existing for the same reason elsewhere in this codebase. |
| Reflection | Triggered when cumulative importance since the last reflection crosses a threshold; a two-stage generate-questions-then-synthesize AI process producing several insights per pass. | Built in full via UltimateNPCReflectionService.cs with the same importance-threshold trigger, but a single-stage prompt asking for ONE synthesized insight per pass rather than several - see L.2 for why. |
| Planning | Agents form a rough daily intention and revisit it through the day rather than acting purely reactively. | Not built. UltimateNPCBrain's real-time activity scoring already handles what an NPC does all day adequately; this system's actual gap was specifically that conversation felt memoryless, not that behaviour did. |
L.2A Real Constraint, Worked Around Rather Than Fought
SmartSpeechEngine.CallApiAsync wraps every AI request in a fixed system prompt built for spoken dialogue ("keep replies SHORT... like real spoken dialogue... having an ongoing conversation"), which governs every existing NPC conversation on the shard. That instruction actively fights a request for "2-3 structured insights." Rather than edit a prompt this load-bearing, or risk the model simply ignoring the conflicting instruction, reflection asks for exactly one short first-person insight per pass instead - which reads naturally as "spoken-style" output even though nothing is actually said aloud, and works with the existing constraint rather than against it. Multiple insights happen across multiple future reflections instead of one crowded call - the same "reflections can become memories that feed later reflections" shape the paper describes emerges naturally, since reflections are themselves stored back into the memory stream as new, high-importance memories.
L.3Feeding The Stream - Five Sale Hooks
A memory stream with nothing writing to it is inert. The first (and, at time of writing, only) event type wired in is a completed real sale - chosen because it's naturally self-throttling (a real customer has to show up and buy, not something that fires on a timer the way harvesting does) and because it directly connects to Appendix K's SimPC EcoShopping loop: every time a sim buys from a vendor, that vendor now remembers it. Five call sites, all importance 2 (routine but worth having on record): UltimateNPCTradingActivityBase.TrySell (the shared base for Weaving/Tanning/Cooking/Milling/PieBaking/CiderMaking - one edit covers six activities), plus the four standalone TryDirectPurchase methods on UltimateNPCOrchardingActivity, UltimateNPCWoodcuttingActivity, UltimateNPCFarmingActivity, and UltimateNPCShepherdingActivity, each of which implements its own version rather than sharing a base class.
L.4Closing The Loop - Persona Injection
The actual payoff, and the smallest change of the whole appendix: UltimateNPC.Persona (the getter every AI call already reads, rebuilt fresh from job/mood/greed on every call) gains one line appending UltimateNPCMemoryStream.BuildPersonaContext(this) before its closing "keep replies short" instruction. That method returns the NPC's most recent reflection if it has one ("Lately you've been thinking: ...") plus its 1-2 most recent raw memories for texture even before any reflection exists - most NPCs spend a long time in that pre-reflection state, and leaving the persona unchanged until then would make the whole system feel dormant even though it's quietly accumulating the whole time. No change was needed anywhere in SmartSpeechEngine.cs or any conversation-handling code - every future AI call for that NPC simply carries real context because the persona it's given already contains it.
L.5Files
| File | Purpose |
|---|---|
| UltimateNPCMemoryStream.cs (new) | Persisted singleton Item, same proven pattern as SimLifeEventLedger.cs - keyed by Serial rather than a string SimID since UltimateNPC has no SimID field. AddMemory/GetMemoriesFor/GetCumulativeImportanceSince/BuildPersonaContext. Capped 100 memories per NPC, 20000 shard-wide. |
| UltimateNPCReflectionService.cs (new) | 15-minute sweep, importance threshold 35 (Stanford's own agents used 150, tuned for far more granular daily action logging than this shard produces), capped at 5 reflections started per sweep - the same per-tick population budget shape as SimulatedPlayerDirector.ProcessBudgetPerTick, applied here before it became a third instance of that bug rather than after. |
| UltimateNPC.cs (one line) | Persona getter appends BuildPersonaContext - see L.4. |
| UltimateNPCTradingActivityBase.cs / UltimateNPCOrchardingActivity.cs / UltimateNPCWoodcuttingActivity.cs / UltimateNPCFarmingActivity.cs / UltimateNPCShepherdingActivity.cs (one AddMemory call each) | The five sale hooks - see L.3. |
| UNPCThoughtsWebExport.cs (new) | [UNPCThoughtsExport - same manual, on-demand pattern as SimBiographyWebExport.cs (a full population scan isn't run silently on a timer). Hand-written JSON to unpc_thoughts.json, skipping any NPC with zero memories. |
| thoughts.html (new) | "The Thoughts of Sosaria" - the public companion to biographies.html, same visual family (dark void background, parchment cards, detail overlay). Card grid shows name, role, mood badge, and current belief as an italic quote or an honest "hasn't had cause to reflect yet"; detail view adds the belief as a full pull-quote plus a chronological recent-goings-on list. |
L.6Commands
| Command | Description |
|---|---|
| [UNPCMemoryStatus <name> | One NPC's full memory list (importance, age, raw vs. reflection), plus last reflection time and live progress toward the next one ("16/35"). |
| [UNPCMemoryOverview | Every NPC with at least one memory, sorted closest-to-reflecting first - the population-wide companion to UNPCMemoryStatus, same relationship SimPCEcoShoppingStatus has to checking one sim at a time. |
| [UNPCThoughtsExport | Writes unpc_thoughts.json for thoughts.html - see L.5. |
L.7Design Principles Carried Over From Prior Art
A ServUO community project (uo-llm-npc, MIT licensed) independently reached a similar architecture and documented four hard-won rules worth keeping in mind as this system grows, since this shard has already converged on the same discipline without naming it: the LLM is never in the simulation loop (world state advances deterministically in plain C#; the model only ever generates words - see UltimateNPCBrain's real-time scoring versus SmartSpeechEngine's separate AI calls); every AI call is fail-open with a deterministic fallback on timeout (SmartNPC.FallbackLines already does this); off-screen NPCs cost nothing (the Hot/Warm/Cold fidelity tiers already do this); and - the one most worth holding onto deliberately as reflection grows more capable - actions stay a hardcoded allowlist, never free text. If a future reflection is ever allowed to influence something mechanical (a price adjustment, say), the AI's output should map to a small, fixed menu of safe responses a keyword check selects from, not be trusted to act freely. Words should stay words.
L.8Real Results, Confirmed Live
Two shepherds' actual reflections, produced from real sale memory with no scripting: "Been a right good season with all this trading, so I reckon I'll expand my flock come spring to keep up with the demand" and "Been a good stretch of honest work, and it looks like Wulfwaru and the others keep coming back for quality goods, so I'll keep my herds well-tended." The second one directly names a real repeat customer. A third NPC's reflection went further still, differentiating between customers by name: "Otta's been takin' far too much of me stock, so I'll be savin' the better cuts for Jace and Wulfnan from here on out" - a genuine, unscripted preference for two specific named customers over a third, arising entirely from real transaction history. In direct conversation, an NPC was observed volunteering unprompted, accurate detail about its own actual sales history and named customers ("Ah, you know Hereward then? He's been a good customer of mine - buys regular and pays fair") rather than generic dialogue - live confirmation that persona injection (L.4) is actually shaping what gets said, not just what gets remembered.
L.9Known Follow-Ups — All Four Now Built (see L.11)
As of v16 this section listed four flagged-but-unbuilt event types. All four shipped in the 2026-08-06 pass and are documented in L.11: theft/mugging (UltimateOrc.cs), region resource stress and nearby IDOC house collapses (WeatherTownHooks.cs and IDOCWatcher.cs respectively), repeat-customer tallying (UltimateNPCCustomerLedger.cs, not originally listed here by name but the same “regulars by luck of the memory window” gap this section already gestured at), and conversation summaries (ConversationMemory.cs + UltimateNPCConversationBridge.cs). The original v16 wording is kept below for the historical record of what was flagged and why.
Only one event type feeds the stream - real sales. Theft/mugging (a genuinely high-importance event, 7-8 rather than 2, but belongs to UltimateOrc/EcoOrc, a different class targeting real players rather than other NPCs - worth a separate design conversation about whether it shares this same memory stream or needs its own), region resource stress (the Eco-Chain's own fill/harvest-pressure data would make a natural, cheap hook), conversation summaries (ConversationMemory.cs's 15-minute chat history currently just expires and is lost rather than being folded into one memory line), and nearby IDOC house collapses (the very first thing discussed at the start of this whole project) are all flagged but unbuilt.
Retrieval is recency + importance only - no semantic relevance scoring. Real semantic retrieval needs embeddings; a local embedding model (paired with a vector store) is the standard answer so real-time retrieval doesn't need its own AI API round-trip on top of the reflection calls that already happen - confirmed as a real, working pattern by the same uo-llm-npc project (L.7), which pairs a local Ollama model with Qdrant for exactly this. Not needed yet; worth revisiting once retrieval, not logging, is the bottleneck.
SimPC and UltimateNPC still keep entirely separate memory/ledger architectures (SimPCSocialLedger.cs, SimPCWorldLedger.cs, SimLifeEventLedger.cs versus this appendix's own files). Whether those should ever converge toward one shared system, or stay deliberately separate since SimPCs and UltimateNPCs serve different roles in this world, is an open design question, not a bug.
L.10Status
Confirmed live end to end, under real production traffic rather than forced test purchases - real SimPCs buying from real vendors, memory accumulating, reflections firing on schedule with individualized, specific content (L.8), and that content genuinely shaping live conversation. Public web page (thoughts.html) confirmed loading real exported data through an actual web server, matching the existing biography page's pattern and visual identity.
L.11Four Follow-Ups From L.9, Now Built
All four items L.9 flagged as unbuilt as of v16 shipped in this pass, each as a small, additive, self-contained file feeding the same UltimateNPCMemoryStream every other AI-Town hook already uses - no changes needed to the memory stream or reflection service themselves.
| Follow-up * | *File(s)** * | *What it does** |
|---|---|---|
| Theft / mugging | UltimateOrc.cs (existing class, extended) | A real gold-theft mechanic against a nearby, unengaged real player - separate from EcoOrc’s own resource-pool raiding, which is untouched. Stolen gold physically lands in the orc’s own backpack (killing it recovers what was taken). A successful mugging pushes a region-wide memory via AddMemoryToRegion at importance 7 - noticeably higher than EcoOrc’s own routine importance-6 pool/field raids, since a resident actually losing coin to a mugger is a bigger deal to the town than the ecology being nibbled at. |
| Region resource stress / IDOC-collapse proximity | WeatherTownHooks.cs (new) | Polls the same per-region average grass fill EcologySystem already tracks internally, cross- referenced against WorldConditions.GetGrowthMultiplier at each node’s own location. A region stuck low under a harsh multiplier for three consecutive 10-minute checks gets a “drought” memory (importance 5); the mirror-image sustained bumper-conditions case gets a positive one (importance 3). Both cooldown-gated 2 real hours per region so a droughty winter produces one memory, not one every poll. The same file also pushes a one-time, importance-4 memory to every region the moment WorldConditions.CurrentSeason actually rolls over. |
| Nearby IDOC house collapses | IDOCWatcher.cs (existing file, extended) | The shard’s namesake feature - polls BaseHouse.AllHouses on a timer and diffs DecayLevel against last tick to catch ENTERED IDOC / COLLAPSED / SAVED transitions, alerting Discord and (for ENTERED/COLLAPSED) the whole server. ENTERED IDOC and COLLAPSED now also push a memory into whichever region the nearest living UltimateNPC (within 60 tiles, same map) calls home - resolved by proximity since houses have no region field of their own. COLLAPSED is importance 8, the highest of any routine AI-Town hook - “the very first thing discussed at the start of this whole project,” now genuinely closing that loop. A house with nobody nearby to have noticed gets no memory pushed at all. |
| Repeat-customer tallying | UltimateNPCCustomerLedger.cs (new) | An explicit per-NPC “who buys from me and how often” tally, replacing what L.9 called “regulars by luck of the memory window.” One entry point, RecordSale(npc, buyer), called from all five existing sale-hook sites (L.3). Crossing a milestone count (3/5/10/20 purchases) fires a second, higher-importance memory (3 through 6) noting the buyer’s now a regular, alongside - not replacing - the existing per-sale memory. In-memory only (a restart resets the running count, not the memories already formed), with a small recent-milestones ring buffer feeding the new staff dashboard panel (L.11 dashboard note below). |
| Conversation summaries | ConversationMemory.cs (extended) + UltimateNPCConversationBridge.cs (new) | ConversationMemory.cs stays deliberately unaware of what an UltimateNPC or a memory stream even is - it now just raises a ConversationExpired event carrying the raw chat turns whenever a session clears for inactivity (previously the turns just vanished). UltimateNPCConversationBridge.cs is the one subscriber that cares: it folds an expired conversation into a single, deterministic (non-AI-generated) memory line - who, how many exchanges, and the last thing said - so BuildPersonaContext gets real texture without spending another SmartSpeechEngine call on an off-screen event. |
Dashboard visibility. StaffWebStatus.cs gained two new panels alongside these: an “AI-Town Memory Feed” panel showing every notable event (importance 5+) across all UltimateNPCs in the last 48 hours, newest first - raids, muggings, IDOC events, and drought/bumper weather all surface here in one place - and a “Regular Customers” panel listing recent repeat-buyer milestones from UltimateNPCCustomerLedger.
L.12Status (2026-08-06 pass)
All four L.9 follow-ups confirmed built and wired into the existing memory stream with no changes needed to UltimateNPCMemoryStream.cs or UltimateNPCReflectionService.cs themselves - every one of them uses the same AddMemory / AddMemoryToRegion entry points already proven out by the original sale hooks and orc-raid hooks. Dashboard panels for both the memory feed and the customer ledger are live on StaffWebStatus.cs.
SimPC Housing Fairness — Balancing Population Across the Eco-Chain Regions
Where Appendix K closed the loop between the Eco-Chain economy and a real player-facing marketplace, this appendix closes a gap on the population side of that same loop: three of the six Eco-Chain regions (Britain, Skara Brae, Yew) were showing zero EcoShopping traffic while the other three (Trinsic, Vesper, Cove) were visibly busy, even though every region’s grass/sheep/traders/shepherd were confirmed working identically. The cause turned out to be genuine - a real population imbalance, not a bug in the trader, purchase, or memory systems.
M.1The Investigation
Live-diagnosed via a chain of real data rather than assumed: a housing distribution readout, two consecutive EcoShopping status snapshots taken three minutes apart, staff-status-page pool-depletion figures per region, and direct in-game props-panel screenshots confirming CommittedActivity: EcoShopping at a busy region. Britain/Skara Brae/Yew held roughly 6.6% of the population combined and showed no EcoShopping activity in-game; Trinsic/Vesper/Cove held the rest, with Trinsic - the heaviest-populated of the three - also showing the heaviest EcoShopping traffic. The gradient held once framed as “big three vs. small three” rather than a strict per-region ranking.
M.2Root Cause
SimPCSpawnPolicy.TryFindAutomaticSpawnAnchor() anchors every new sim’s spawn point on a real online player, or - with nobody online - on an already-settled SimPC. It has zero awareness of the six Eco-Chain regions at all. That’s a rich-get-richer loop: population clusters wherever real players already spend time, and the “fall back to an existing SimPC” branch then perpetuates that same cluster forever once nobody’s online to re-anchor it elsewhere.
M.3Files Built
| File * | *Purpose** |
|---|---|
| SimPCHousingDistribution.cs (new) | Diagnostic-only. Resolves every sim’s home to its nearest Eco-Chain region (same nearest-EcoGrassNode logic SimPCEcoShoppingActivity.ResolveRegionKey() uses) and prints a sorted per-region headcount + percentage to chat. |
| SimPCHousingRebalancer.cs (new) | The core fix. Confirmed, in-game-walked region centre coordinates (the same six EcoWorldSeeder / EcoFarmSeeder already use): Britain (1198,1823), Trinsic (1788,2672), Vesper (2795,750), Cove (2224,1150), Skara Brae (551,2119), Yew (689,1008). GetRegionCounts() resolves every sim to a region and counts; TryFindUnderservedRegionAnchor() picks whichever region has the fewest resolved homes (ties broken by fixed region order) and finds an open spawn point scattered near its centre. [SimPCHousingRebalanceStatus shows the live per-region counts and next target; [SimPCHousingRebalance <off|on|N> turns the correction off entirely, restores the default cadence, or sets a custom “every Nth cycle” frequency - existing sims/houses are never touched either way, this only steers where new population lands going forward. |
| SimPCPopulationManager.cs (extended) | Every RebalanceCycleFrequency-th automatic population cycle (default every 4th, 10-minute cycle), the manager skips the normal player-anchored spawn and anchors on the most underserved region instead, via a new shared TrySpawnAt(map, location, role, …) method extracted from the previously-inline spawn logic. Falls back to the normal player-anchored path automatically if the rebalance target can’t produce a valid spawn. |
| SimPCSeedBalanced.cs (new) | [SimPCSeedBalanced - for a full wipe-and-reseed. Neither pre-existing bulk-spawn command achieves region fairness on a fresh population ([SimPCPopulate anchors on the GM’s own location; [SimPCDistribute/ [SimPCDistributeAllFacets weight by land area with no Eco-Chain awareness). This splits evenly across all six region centres so a fresh population starts balanced rather than needing gradual correction afterward. Respects existing global/per-map/per-home population caps and reports exactly how many were created per region. |
M.4Commands
| Command D | escription |
|---|---|
| [SimPCHousingDistribution | Per-region SimPC home headcount + percentage, across all six Eco-Chain regions. |
| [SimPCHousingRebalanceStatus | Live per-region counts, which region the rebalancer would target next, and the current cadence. |
| [SimPCHousingRebalance <off|on|N> | Turns the automatic rebalance pass off entirely (organic/imbalanced growth resumes), on (restores the default every-4th-cycle cadence), or to a custom frequency of every Nth automatic population cycle. |
| [SimPCSeedBalanced | One-shot bulk seed, split evenly across all six confirmed region centres - built for the wipe-and-reseed scenario, capped at 1000 per use. |
M.5Bulk Re-Seeding Caps
Raising the population ceiling for a full reseed (e.g. to 900) needs three [SimPCConfig values set together, since all sims land on one map (Felucca): maxpop (global cap), permap (per-map cap), and homespermap (per-map home cap). A high global cap alone still silently truncates a bulk seed if either of the other two is left low.
M.6Status
Confirmed live at each stage: the housing-distribution readout matched the observed empty regions; the rebalance-status readout correctly targeted the lowest-count region as new population landed; and, after a full population wipe and a 900-count [SimPCSeedBalanced run, the six regions landed within a few sims of each other (150/154/154/152/150/140) rather than the original ~6.6%-vs-the-rest imbalance.
AI-Town Hardening & Player-Specific Memory — the 2026-08-07 Pass
This pass closed two real bugs found by inspection in Appendix L's own machinery, one small parsing gap in SimPC tooling, and built the two features L.9/L.11 left as the natural next step once memory and reflection were both confirmed live: giving each UltimateNPC a sense of who a specific player is to them, and letting sustained real experience actually move the mood system rather than just sitting in the memory stream unread.
N.1Region-Key Bug — Weather Memories Skipping Farmers, Thoughts Page Bucketing Farmers Under “Default”
One root cause, two symptoms, both traced to the same field mix-up. UltimateNPC.RegionKey is only ever set by the Shepherding and Trading activities; a Farming NPC's real region lives in PlotRegionKey instead, and RegionKey stays “default” for it for as long as it lives. Two consumers assumed every NPC used RegionKey uniformly: UltimateNPCMemoryStream.AddMemoryToRegion() (used by WeatherTownHooks to push drought/bumper-harvest memories to everyone in an affected region) was silently skipping every farmer in the region, and UNPCThoughtsWebExport.cs's JSON export was grouping every farmer into an “Unassigned”/“Default” bucket on the public Thoughts of Sosaria page regardless of which of the six regions they actually lived in.
Fix: both call sites now branch on activity — a Farming NPC is matched/labelled by PlotRegionKey, everyone else still uses RegionKey exactly as before. AddMemoryToRegion() gained an OR condition; the web export gained a small RegionLabelFor(npc) helper reused for the exported regionKey field.
Confirmed live: the freshest Thoughts of Sosaria export (2026-08-07) shows all 37 residents correctly grouped across the six real regions — Britain 6, Trinsic 7, Vesper 6, Cove 5, Skara Brae 2, Yew 11 — with zero left in a “Default”/“Unassigned” bucket, including every homed farmer (Nodin, Panyin, Liam, Brad, Syeira, Belana, Diamanta) now showing under their real home region.
N.2Farming Shelf-Cap — the Same Producer-Gating Bug Found in the Fruit/Wood Chain
Same shape as the earlier Fruit/Wood starvation fix, this time in UltimateNPCFarmingActivity.cs. EconomicPulse() was gating the call to EcoFarmPlot.HarvestForStock() on the farmer's own CropStock still being under MaxStock — but HarvestForStock() does two things at once: it deposits the harvest into the shared regional Grain pool AND resets the plot to Empty so it can be replanted. Gating the call on personal shelf space blocked both effects together, so a farmer sitting at a full personal shelf would stop feeding the Grain pool and stop resetting its own plot — a slow-building production stall hidden behind grain pools that otherwise read healthy on the dashboard, since other farmers in the same region kept the pool topped up.
Fix: HarvestForStock() is now called unconditionally whenever the plot is Ripe (so the Grain pool and the plot's replant cycle are never blocked), and only the amount kept on the farmer's own person is capped — keep = Math.Min(yield, MaxStock - npc.CropStock). Mirrors the pattern already used for Orcharding and Woodcutting after the earlier fix to those two.
Note: Shepherding's very similar-looking personal-shelf cap on wool/meat/hides was checked and confirmed NOT to share this bug — it withdraws from a pool that sheep and wolves feed independently via EcologySystem, so capping personal stock there doesn't block production the way it does for the three harvester-producer activities.
N.3SimPCForceActivity — Multi-Word Sim Names
[SimPCForceActivity <name> <profile> was parsing its arguments strictly positionally, which broke on any sim with a two-word name (the shard's default naming convention) unless the name was quoted — an easy trap for anyone typing the command by hand. Fixed to treat the last whitespace-separated argument as the profile and rejoin everything before it as the sim's name, so [SimPCForceActivity Aethelgyth Stormborn mining now works exactly as typed, with no quoting needed.
N.4Player-Specific Memory & Reputation
Every UltimateNPC's persona was, until this pass, blind to who it was actually talking to — a shepherd who'd sold wool to the same player forty times spoke no differently to them than to a total stranger, even though the memory stream (Appendix L) had been quietly capable of holding customer-specific detail the whole time. This closes that gap without building a second memory system: it reuses data that already existed.
How it works: UltimateNPC now overrides OnSpeech and OnDoubleClick to stash the current speaker in a transient (unserialized) field the instant either fires, before calling into the base class — the only point in the existing architecture where the NPC's Persona getter (read synchronously, deep inside SmartNPC.RespondTo) can learn who's asking. A new file, UltimateNPCPlayerReputation.cs, tracks “ever met this NPC” per (npc, player) pair via a per-NPC HashSet<Serial>, and reuses UltimateNPCCustomerLedger.GetPurchaseCount() — no new purchase ledger was built — for tiered recognition text at the same 3/5/10/20-purchase milestones the ledger already uses elsewhere. Persona now appends one line describing the current speaker (stranger / recognized / regular customer, by tier) right before its closing “keep replies short” instruction.
Deliberately scoped as dialogue-only, matching the design discipline in L.7 — this never gates trade mechanics, prices, or what an NPC will sell to whom. It only changes what an NPC says.
New command: [UNPCPlayerRep <npc name fragment> — lists every player one UltimateNPC has met and/or bought from, i.e. the data behind its persona's player-recognition line.
N.5Mood-as-Drift
Investigated after a live screenshot showed a real gap: an NPC (Dembe) with eight consecutive orc-raid memories over roughly two hours still reporting CurrentMood as Cooperative. The mood-pressure fields (Fear, Anger, Desperation, Confidence, TrustInPlayers, RecentProfit) that drive CurrentMood were confirmed to be touched only by direct trade events (OnProfitableAction, RecordGoodDeed, RecordPredatoryAct) — the memory stream and mood system had been running side by side with no connection between them at all, so sustained real experience (repeated raids, a hard drought, a run of good trading) never actually moved how an NPC felt, only what it remembered.
New file, UltimateNPCMoodDrift.cs, adds a self-contained Timer (10-minute cadence, 4-minute initial delay after startup) that scans each NPC's memory stream for entries newer than a per-NPC “last scanned” bookmark (new or never-scanned NPCs get a 1-hour initial backlog), classifies each entry by keyword-matching the same templated memory text already used shard-wide (negative: mugged, orc raiders, came down/collapse, struggling to grow/dry spell; positive: sold, regular, kind lately/growing conditions), sums the signed Importance across the batch, and nudges the existing Fear/Anger/TrustInPlayers/Confidence public setters (already clamped, already triggering RecalculateMood()) by a small capped amount — at most 8 points per pass. Deliberately drift, not flip: a single bad memory shouldn't swing an NPC's whole disposition, but a sustained run of them now does.
New command: [MoodDriftCheck — forces an immediate drift pass across every UltimateNPC instead of waiting for the 10-minute timer, for testing.
N.6Status
All five items confirmed present and correct in the 2026-08-07 dev build reviewed for this pass (2026 Stuff Claude built DEV.rar): the region-key branch verified in both UltimateNPCMemoryStream.cs and UNPCThoughtsWebExport_1.cs, unconditional HarvestForStock() verified in UltimateNPCFarmingActivity.cs, the rejoin-then-parse fix verified in SimulatedPlayerCommands.cs, and both new files (UltimateNPCPlayerReputation.cs, UltimateNPCMoodDrift.cs) present with correct unqualified Usage/Description attributes — the one compile error reported during testing (CS0234, attributes incorrectly fully-qualified as Server.Commands.Usage) was in an interim delivery only and is not present in this build. Region-key fix confirmed live via the freshest Thoughts of Sosaria export (N.1). Player reputation and mood drift are newly built this pass and awaiting their own live confirmation pass the way L.8 was confirmed for reflection — worth a follow-up note once an NPC's persona has been observed live using a player-recognition line, and once a MoodDriftCheck has been run against an NPC with a real recent raid/drought/trading streak.
Wood & Ore Chains, Dragon Gossip, and Job Mechanics — the 2026-08-09 Pass
This pass closed the Wood chain's missing consumer, built the Ore chain from scratch (it previously had neither a producer nor a consumer), fed real wild-dragon activity into the AI-Town memory stream for the first time, and traced two findings from the 2026-08-10 production snapshot review (mass “Wary” mood, and a wealth outlier pair) to their actual mechanisms in code.
O.1Wood Chain Completed — Carpentry (the Consumer Half)
Woodcutting (9.4) already deposited into the region's Wood pool on every chop, but nothing drew it back down — the same “half-built chain” shape Milling closed for Grain. UltimateNPCCarpentryActivity.cs closes it: a Trading-family job (subclasses UltimateNPCTradingActivityBase, same engine as Weaving/Tanning/Cooking/Milling) drawing Wood at 2:1 into Boards, base price 3gp. It reuses the NPC's existing generic TradeStock/TradeSourceBuffer/TradeBasePrice fields, so no UltimateNPC.cs changes were needed for this half.
Files: UltimateNPCCarpentryActivity.cs (new). UltimateNPCActivity.cs — added Carpentry to the UltimateNPCActivityKey enum. UltimateNPCBrain.cs — registered UltimateNPCCarpentryActivity.Instance.
O.2Ore Chain Built From Scratch — Mining & Blacksmithing
Ore previously had no presence in the Eco-chain at all — confirmed by grep: the only references to RegionalResourcePool.ResourceType.Ore anywhere were read-only status-page display code. Both halves were built together, mirroring the Wood chain's own producer/consumer split exactly.
EcoOreVein.cs (new, alongside EcoTreeNode.cs) — the mineable node. Same shape as EcoTreeNode: regrowth toward a cap, weather-scaled via WorldConditions.GetGrowthMultiplier, visual swap between a full-vein and depleted-vein graphic when mined out. Default MaxAmount 120 / RegrowthRate 1 / MineableThreshold 60 — deliberately slower than Wood's defaults (150/1/80), so ore reads as scarcer than timber, per the geological framing in the file's own header. Graphic IDs (0x1367 full / 0x1363 depleted) are explicitly placeholders — cosmetic only, swap for real mountain/cave rock statics before going live.
UltimateNPCMiningActivity.cs (new) — the producer, a direct mirror of UltimateNPCWoodcuttingActivity.cs: sells raw IronOre off the NPC's own stock (OreStock/OrePrice, new fields on UltimateNPC.cs), and deposits into the region's Ore pool on every successful dig.
UltimateNPCBlacksmithingActivity.cs (new) — the consumer, a Trading-family job (subclasses UltimateNPCTradingActivityBase) drawing Ore at 2:1 into IronIngot, base price 7gp — a shade above Cloth's 6gp, reflecting a more labor-intensive smelt.
EcoOreVeinCommands.cs (new) — [EcoOreVein / [EcoOreVeinFill / [EcoOreStatus, mirroring EcoOrchardWoodCommands.cs's [EcoTreeNode / [EcoTreeNodeFill / [EcoOrchardStatus exactly. Important operational note: unlike Wood (which already had trees seeded shard-wide), nothing places ore veins automatically anywhere — [EcoOreVeinFill <regionKey> must be run once per active region before any Miner will find work.
UltimateNPC.cs — added OreStock/OrePrice fields (mirrors WoodStock/WoodPrice), bumped the save version 2→3, and added the version-gated serialize/deserialize block (pre-v3 saves default both to 0, same discipline as every prior version bump on this class). UltimateNPCActivity.cs — added Mining and Blacksmithing to the enum. UltimateNPCBrain.cs — registered both new activities. UltimateNPCRegionFinder.cs needed no changes — it was already generic on ActivityKey.
O.3How UltimateNPCs Actually Find Work — Harvester vs. Trader Mechanics
Worth documenting precisely, since it isn't obvious from outside the code and governs where every new node-based job (including Mining) can actually be placed. Every UltimateNPC's UsesDayNightCycle flag — not its job category — decides which of two completely different behaviors it follows. The flag defaults to true and is overridden false only by Shepherding and by UltimateNPCTradingActivityBase (so every Trading-family job — Weaving, Tanning, Cooking, Milling, Carpentry, Blacksmithing — inherits false).
Harvesters (UsesDayNightCycle = true — Farming, Orcharding, Woodcutting, Mining): each has a HomePoint, captured once and permanently — wherever the NPC happened to be standing the very first time it thinks after being placed. They sleep at home overnight, path (real PathFollower-driven walking) out to a physical work site each morning, work there all day, then path home at night. For Mining, the work site is GetWorkSiteLocation() → EcoOreVein.FindNearestMineable(npc.Map, npc.HomePoint, 200) — the nearest mineable vein within 200 tiles of home, not of the NPC's current position. If nothing mineable exists within that radius, the Miner still has the job assigned and simply finds nothing to do, tick after tick, with no warning. Placement matters: a Miner only works if its spawn point lands within 200 tiles of ground you've run [EcoOreVeinFill over.
Traders (UsesDayNightCycle = false — Shepherding and every Trading-family job): never sleep or commute. They stand near HomePoint and pull raw material straight out of the region's shared RegionalResourcePool on a 5-second timer (EconomicPulse) — there is no physical node to walk to at all. A Carpenter or Blacksmith can be placed almost anywhere in the region and will still work, as long as its claimed region (see O.4) actually has stock in the pool.
O.4Region Tags Are Not Geometry
A second mechanic worth being explicit about: “region” has no geometric definition anywhere in this system — no polygon, no map boundary, nothing tied to the game's own named areas. It is a plain string tag (from EcologySystem.cs's ActiveRegions whitelist: britain, trinsic, cove, vesper, skarabrae, yew) attached to a node purely because a GM typed that word in when placing it. A location only “belongs” to Vesper because someone stood there and ran a placement command — [EcoNodeFill, [EcoTreeNodeFill, [EcoOreVeinFill, etc. — with “vesper” as the argument.
Which region an NPC's activity actually claims (including Mining) is decided by UltimateNPCRegionFinder — nearest EcoGrassNode to the NPC's HomePoint, by straight-line distance — not by nearest tree or ore vein. The subsequent physical work-site search (O.3) is a separate, untagged 200-tile radius lookup that ignores the node's own RegionKey entirely. In practice the two line up because grass, tree, and ore nodes for a given region are placed in the same physical cluster — but nothing in code enforces that; it's a placement convention, not a guarantee. Keep new node placements physically close to that region's existing grass nodes to avoid a Miner claiming “vesper” via a nearby grass node while its actual nearest ore vein sits tagged “trinsic” two screens away.
O.5DragonTownHooks.cs — Dragon Activity Joins the Memory Stream
Spotted directly from a live thoughts.html export: every “Wary” belief on the shard was about orc raiders — nothing anywhere referenced the wild EcoDragon, despite it roaming the same six regions and its death already being a shard-wide broadcast. It simply never fed UltimateNPCMemoryStream the way raids do.
Built as a zero-edit companion file — same “poll, don't hook” philosophy as WeatherTownHooks.cs — rather than adding event hooks into EcoDragon_5.cs itself. Polls EcoDragon.Instance every 10 minutes (2-minute initial delay after startup) and diffs it against what it last saw, pushing regional memories for three event types: Sighting (region changed, importance 4), Kill nearby (KillCount increased, importance 6 — deliberately matched to an orc raid's weight), and Slain (instance went from alive to gone, importance 7, the single biggest thing that can happen to a region's ecology).
New command: [DragonTownCheck — forces an immediate check instead of waiting for the 10-minute poll, for testing.
O.6Investigated: Mass “Wary” Mood, and the Wealth-Outlier / Repeat-Buyer Link
Two findings from the 2026-08-10 snapshot review, followed to their actual mechanism in code rather than left as observations.
Mood: 34 of 37 UltimateNPCs reading “Wary” traced to UltimateNPCMoodDrift.cs. ClassifySentiment() classifies any memory containing “orc raiders” as negative, and the negative branch of ApplyDriftFor lowers TrustInPlayers for every negative memory regardless of cause — so orc-raid memories, which have nothing to do with player behavior, still drag TrustInPlayers below the 35 threshold RecalculateMood() uses to flip to “Wary.” Fix identified but not yet made: raid/weather-type negatives should push Fear/Anger only; TrustInPlayers should be reserved for genuinely player-caused negative events.
Wealth: cross-referencing the two extreme-wealth outliers from the 08-10 snapshot (Wren Moonstrike 29.5M gp, Hadrian Darkraven 35.9M gp, both Hauler Association) against the separate Regular Customers panel confirmed both are also the shard's heaviest repeat buyers from UltimateNPCs (milestones up to #20/#10, versus everyone else topping out around #3-5). A real, evidence-based correlation — still points at the Eco-Chain vendor purchase path (a credit/debit sign-flip or double-credit somewhere around TrySell / ConsumeGoldFrom) as the likely mechanism, but the actual bug has not yet been located in code. Next step: [SimPCInfo on both sims in-game, or an audit of UltimateNPCTradingActivityBase.TrySell against the Hauler Association purchase path specifically.
O.7New Commands
| Command | Description |
|---|---|
| [DragonTownCheck | Forces an immediate wild-dragon AI-Town memory check (sighting/kill/slain) instead of waiting for the 10-minute poll. |
| [EcoOreVein <regionKey> | Places a single EcoOreVein at your feet for the given region. |
| [EcoOreVeinFill <regionKey> | Places a 5x5 grid of EcoOreVeins (10-tile spacing) centred on your location. Run once per active region before Miners will find any work. |
| [EcoOreStatus | Per-region report for the Ore chain: vein counts, pool levels, and how many Miners/Blacksmiths are working each region. |
O.8Status
Carpentry, Mining, and Blacksmithing were written and verified for brace/paren balance this pass but have not yet been compiled against the live dev/prod tree, dropped in, or seeded (no ore veins exist anywhere in the world yet — [EcoOreVeinFill has not been run). DragonTownHooks.cs is similarly delivered but unconfirmed live. Both the mood-drift TrustInPlayers fix and the wealth/purchase-path bug are diagnosed but not yet fixed — carried forward as open items. Still to build, per the confirmed roadmap: a Fish chain (new ResourceType, a Fisherman activity, a trader) and a small Rabbit trickle into the existing Meat/Hides pools.
Orc Raid Pacing & Militia Response — the 2026-08-11 Pass
Prompted by a simple field observation - “I think they may be raiding too much” - rather than a bug report. Investigated by reading EcoOrc.cs (the base raiding class) directly instead of guessing from the personality layer alone, which is what actually surfaced the real mechanism: raids were near-guaranteed to succeed, took a flat cut regardless of how depleted the target already was, and were completely uncoupled from the region's own health or from each orc's personality. This appendix covers the fix in four layered pieces, plus the militia response system that grew out of the same conversation.
P.1The Diagnosis
EcoOrc.OnThink() drives each orc's own raid clock independently (m_NextRaidTime, on a 40–55 minute cycle). DoRaid() almost always succeeded - it only failed outright if both the resource pool AND every grass node in the region were already at zero. RaidPool()/RaidFields() then took a flat 15–30%/50% cut every time, with no read on how depleted the target already was. On top of that, UltimateOrcRaidingPartyService reinforced a region back to its configured headcount every 5 minutes with no cooldown while any survivors remained, and every new reinforcement started its own independent raid clock the moment it spawned. None of these four things talked to each other, so a region under real pressure got raided at exactly the same rate as a region that had barely been touched.
P.2Raid Softening & Outcome-Driven Pacing (EcoOrc.cs, EcoOrcRegionHealth.cs)
EcoOrcRegionHealth.cs is a new shared static helper (Server.Ecology) exposing IsPoolDistressed, IsGrassDistressed, IsRegionDistressed, GetPoolTotal, and GetGrassStats, built only against APIs already confirmed to exist on this shard (RegionalResourcePool.GetCurrent, EcoGrassNode.CurrentAmount/RegionKey) - no assumption was made about a Max/Capacity field on either type. RaidPool() and RaidFields() now check region health before taking their cut: a distressed region has a 55% chance the raid is held back entirely, and any raid that does land against a distressed region is scaled down to 40% of its normal take. A held-back raid is tracked (m_LastRaidHeldBack, m_LastRaidStrength) and does not count as a successful raid for any downstream system - including militia triggering, below. Separately, ComputeNextRaidDelay() makes the raid clock itself responsive to outcome: a strong haul stretches the next interval out, a held-back or weak raid shortens it, bounded to a 25–75 minute range - so a region's raid pressure now actually breathes with its own resource state instead of ticking on a fixed schedule regardless.
P.3Personality-Driven Raid Participation (UltimateOrc.cs)
UltimateOrc already carried six personality fields (Aggression, Greed, Cowardice, RiskTolerance, Confidence, Anger) that drove mugging odds, dialogue, and combat taunts - but had no bearing at all on the actual raiding decision it inherits from EcoOrc, so a cowardly orc raided exactly as often as a bold one. MaybeDeferRaidForPersonality() now runs before base.OnThink() each tick (the point where EcoOrc's own logic fires DoRaid() once due), computing a 5–60% chance - driven by Cowardice minus Aggression, plus half of (40 minus RiskTolerance) - that this particular orc sits its due raid out and tries again shortly after. RaidSkipChance is exposed as a CommandProperty and shown on [UOrcInfo, alongside whether the orc's region currently reads as distressed.
P.4Reinforcement Throttling (UltimateOrcRaidingPartyService.cs)
The auto-reinforcement loop now checks EcoOrcRegionHealth.IsRegionDistressed() before topping a region back up toward its configured headcount - a distressed region simply doesn't get reinforced that cycle, so pressure isn't compounded by fresh raiders arriving on top of an already-thin region. This gate applies only to the “still has survivors, under target” branch, not the full-wipe recovery branch - a region that's been wiped out still recovers normally. [UOrcAutoRegionStatus reports “reinforcement paused - region distressed” when the gate is active.
P.5Militia Response — “Someone Fight the Orcs Off” (EcoOrcMilitia.cs, EcoOrcMilitiaService.cs)
A follow-on idea from the same conversation: a region taking real, sustained raiding damage should visibly defend itself, without adding a real difficulty check or a standing garrison. EcoOrcMilitiaService tracks successful (non-held-back) raids per region in a rolling 60-minute window; once a region crosses 3 raids in that window, it spawns a band of 2–4 EcoOrcMilitia at the location of the raid that tripped the threshold, then goes on a 30-minute cooldown before it can trigger again - an event, not a standing response. RecordRaid() is called directly from EcoOrc.cs's two raid-success paths (one new line each), so no polling timer was needed; the raid itself is the trigger.
EcoOrcMilitia is a fresh, disposable BaseCreature rather than a real SimPC pulled into the fight - deliberately weaker than a rank-and-file EcoOrc (modest Str/Dex/Int, 35–50 HP, 3–7 damage, Swords 50–65) so the design intent is “pure spectacle, usually loses” rather than a real threat to the raiders. Engagement is self-directed - each OnThink, if not already fighting, a militia looks for the nearest live EcoOrc tagged to its own region within a 15-tile radius and engages it - the same pattern EcoOrc.cs itself uses for sheep-hunting. Every militia disperses (self-deletes) 15 minutes after spawning regardless of outcome, win or lose. First live test (2026-08-11): the band spawned correctly, engaged, and was wiped out entirely - consistent with the “usually loses” design intent chosen for this feature; stat tuning (HP/damage/skills, band size, or a flee-at-low-health threshold instead of fighting to the death) is a follow-up to revisit once more real responses have been observed, not a bug.
Raiding itself is not proximity-based, which matters for camp/spawn placement: DoRaid() raids by RegionKey against that region's pool and grass nodes directly, with no distance check against the orc's actual location - an orc spawned well outside camp still raids the camp's region normally as long as RegionKey is set correctly. Militia spawn at the raiding orc's own current location (not the camp), so moving raider spawns away from camp doesn't reduce militia's chance of finding them - if anything it reads better narratively (“militia rally out to the raiders' den” rather than appearing beside camp).
P.6Status Page & Command Gump Integration
StaffWebStatus.cs's existing “Orc Raid Activity” panel gained: a region-distress summary line, a [HELD BACK] tag on any raid row where it applies, a currently-held-back count in the panel footer, and (this pass) an active-militia count and region list, plus a militia count in the footer. UnifiedWebStatus_fixed.cs gained a new Militia= map-marker export line in the map-data timer (Part 1, alongside the existing Warband= orc markers) and a “Militia Responding” row on the public “Orc Raiders” panel (Part 2) when a band is currently active. [EcoOrcTick and [EcoOrcStatus show held-back flags; [UOrcInfo shows RaidSkipChance and region distress. Both new militia commands - [EcoOrcMilitiaStatus (per-region raid pressure + active militia + last response time) and [EcoOrcMilitiaForce <regionKey> (forces an immediate test response) - are registered but were not yet in IDOCCommandGump.cs before this pass; added to the Eco-Chain → Orcs section alongside [EcoOrcStatus/[EcoOrcTick as part of today's gump update.
P.7Files
| File * | *Purpose** |
|---|---|
| EcoOrcRegionHealth.cs (new) | Shared static health-check helper (Server.Ecology) - IsPoolDistressed, IsGrassDistressed, IsRegionDistressed, GetPoolTotal, GetGrassStats. Built only against confirmed RegionalResourcePool/EcoGrassNode APIs. |
| EcoOrc.cs (extended) | Raid softening/hold-back against a distressed region, outcome-driven ComputeNextRaidDelay(), a pre-existing DoRaid() fallback-type bug fixed along the way, and the one-line EcoOrcMilitiaService.RecordRaid() call at the end of each successful raid path. Serialization version bumped for the two new persisted fields. |
| UltimateOrc.cs (extended) | ComputeRaidSkipChance() / RaidSkipChance CommandProperty / MaybeDeferRaidForPersonality(), called before base.OnThink() so a cautious orc can skip a due raid. |
| UltimateOrcRaidingPartyService.cs (extended) | Distress gate on the “still has survivors, under target” reinforcement branch only; new status line in [UOrcAutoRegionStatus. |
| EcoOrcMilitia.cs (new) | Disposable BaseCreature militia defender - self-directed engagement against the nearest same-region EcoOrc within 15 tiles, fixed 15-minute lifespan, intentionally weak stats (“usually loses” by design). |
| EcoOrcMilitiaService.cs (new) | Per-region rolling raid-pressure tracking and militia trigger/spawn logic. RecordRaid() is the only entry point EcoOrc.cs calls; [EcoOrcMilitiaStatus and [EcoOrcMilitiaForce for GM visibility/testing. |
| EcoOrcCommands.cs / UltimateOrcCommands.cs (extended) | [EcoOrcTick/[EcoOrcStatus/[UOrcInfo updated to surface held-back state, raid strength, and RaidSkipChance/region distress. |
| StaffWebStatus.cs / UnifiedWebStatus_fixed.cs (extended) | Region-distress summary, held-back tags, and active-militia visibility added to the existing Orc Raid Activity / Orc Raiders panels on both the staff and public status pages; a new Militia= marker added to the public map-data export. |
| IDOCCommandGump.cs (extended) | [EcoOrcMilitiaStatus and [EcoOrcMilitiaForce added to the Eco-Chain → Orcs section - the only two commands from this pass that weren't already reachable through the gump. |
P.8GM Command Reference
| Command | Description |
|---|---|
| [EcoOrcStatus | Lists every EcoOrc with region, hunger, raid count, and last raid result - now flags a held-back raid distinctly. |
| [EcoOrcTick | Targets an EcoOrc and forces an immediate raid, bypassing the wait - reports HELD BACK if the region was too depleted. |
| [UOrcInfo | Target an UltimateOrc for a full detail report - now also reports RaidSkipChance, held-back state, and region distress. |
| [UOrcAutoRegionStatus | Lists every auto-reinforced region, headcount, target, and wipe-cooldown - now also reports “reinforcement paused - region distressed” when the new gate is active. |
| [EcoOrcMilitiaStatus | Lists recent raid pressure per region (N/3 within the rolling window), any currently active militia, and time since the last response. |
| [EcoOrcMilitiaForce <regionKey> | Forces an immediate militia response at your location, tagged to a region - for testing without waiting on real raid pressure to build. |
P.9Known Follow-Ups, Not Done This Pass
Militia stat tuning (HP/damage/skills, band size, or a flee-at-low-health threshold instead of fighting to the death) is a candidate follow-up once more real responses have been watched play out - the first live test resulted in a total wipe, which is within the intended “usually loses” design but may be tuned to last a little longer if it reads as too one-sided. A “pull real townsfolk (SimPC) into the fight” version of militia was considered and deliberately set aside in favor of a disposable creature for v1, to avoid any risk to a SimPC's own job/commitment/economy loop - worth revisiting if the current version proves fun and stable. Separately, and unrelated to the orc work above: HouseDebrisSweep.cs exists in development but has intentionally not been deployed to production as of this pass - not yet confirmed correct by the shard owner, so it is not documented here as shipped.
P.10Status
Live and confirmed working (2026-08-11): raid softening/hold-back, outcome-driven pacing, personality-driven skip chance, and reinforcement throttling are all deployed; the militia response system was tested live and triggered/spawned/engaged/expired correctly, with the band being defeated as intended by its “usually loses” design. Both status pages and the command gump now surface the full raid-pacing and militia picture. HouseDebrisSweep.cs remains a separate, not-yet-deployed item pending further validation.
NPC Relationships, Goals, and Goal-Driven Behavior — Phase 2B / 3A / 3B Tier 1–2
This appendix documents four layers of work built on top of the Phase 1 UltimateNPC population layer between 2026-08-12 and 2026-08-13: Phase 2B (NPC-to-NPC relationships and faction hatred), Phase 3A (personality-driven goal generation and tracking), Phase 3B Tier 1 (goals biasing activity selection), and Phase 3B Tier 2 (activity commitment / anti-herding). None of this was present anywhere in v22's main text; it existed only in standalone session documents until this pass consolidated it here.
Q.1Status Summary and Scope
Deployment status differs meaningfully by layer, and should not be flattened into a single “done” claim:
| Layer | Status | Evidence |
|---|---|---|
| Phase 2B (Relationships) | Live, with production data. | Timestamped snapshot exists: 57 NPCs, 342 relationships, 6.0 avg/NPC, full type breakdown (Q.2). |
| Phase 3A (Goals) | Live, with production data. | Timestamped snapshot exists: 57 NPCs, 56 active goals, 0.98 avg/NPC, goal-type distribution (Q.3). |
| Phase 3B Tier 1 (Goal-weighted activity) | Code-complete, compilation-verified. Not confirmed live. | The latest source document (“Final Integration Complete”, 2026-08-13) reports all 8 compile errors resolved and instructs a full server restart plus a [Phase3BTestAll run, but no follow-up document records that test run's actual result on a live shard. |
| Phase 3B Tier 2 (Commitment) | Code-complete, compilation-verified. Not confirmed live. | Same document/evidence as Tier 1 — they shipped together. |
Do not represent Phase 3B as “in deployment” without further verification. Every Phase 3B document found is a build/deployment guide or a readiness declaration; none is a post-deployment status report with live NPC counts, the way the Phase 2B and Phase 3A sections below both have. Per its own roadmap document, Phase 3B explicitly does not yet make goals trigger real actions (house purchases, faction recruitment, marriage proposals), does not modify trade pricing by goal urgency, and does not cause alliance formation or faction war. Tier 1+2 affect only which activity an NPC works and how long it sticks with it — everything downstream of that is scoped to a future Tier 3+ that has design notes but no code.
Q.2Phase 2B — NPC Relationships & Factions
Phase 2B gives UltimateNPCs a social memory: a bidirectional, event-driven bond value with every other NPC they have interacted with. It extends the closed-loop economy from tracking what NPCs trade to tracking with whom, and how they feel about it afterward — and lays the foundation for goal generation (Q.3) to be relationship-aware. Relationships are not symmetric by construction: RelationshipService.GetRelationship(A, B) and GetRelationship(B, A) are stored and updated independently. Symmetric events (a fair trade) update both directions equally; asymmetric events (a betrayal) update only the betrayer's outgoing relationship toward the betrayed.
Each relationship is a signed integer clamped to [-100, +100], with a derived Type recalculated automatically whenever the value changes: Romantic at ≥60, Friend at 50–59, Neutral at -19–49, Rival at -50–(-20), Enemy below -50.
Five factions exist (Humanoid, Orc, Dragon, Undead, Demon). When two NPCs speak to each other for the first time, UltimateNPC.OnSpeech() detects the encounter and RelationshipService.ApplyFactionHatred() applies asymmetric modifiers to both directions. Only 14 of the 25 possible faction-pair combinations are documented with a modifier (table below) — undocumented pairs currently receive no automatic modifier at all, which may or may not be intended; confirm against RelationshipService.cs directly.
| Source | Target | Modifier | Reason |
|---|---|---|---|
| Humanoid | Orc | -20 | Fear/dislike |
| Humanoid | Dragon | -25 | Hatred |
| Humanoid | Undead | -15 | Fear |
| Orc | Humanoid | -15 | Dislike |
| Orc | Orc | +5 | Kinship |
| Orc | Dragon | -20 | Fear |
| Dragon | Humanoid | -30 | Prey/contempt |
| Dragon | Orc | -15 | Competitors |
| Dragon | Dragon | -10 | Territorial |
| Undead | Humanoid | -20 | Revulsion/hunger |
| Undead | Undead | +5 | Kinship |
| Demon | Humanoid | -25 | Hate the living |
| Demon | Orc | -10 | Some respect |
| Demon | Undead | +5 | Cooperation |
Faction penalties are overcomable: positive interactions accumulate on top of them. Documented example — an Orc starts at -15 toward a Human on first meeting; three profitable trades at +5 each bring the pair to a net 0 (Neutral), and a fourth pushes them into Friend territory.
| Event | Trigger point | Value change |
|---|---|---|
| Fair trade | UltimateNPCTradingActivityBase.TrySell(), NPC-to-NPC only | +5 both directions |
| Unfair trade (>15% over market) | Same trade path | -5 both directions |
| Betrayal (unfair trade against an existing friend, value > 20) | Same trade path, escalates instead of the plain -5 | -30 if prior relationship was a friend (>20 value); -10 otherwise |
| Gift, romantic | UltimateNPC.GiftToNPC(target, value, isRomantic: true) | +10 giver→recipient, +3 recipient→giver |
| Gift, normal | Same method, isRomantic: false | +7 giver→recipient, +3 recipient→giver |
| NPC death (grief) | UltimateNPC.OnDelete(), applied to every surviving NPC with a relationship to the deceased | -50 if survivor's relationship was ≥50; -20 if 20–49; 0 (no change) below 20 |
The betrayal and gift hooks are not automatically detected from ordinary play in Phase 2B — betrayal has no trigger beyond the unfair-trade-against-a-friend case above, and the general gift path is GM- or code-triggered only (GiftToNPC() / [TestGift). Trade and death are the only two event types confirmed to fire from ordinary autonomous NPC behavior without GM intervention.
UltimateNPC.cs's serialization moved from version 4 to version 6 to add the relationship list (written as count, then per-entry targetSerial, value, interactionCount, type). Saves at v0–v5 load with the relationship list initialized empty — no corruption risk; relationships simply rebuild as NPCs re-interact.
Live data snapshot (2026-08-13T07:41:43Z): 57 total NPCs in the relationship network, 342 total bonds, 6.0 average relationships per NPC. Type breakdown: 145 Friends, 87 Rivals, 42 Romantic, 68 Enemies. An earlier, higher-level overview document describes an aspirational target of “5,000+ relationships forming organically” as an illustrative dashboard example — this is not a measured figure and should not be quoted as current state; use the 342-bond snapshot above.
What Phase 2B explicitly does not include, per its own completion note: per-player NPC reputation (a separate, narrower feature — Q.6 references UltimateNPCPlayerReputation.cs), relationship-based pricing, relationship-based dialogue/persona changes, relationship decay over time, or automatic memory-stream integration. A companion planning document proposes all of these — persona tone shifting with bond strength, trade prices moving ±20–30% by relationship, trade refusal below -50 — but it is a proposal, not a shipped feature; no later document confirms it was built.
Q.3Phase 3A — Goal-Driven Behavior
Phase 3A gives every UltimateNPC personal ambitions. On spawn, and on periodic re-evaluation, each NPC is scored against 24 possible goal types using its own personality dials, and the top 3–5 scoring goals become that NPC's ActiveGoals. Goals carry a 0–100% progress value and a 1–10 urgency value, both used later by Phase 3B.
| Category | Goal types |
|---|---|
| Economic | SaveGold, BuyHouse, StartBusiness, BecomeWealthy |
| Romantic | WinHeart, FindLove, Marry, ForgetLove |
| Social | BuildReputation, GainRespect, LeadFaction, BecomeElder |
| Creative | PerfectCraft, BreedRareVariant, WriteStory, DiscoverSecret |
| Defensive | ProtectFamily, SurviveRaid, PrepareForWar, EscapeDanger |
| Existential | FindMeaning, UnderstandWorld, AchieveEnlightenment, SeekDeletion |
On wake, the engine scores every goal type against personality traits and keeps the top scorers, e.g. SaveGold = Greed×2 + Industry×1.5; BecomeWealthy = Greed×3 + MarketSavvy×2 + Industry×1; LeadFaction = Confidence×2 + Aggression×1.5 + Lawfulness×1. Worked example from source documents: an NPC with Greed 92, Industry 78, Aggression 65, Confidence 55, Lawfulness 22 scores BecomeWealthy highest at 411, followed by SaveGold at 262 and StartBusiness at 216 — generated goals: BecomeWealthy (urgency 10), SaveGold (urgency 7), StartBusiness (urgency 5). A simpler threshold-based description also appears in the source material (e.g. Greed ≥ 60 → SaveGold/BuyHouse/StartBusiness/BecomeWealthy candidates); treat the weighted-score version above as the more detailed and authoritative description.
One source document additionally describes relationship-weighted goal selection: an NPC's top 3 friends (relationship value > +50) have their own active goals queried, and matching goal types get their selection weight boosted +30%, so friends naturally converge on shared goals over time. This is described as already integrated in one document but is absent from the later, more heavily referenced overview docs — treat it as implemented but not independently re-confirmed.
Urgency is a 1–10 personality-weighted value assigned at goal creation and referenced by Phase 3B's urgency multiplier (0.5 + urgency/20, i.e. 0.55× at urgency 1 up to 1.0× at urgency 10). Progress advances via NPC activity: for economic goals, each 50 gold of profit adds 1% progress, capped at 100%. At 100% the goal is marked complete, archived, logged to console, and replaced by a newly generated goal — the cycle is fully autonomous. A companion service, AutonomousNPCEncounter.cs, fires on a 45-minute timer: it picks a random NPC, finds nearby NPCs, and generates a short interaction scaled to their existing relationship (friend/neutral/rival/enemy each get distinct dialogue and a small relationship nudge, e.g. +3 for a friendly encounter) — the mechanism by which relationships continue strengthening passively even without trades.
Live data snapshot (2026-08-13T07:41:43Z): 57 NPCs with goals, 56 total active goals (0.98 avg per NPC — an early snapshot, goals had just been initialized), 0% completion rate at time of snapshot. Goal-type distribution, top entries: BuildReputation 15, WinHeart 11, PrepareForWar 8, UnderstandWorld 5, FindLove/PerfectCraft 4 each, SaveGold 3, StartBusiness/BuyHouse/ProtectFamily/BreedRareVariant/GainRespect 1–2 each. As with relationships, an earlier overview document's “267 NPCs pursuing goals... 1,247 goals in progress” figures are illustrative mock-ups, not measured data — the snapshot above is the only figure with a timestamp and should be treated as authoritative.
What Phase 3A explicitly does not do, confirmed by a dedicated roadmap document: goals do not trigger real-world NPC actions (house purchases, faction recruitment, marriage), do not modify pricing or trading behavior, and do not cause alliance formation or war declarations. These remain scoped to Phase 3B Tier 2+.
Q.4Phase 3B Tier 1 — Goal-Weighted Activity Selection
Tier 1 makes an NPC's active goals bias which activity it chooses to work, instead of activity selection being driven purely by base personality fit. The mechanism is a static affinity table (UltimateNPCGoalActivityWeighting.cs) mapping goal type → activity → bonus/penalty, applied inside UltimateNPCBrain.ChooseActivity(): for each eligible activity, the NPC's base activity score is summed with a goal-weighted bonus equal to the sum, over every active non-completed goal, of affinity[goal.Type][activity] × urgencyMultiplier(goal.Urgency). The highest-scoring eligible activity is selected.
| Goal type | Notable affinities |
|---|---|
| SaveGold | Farming +50, Mining +45, Fishing +40, Lumberjacking +35, Crafting +30, Socializing -20, Loitering -30 |
| StartBusiness | Trading +60, Crafting +50, Farming +35, Socializing +25, Mining +30, Loitering -15 |
| PerfectCraft | Crafting +60, Lumberjacking/Mining +20 each, Socializing +5, Loitering -25 |
| FindLove | Socializing +70, Tavern +60, Loitering +20, Trading +15, Farming -10 |
| WinHeart | Socializing +60, Tavern +30, Trading +20, Crafting +15 |
| BuildReputation | Socializing +50, Trading +40, Crafting +35, Tavern +30–35, Loitering +10 |
| GainRespect | Socializing +55, Combat +45, Trading +35, Crafting +30 |
| UnderstandWorld | Socializing +45, Loitering +40, Trading +30, Tavern +25, Farming +10 |
| ProtectFamily | Combat +60, Farming +50, Crafting +40, Mining +30, Socializing -10 |
| PrepareForWar | Combat +70, Crafting +35, Mining +30, Socializing +15, Farming -15 |
| BreedRareVariant | Farming +70, Loitering +15, Socializing +10, Mining -10 |
| BuyHouse | Farming +50, Mining +45, Trading +40, Crafting +35, Fishing +30, Socializing -15, Loitering -25 |
Flag before this table is treated as fully functional: several activity keys referenced throughout it — Socializing, Tavern, Combat, Lumberjacking — do not appear in the UltimateNPCActivityKey enum as it exists after the Phase 3B compilation fixes (see Appendix D). If the live enum genuinely lacks these values, every affinity entry keyed to them is dead weight — either it fails to compile as written, or it was silently dropped or renamed during the “8 compilation errors resolved” pass and never re-documented. The table above is reproduced exactly as written in the source design docs, which predate the final enum; verify directly against the live enum before relying on it.
Anti-herding rationale: without goal weighting, a large NPC population tends to converge on whichever activity scores highest by base personality fit alone — documented as “287 NPCs might all converge on Farming.” With Tier 1 active, NPCs with different active goals get pulled toward different activities even when their base personality is similar, spreading population across 5+ activity types instead of clustering on one.
Q.5Phase 3B Tier 2 — Activity Commitment Tracking
Tier 2 stops NPCs from re-evaluating and flipping activities on every think cycle. Once an NPC selects a non-Loitering activity, it commits to that activity for commitmentMinutes = 15 + (Industry / 10) — 15 minutes at Industry 0, 20 minutes at Industry 50, 25 minutes at Industry 100. IsActivityCommitted() returns true while DateTime.UtcNow < CommitmentExpiresUtc; while true, ChooseActivity() short-circuits and returns CommittedActivityKey without re-running the scoring loop. Loitering never commits — it is treated as an explicit “between jobs” idle state, so an NPC selecting Loitering immediately releases any prior commitment and stays free to re-evaluate at any time.
This solves two problems at once: NPCs constantly re-picking activities every tick looks chaotic and undermines the sense of a stable working population, and without a commitment lock, transient score fluctuations (a goal's urgency ticking up mid-cycle) could cause an NPC to abandon in-progress work. The fix is a straightforward lock, industry-weighted so harder workers hold their jobs slightly longer than idle-leaning NPCs.
UltimateNPC.cs moved from serialization version 9 to 10 to persist CommittedActivityKey (-1 for null, else the int enum value) and CommitmentExpiresUtc, version-gated so v9 and earlier saves load cleanly with commitment initialized empty — consistent with this codebase's established backward-compatibility convention (base fields always read unconditionally, new fields always version-gated, version number always incremented past every prior version, never reused).
A 15-test suite (UltimateNPCPhase3BTests.cs) is reported passing 15/15 on a clean install: 6 tests on goal-weighting affinity/urgency math, 4 on commitment set/expire/release state transitions, 5 on the industry-based duration formula, and 1 multi-NPC anti-herding distribution check. This is unit-level validation on a clean/isolated install — it is not the same as the live-shard confirmation discussed in Q.1. Documented performance characteristics: O(1) per-NPC commitment check, O(n) activity scoring where n ≈ 8–10 activities, no background timers (checked on demand), roughly +16 bytes/NPC memory overhead.
Q.6GM Command Reference (Consolidated)
| Command | Description |
|---|---|
| [UNPCCreate | Spawns a new UltimateNPC at your location; job auto-selected on next wake. |
| [UNPCList | Lists all UltimateNPCs with activity, work state, mood, region, and a per-region headcount summary. |
| [UNPCInfo | Full detail report on a targeted NPC: personality dials, mood pressures, reputation, activity-specific state. |
| [UNPCTick | Forces an immediate economic pulse (harvest/withdraw) on a targeted NPC — for testing goal progress without waiting. |
| [UNPCGoto | Prints the [UNPCList → [go <serial> workflow for locating an NPC. |
| [UNPCRefreshTitles | One-time (safely re-runnable) fix to sync every NPC's Title to its current job. |
| [CreateRelationship <serial1> <serial2> | Creates a bidirectional neutral (value 0) relationship between two NPCs. |
| [ShowRelationships <serial> | Lists all relationships for an NPC: type, value, interaction count. |
| [TestTrade <seller> <buyer> | Simulates a profitable trade; +5 both directions. |
| [TestGift <giver> <recipient> [romantic] | Simulates a gift; +10/+3 if romantic, +7/+3 if normal. |
| [TestBetrayal <betrayer> <betrayed> | Simulates a betrayal; -30 if prior friend (>20), else -10. |
| [InitializeRelationships | Reinitializes RelationshipService, reloading faction hatred modifiers (testing/reset use). |
| [UNPCRelationshipsExport | Manually triggers the Phase 2B relationship export (unpc_relationships.json). |
| [UNPCGoalsExport | Manually triggers the Phase 3A goal export (unpc_goals.json). |
| [UNPCCommitment <name> | Shows commitment status: committed activity, expiration time, active goals. |
| [UNPCActivityWeight <name> | Shows the full goal→activity weighting breakdown (favor/avoid/neutral) for the NPC. |
| [UNPCForceActivity <name> | Forces an NPC to immediately re-evaluate activity selection. |
| [UNPCClearCommit <name> | Manually clears an NPC's activity commitment. |
| [UNPCActivityStats | Shard-wide activity distribution and commitment rate. |
| [UNPCCommitmentExport | Manually triggers the Phase 3B commitment export (unpc_commitment.json). |
| [Phase3BTestAll | Runs the full 15-test Phase 3B Tier 1+2 validation suite. |
| [Phase3BTestGoalWeighting | Runs the 6 goal→activity affinity/urgency tests only. |
| [Phase3BTestCommitment | Runs the 4 commitment set/expire/release tests only. |
| [Phase3BTestCommitmentDuration | Runs the 5 industry-based duration formula tests only. |
| [Phase3BTestAntiHerding | Runs the single multi-NPC activity-distribution anti-herding test. |
| [UNPCPlayerRep <name> | Adjacent feature, not Phase 2B/3A/3B proper: lists players an NPC has ever met and their purchase counts. |
Q.7Files Reference (Consolidated)
| File | Phase | Purpose |
|---|---|---|
| RelationshipService.cs | 2B | Static registry (Dictionary<Serial, List<NPCRelationship>>); event handlers OnTradeBetween/OnGiftGiven/OnBetrayal/OnNPCDeath; faction hatred modifiers; auto type recalculation. |
| NPCRelationship.cs | 2B | Data structure: TargetSerial, Value (-100..+100), Type, InteractionCount. |
| NPCRelationshipType.cs | 2B | Enum: Romantic, Friend, Neutral, Rival, Enemy. |
| UltimateNPCRelationshipCommands.cs | 2B | 6 GM test/debug commands (CreateRelationship, ShowRelationships, TestTrade, TestGift, TestBetrayal, InitializeRelationships). |
| UltimateNPCRelationshipsWebExport.cs | 2B | Daily JSON export to unpc_relationships.json; per-NPC summaries + shard-wide stats. |
| UltimateNPCTradingActivityBase.cs | 2B (modified) | Trade hook: calls RelationshipService.OnTradeBetween() on NPC-to-NPC sales. |
| UltimateNPC.cs | 2B/3A/3B (modified, multiple passes) | Relationships field/property, OnSpeech faction-hatred detection, OnDelete grief hook, ActiveGoals field/property, commitment properties. Serialization v4→v6 (2B)→v9 (pre-3B, gap undocumented)→v10 (3B Tier 2). |
| NPCGoal.cs | 3A | Goal data structure: Type, TargetName/Serial, Progress (0–100), Urgency (1–10), CreatedDate, IsCompleted. |
| NPCGoalsEngine.cs / NPCGoalsEngine_Phase3A.cs | 3A | Goal generation (GetGoalsForNPC) and evaluation (EvaluateSingleGoal, EvaluateGoals) for all 24 goal types; relationship-weighted goal selection; GetTopGoal(). |
| AutonomousNPCEncounter.cs | 3A | 45-minute timer service; picks nearby NPC pairs, generates relationship-appropriate dialogue, applies small relationship nudges. |
| UltimateNPCGoalsWebExport.cs / UltimateNPCGoalsWebExport_2.cs | 3A | Daily JSON export to unpc_goals.json. Two files exist with no source note explaining the “_2” suffix — see Appendix D. |
| UltimateNPCGoalActivityWeighting.cs | 3B Tier 1 | Static goal→activity affinity dictionary (12 goal types documented); GetGoalWeightingForActivity(); urgency multiplier. |
| UltimateNPCActivity.cs | 3B Tier 1 | Abstract activity base class + UltimateNPCActivityKey enum (18 values as of the Phase 3B compile fix — see Appendix D). |
| UltimateNPCBrain.cs | 3B Tier 1/2 (modified) | ChooseActivity() rewritten: commitment short-circuit check, base score + goal-weight scoring loop, commit-on-change logic. |
| UltimateNPCCommitmentWebExport.cs | 3B Tier 2 | Daily JSON export to unpc_commitment.json; commitment rate, average duration, activity/committed-activity distribution. |
| UltimateNPCPhase3BCommands.cs | 3B Tier 1/2 | GM debug commands: UNPCCommitment, UNPCActivityWeight, UNPCForceActivity, UNPCClearCommit, UNPCActivityStats. |
| UltimateNPCPhase3BTests.cs | 3B Tier 1/2 | 15-test validation suite (goal weighting, commitment, duration, anti-herding). |
| UltimateNPCPlayerReputation.cs | Adjacent, not 2B/3A/3B | Narrow per-player memory feature: tracks whether a player has ever spoken to/clicked/bought from this NPC. Colors only Persona dialogue text; never touches trade/pricing logic. |
Q.8Known Gaps and Follow-Ups
Phase 3B live-deployment confirmation is missing (Q.1). The goal-affinity table references activity keys not present in the live enum (Q.4, Appendix D). Activity-type count is inconsistently reported as 18 vs. 19 (Appendix D). UltimateNPC.cs's serialization history has an undocumented v7/v8 gap (Appendix D). Relationship-driven dialogue and pricing are proposed in a planning document but Phase 2B's own completion note lists both as explicitly not built — treat as unimplemented until confirmed against source. UltimateNPCGoalsWebExport_2.cs's relationship to the original export file is unexplained (Appendix D). Explicitly unbuilt and scoped to future work: Tier 3 (housing acquisition on goal completion), Tier 4 (death & inheritance, estate transfer), and Tier 5 (marriage resolution, feuds/alliances from goals, political intrigue) — design notes reference porting SimPC's already-proven housing, work-commitment, and death-recovery services rather than building from scratch, estimated to cut the remaining timeline from 4–5 weeks to 1–2 weeks. None of this is built yet.
Belief-Driven Militia Response — Layer 2 (the 2026-08-18 Pass)
Prompted by a follow-on design question to Appendix P's militia work: could a region's militia respond to how the community feels about being raided, not just how many times it has literally been raided? Appendix P's mechanical trigger (3 raids in a rolling 60-minute window) is a purely quantitative count — it cannot tell the difference between three light raids that barely registered and one brutal raid that terrified the whole town. This pass adds a second, emergent trigger layer that reads directly off the belief/goal/mood infrastructure Appendices Q and L already built, alongside the mechanical layer rather than replacing it.
R.1Design: Two Layers, One Response, Shared Cooldown
EcoOrcMilitiaService.RecordRaid() now runs two independent checks after every successful raid. Layer 1 (mechanical, unchanged from Appendix P) fires when RecentRaids.Count reaches RaidsToTrigger (3) within RaidWindow (60 minutes). Layer 2 (belief-driven, new) fires when CheckBeliefDrivenResponse() finds the region's NPC population has crossed a collective-belief threshold, checked every time a raid lands regardless of the mechanical count. Both layers share the same 30-minute MilitiaCooldown (state.LastMilitiaUtc): whichever fires first spawns the militia and starts the cooldown, blocking the other layer until it expires — the two triggers cannot double-spawn a band for the same underlying pressure. A new RegionRaidState field, LastSpawnWasBelief, records which layer actually fired, purely for status-command and dashboard reporting.
R.2The Three Indicators
CheckBeliefDrivenResponse() scans every Mobile in World.Mobiles.Values, keeps the ones whose region key (via the same RegionKey/PlotRegionKey resolution UltimateNPCCommands.cs already uses for farming vs. non-farming NPCs) matches the raided region, and tallies three independent indicators across that population:
| Indicator | Threshold | What it measures |
|---|---|---|
| Protective goals active | ≥40% of region NPCs have an active goal of type ProtectFamily, PrepareForWar, DefendHome, GuardRegion, ProtectRegion, or RaidPrevention, at urgency ≥7 | Reads UltimateNPC.ActiveGoals via reflection (Appendix Q.3's goal system) — the community is actively planning to defend itself, not just annoyed. |
| Fearful mood spread | ≥35% of region NPCs report UltimateNPCMood.Afraid or .Wary | Reads UltimateNPC.CurrentMood via reflection — the raw emotional read, independent of whether that fear has translated into a goal yet. |
| Raid-memory saturation | ≥30% of the region's collective UltimateNPCMemoryStream memories mention orc/raid/attack/defend/militia (case-insensitive substring match) | Reads UltimateNPCMemoryStream.Instance.GetMemoriesFor(m) per NPC — the community's own accumulated narrative, not a live snapshot of goals/mood. |
The trigger condition is 2 of 3 indicators, not 3 of 3 — deliberately, so a single very brutal raid (which spikes fear and protective-goal urgency immediately, before memory saturation has had time to build) can still fire the belief trigger without waiting for the third, slower-moving indicator to catch up. All three thresholds (0.40 / 0.35 / 0.30) are named constants on EcoOrcMilitiaService and tunable without touching the trigger logic itself.
R.3Implementation Notes
All four query methods (GetNPCRegionKey, HasActiveProtectiveGoal, GetNPCMood, CountRaidRelatedMemories/GetNPCMemoryCount) are implemented against real UltimateNPC state — this pass shipped with working queries from the start, not stub placeholders returning false/0. HasActiveProtectiveGoal and GetNPCMood use reflection (GetProperty/GetValue against UltimateNPC's ActiveGoals and CurrentMood properties) rather than a direct compile-time reference, consistent with this codebase's established pattern for cross-class integrations where a hard reference isn't already in place — each call is wrapped in try/catch with a safe false/None fallback so a reflection failure degrades the belief trigger silently rather than throwing. The memory queries call UltimateNPCMemoryStream.Instance.GetMemoriesFor(m) — the existing public method Appendix L's memory stream already exposes — rather than requiring a new getter; no changes were needed to UltimateNPCMemoryStream.cs itself.
R.4Message & Memory Differentiation
SpawnMilitiaResponse() now takes a triggerSource parameter ("mechanical", "belief", or "admin" for [EcoOrcMilitiaForce) and picks a different broadcast and a different memory line accordingly, so the town's own reaction reads differently depending on why the militia turned out:
| Trigger | Broadcast | Memory logged (importance 6) |
|---|---|---|
| Mechanical (unchanged from Appendix P) | “Town militia rally to drive off the raiders troubling {region}!” | “The town militia turned out to fight off orc raiders — N of them answered the call.” |
| Belief-driven (new) | “Sensing imminent danger, town militia rally to defend {region}!” | “The town militia, sensing collective fear and danger, turned out to defend against expected orc attack — N of them answered the call.” |
Both memory lines feed the same UltimateNPCMemoryStream every other AI-Town hook uses, so a belief-triggered response becomes part of the town's own narrative and can itself raise future raid-memory saturation — the belief trigger can reinforce itself over a sustained campaign, which is the intended behavior, not a bug to guard against.
R.5A RunUO 2.2 / Mono C# 4.0 Compatibility Lesson
Two real compiler incompatibilities were found and fixed while shipping this pass, both worth recording since they will recur in any future file written against modern C# habits and compiled against RunUO 2.2's older Mono toolchain. First: C# 7's inline pattern-matching syntax (if (m is UltimateNPC unpc)) is not supported — it produced CS1026/CS1002/CS1525 syntax errors, not an obvious “feature not supported” message, which made the real cause easy to misdiagnose as something else entirely on first read. The fix is the classic two-step form: UltimateNPC unpc = m as UltimateNPC; followed by a null check. Three occurrences were fixed (GetNPCRegionKey, HasActiveProtectiveGoal, GetNPCMood). Second: the two-argument String.Contains(string, StringComparison.OrdinalIgnoreCase) overload is a C# 4.0-and-later BCL addition not present in this compiler's reference assemblies — it produced CS1501 (no overload takes 2 arguments). The fix is value.ToLower().Contains(needle.ToLower()) — five call sites in CountRaidRelatedMemories's keyword match were converted. Neither bug is specific to this feature; both are worth adding to a shared “what doesn't compile here” checklist for future AI-Town/SimPC extensions (see Appendix D and IDOC_Codebase_Conventions).
R.6Command Gump & Status Integration
[EcoOrcMilitiaStatus's per-region line now appends a (belief) or (mechanical) tag reflecting state.LastSpawnWasBelief, alongside the existing raid-window count and active-militia count from Appendix P. The same pass also closed a gap unrelated to militia specifically but discovered while auditing IDOCCommandGump.cs for this appendix: three web-export commands that already existed as registered CommandSystem entries — [UNPCGoalsExport, [UNPCRelationshipsExport, and [UNPCCommitmentExport (all three documented in Appendix Q.6) — were reachable only by typing them directly, with no entry in the command gump's AI-Town section. All three are now listed there alongside the existing [UNPCThoughtsExport.
R.7Files
| File | Purpose |
|---|---|
| EcoOrcMilitiaService.cs (extended) | BeliefDrivenEnabled toggle; ProtectiveGoalThreshold/FearfulMoodThreshold/RaidMemorySaturation constants; CheckBeliefDrivenResponse() and its four query methods; RegionRaidState.LastSpawnWasBelief; SpawnMilitiaResponse() triggerSource parameter and message/memory branching; two C# 4.0 compatibility fixes (R.5). |
| IDOCCommandGump.cs (extended) | [UNPCGoalsExport, [UNPCRelationshipsExport, [UNPCCommitmentExport added to the AI-Town section alongside [UNPCThoughtsExport. |
R.8GM Command Reference
| Command | Description |
|---|---|
| [EcoOrcMilitiaStatus | Unchanged command from Appendix P — now also reports (belief) or (mechanical) for each region's last response. |
| [EcoOrcMilitiaForce <regionKey> | Unchanged from Appendix P; tagged internally as triggerSource "admin", distinct from both real trigger sources in the broadcast/memory text. |
R.9Known Follow-Ups, Not Done This Pass
Thresholds (0.40 / 0.35 / 0.30) and the urgency floor (7) are launch defaults, not yet tuned against real live-shard raid campaigns — Appendix P's own militia-strength tuning follow-up is a prerequisite for judging whether the belief trigger fires at a sensible cadence relative to the mechanical one, since both currently spawn functionally identical militia bands. Whether the belief trigger should get its own distinct band composition or messaging beyond the broadcast/memory text (R.4) — e.g., a larger or more determined response for a belief-driven callout versus a routine mechanical one — is an open design question. The activity-key and serialization-version gaps flagged in Appendix D affect the goal query (HasActiveProtectiveGoal) indirectly, since it reads the same UltimateNPC.ActiveGoals structure Appendix Q documents; worth re-checking once those are resolved.
R.10Status
Implemented and compiling clean against RunUO 2.2/Mono as of 2026-08-18, after the two C# 4.0 compatibility fixes in R.5. Not yet confirmed against a real live-shard raid campaign the way Appendix P's mechanical layer was — the next real test is watching whether the belief trigger fires independently of the mechanical one during an actual sustained raid, and whether the 2-of-3 threshold reads as sensible in practice.
AI-Town Flavor NPCs & Autonomous Quest Runners
A DEV-folder audit conducted alongside this pass (see Appendix D for the gaps it surfaced elsewhere) found a live, actively developed system with no prose documentation anywhere in this document: a reusable SmartNPC base class (built on BaseCreature) wiring any NPC into an LLM chat layer (SmartSpeechEngine) with per-(NPC, player) short-term conversation memory (ConversationMemory.cs), alongside a separate AutonomousQuestRunnerNPC base class driving self-playing quest-bots (travel → hunt → return → turn-in) built on the stock RunUO QuestSystem.
Concrete NPCs built on these bases include named quest givers (Osric, AnsellaGryen) and quest-runners (Kaida/HatchlingHunterNPC, Rowan/WolfCullerNPC), plus flavor NPCs (GuardCaptainNPC, GuideNPC, HermitNPC, InnkeeperNPC) and a question-answering layer (ServerKnowledge.cs, reloadable via [AIReloadKnowledge) that reads a shard lore/rules text file to ground the LLM's answers. Files span June 19 through August 6, with the most recent file's own header comment explicitly bridging into Appendix L's memory stream: when a conversation session expires, UltimateNPCConversationBridge.cs (already documented in Appendix L.11 as one of the four follow-ups) folds its gist into that NPC's long-term memory. This system is the SmartNPC/quest-runner side of that same bridge — the two were built together but only the memory-stream half had made it into this document until now.
A July README frames this explicitly as live-shard maintenance work, with a deployment checklist that includes a required one-line manual edit to the stock engine's QuestSystem.QuestTypes registration — worth flagging specifically since it's the one step in this system that isn't self-contained within these files.
| Command | Description |
|---|---|
| [AIReloadKnowledge | GameMaster. Reloads Data/ServerGuide.txt, the lore/rules text ServerKnowledge.cs grounds its answers against. |
The Invasion System
A staff-run scheduled town-invasion / champion-spawn event system, adapted from ServUO community scripts (the source header credits Ravenwolfe as original author, compiled/modified for this shard's RunUO 2.2 base) rather than built from scratch. [Invasions opens InvasionMasterGump, from which a GM picks a town, monster type, champion, and size (Small/Medium/Large), schedules a start time, and monitors or stops up to seven simultaneous invasions live. The source's own port notes state plainly that nothing needed changing to run under this shard's RunUO 2.2 base — the adaptation work was UI modernization and a cooldown display, not core mechanics.
One file discovered in the same folder, Smartmonster.cs, is dated 2023-11-19 — nearly two years older than every other file examined across this entire audit — and on inspection is a simple friendly-dog NPC (BellaDog) with no evident connection to the invasion system. It reads as an unrelated stray file parked in this folder rather than part of the feature, and is excluded from this write-up accordingly; worth confirming with whoever placed it there whether it belongs somewhere else.
| Command | Description |
|---|---|
| [Invasions | Administrator. Opens InvasionMasterGump: schedule, monitor, and stop town invasion/champion events. |
SimPC Wilderness Conquest Nodes
A large, deeply integrated SimPC subsystem with real registered GM commands (the SimPC category's “Wilderness / World-Conquest Nodes” section in the command gump — AddWildNode, WildNodeInfo, WildNodeReload, and the rest of that section's roughly twenty commands) that has never had prose documentation in this reference despite being live, production-grade, and actively touched as recently as this document's own writing window. WildernessConquestNode.cs alone runs to roughly 109KB — the single largest file found anywhere in the codebase audit for this pass.
The system defines wilderness “conquest nodes” with spawn definitions across Regional/ChampionWave/ChampionBoss tiers and Land/Water/Coastal terrain types, backed by a dedicated monster catalog (WildernessMonsterCatalog.cs) and an ocean-terrain bridge (WildernessOceanBridge.cs). Crucially, it plugs directly into the same SimPC infrastructure that drives every other documented SimPC activity — SimPCBehaviorScoringService, SimPCRolePlanner, SimPCWorkCommitmentService, and SimPCHighRiskAdventureService — via its own SimPCWildernessConquestActivity, rather than being a bolted-on side system. This is the clearest case found in this pass of a fully shipped, actively-used system that simply never got a companion appendix, and belongs conceptually alongside Appendix K (SimPC EcoShopping) as a peer SimPC activity write-up — a fuller technical treatment (files, exact command descriptions, dashboard visibility) is recommended as a follow-up pass specifically on this system, since this audit did not re-verify every individual command's live behavior.
ECO-Chain Dragon — The Roaming World-Boss
Appendix O.5 already documents DragonTownHooks.cs — the dragon's gossip/memory-stream integration into AI-Town. What that section does not cover is the dragon system itself: a singleton roaming world-boss (EcoDragon) with its own hunger cycle, hunting AI that preys on EcoSheep and EcoWolf, inter-region migration, a rare trophy drop (DragonHeart), and status-page integration — hunting within the same core ecology this whole document describes (EcologySystem.cs, EcoGrassNode, EcoSheep, EcoWolf, RegionalResourcePool).
The source README is explicitly titled a “Production Release Package” and documents a v1.1 → v1.2 → v1.4 changelog of real RunUO engine bugs found and fixed during development, each with its own root-cause note: Mobile.Move()'s facing quirk, BaseAI's competing wander timer, and the distinction between a creature's Combatant and its Action state. This reads as mature, iterated, stable content — file dates cluster in mid-July with no further changes since, consistent with “shipped and stable” rather than “in flux.”
| Command | Description |
|---|---|
| [DragonStatus | GameMaster. Current dragon state — hunger, location, region, hunting target. |
| [DragonSpawn [region] | Administrator. Spawns the world-boss dragon, optionally at a named region. |
| [DragonKill | Administrator. Removes the dragon — testing/reset use. |
Systems Delivered But Not Yet Verified
Three further items turned up in the same DEV-folder audit that do not warrant a full appendix yet — either because they show no sign of having been touched or tested since initial delivery, or because they're generic utility tooling rather than a distinct economy/AI-Town system. They are recorded here so they aren't silently lost, and so a future pass knows where to look before either documenting them properly or deciding they were abandoned.
World decoration & population (“2026 Decorate” folder). A player-facing decoration browser ([IDOCB, catalog-driven, reloadable via [IDOCBReload) plus a staff “place any item” variant ([IDOCBAll), alongside two large one-shot world-population scripts (PopulateSosaria.cs, SosariaNPCs.cs). All five files are dated June 19 – July 1 with nothing touched in the roughly seven weeks since, and no README either way — stale relative to every other actively worked system in this codebase. Registered commands (IDOCB, IDOCBAll, IDOCBReload) mean it isn't clearly abandoned, but its deployment status should be confirmed before writing it up properly.
Mobile companion app & Discord bridge (Web Facing/idoc-companion). A five-piece deployment package: three C# status/command/chat-queue bridge scripts, a Node.js server exposing a token-gated REST/WebSocket API with optional two-way Discord chat, and a React/PWA mobile client (status, IDOC countdown, players online, a whitelisted admin-command console). The package's own DEPLOY.md is a clean-install runbook and its TESTING.md is an entirely unchecked testing checklist — every box literally [ ], none marked complete — which is the strongest available signal that this was handed off and never independently verified against the live shard. All files share one timestamp (July 19) with no iteration since. Treat as delivered-but-unverified, not shipped, until someone confirms [WebAdminTest actually passes live.
GM diagnostic/reporting tools (Reports folder). MobileReport.cs, OrphanReport.cs, SpawnerReport.cs, and XmlSpawnerReport.cs are standard RunUO-pattern census/audit utilities (all dated July 1, untouched since), and ConsoleTeeLogger.cs mirrors console output for [ConsoleLogTest. None of these are economy- or AI-Town-relevant in the sense the rest of this document covers, so they don't warrant appendices of their own — noted here purely for completeness. (IDOCWatcher.cs, also in this folder, is not in this category — it is the shard's namesake feature and is already fully documented in Appendix L.11 and G.3.)
Explicitly excluded from this document: the addon/ folder's static camp decoration placements (ArchersCampAddon.cs, GypsyCampAddon.cs) have no economy hook, no service layer, and no GM command surface — they are scenery, not a system. The nested “Gypsy Camps” subfolder bundled alongside them is dated 2015-01-03, predates this project entirely, and should not be attributed to it.
Home Bed & Nightly Pub Visit — the 2026-08-19 Pass
Every UltimateNPC now actually goes home to a real bed each night and makes one trip to a shared pub each evening, closing two long-standing gaps: NPCs never physically “lived” anywhere despite already carrying a HomePoint on file, and the BuyHouse goal (Appendix Q.3) could weight activity scores but never actually complete, because nothing in the game world had ever been wired up to finish it.
X.1Home Bed: Auto-Provisioned, Not Optional
TryUseBed() looks for a free EcoFarmBed (Server.Items) within four tiles of the NPC's captured home point and lies down in it via the same OnDoubleClick interaction a player uses. Unlike EcoFarmerNPC's original version of this pattern, where a nearby bed is a purely optional flourish, every UltimateNPC is guaranteed one: if none exists nearby at all, TryUseBed() provisions a brand new EcoFarmBed right at the home point before using it. If a bed is nearby but already occupied for the night, the NPC leaves quietly and tries again the following night rather than spawning a duplicate on top of it. The bed itself (m_Bed) is transient state, re-acquired every night exactly like EcoFarmerNPC's own m_Bed — only the one-time m_HomeAcquired flag is persisted.
X.2BuyHouse Goal Completion
OnHomeAcquired() fires exactly once per NPC, the first time m_HomeAcquired flips from false to true. It marks any active BuyHouse goal (NPCGoalType.BuyHouse, Appendix Q.3) complete (Progress = 100, IsCompleted = true), adds a first-person memory (“I finally have a place of my own to call home.”, importance 3) to UltimateNPCMemoryStream, and emotes “*proudly settles into a home of their own*”. Before this pass, BuyHouse was the one goal type in Appendix Q's 24-type roster that could never actually be achieved — it only ever weighted activity scores in UltimateNPCGoalActivityWeighting.cs.
X.3The Tavern Lookup Bug: Checking the Wrong Facet
The original pub-visit implementation looked up the nearest of 18 named taverns, copied from the same coordinate table Customs/2026 Decorate/SosariaNPCs.cs uses for shop-sign placement, checked against Map.Trammel. That is silently wrong for this shard: UltimateNPCs live and work on Felucca — Trammel/Sosaria is a separate, largely NPC-free decoration facet — so every single lookup returned Point3D.Zero and the feature never had a chance to run. Per the owner's explicit request once this was found, UltimateNPCTaverns.cs was rewritten deliberately simple rather than re-mapping all 18 coordinates to Felucca: every UltimateNPC now heads to the same one destination, the Brit pub at [1430,1720,20], with a small +/-1 tile random scatter so a full crowd doesn't stack on a single tile. GetNearestTavern()'s name and signature are unchanged so UltimateNPC.cs's call site didn't need touching.
X.4Teleport, Not Pathing
TeleportToPub()/TeleportHome() move the NPC directly with MoveToWorld() rather than a PathFollower walk, after real-world testing showed multi-region overland routes are unreliable to auto-path — a lesson already paid for once with Seafaring and applied here up front rather than rediscovered. The pub window is evaluated every Working tick via IsPubTime() (6pm up to, but not including, 11pm) ahead of the ordinary night check, so an NPC still mid-shift at 9pm — after IsNightTime() would otherwise send it straight home — still catches the pub trip instead of skipping it outright. IsPubTime() also carries a self-correcting daily reset: once the clock rolls back under 6pm, it flips m_VisitedPubToday back to false, so no explicit day-boundary bookkeeping is needed. IsPubReturnTime() (11pm through dawn) governs the trip home. “Always-on” activities with no day/night commute cycle (UsesDayNightCycle == false, e.g. Shepherding) still get the pub trip — it is checked first in OnThink, ahead of the unconditional “always be Working” state reset those activities otherwise apply every tick.
X.5Water/Impassable Safety Check
IsUsable() gates every landing spot — the pub scatter and the home bed spot alike — on both Map.CanFit() and a new IsWaterTile() check, because CanFit() alone doesn't reject open ocean; it reads as “empty enough to fit a mobile.” IsWaterTile() was widened on 2026-08-19 to also catch TileFlag.Impassable in addition to TileFlag.Wet, after a separate but related investigation (stray Sosaria-world shop signage physically sitting in Felucca's ocean) found this shard's tile data includes at least one unpainted “filler” land tile — landID 0x0064, literally named “NoName” — that the classic client renders as flat blue water even though it carries Impassable rather than Wet. IsWaterTile() is shared between UltimateNPCTaverns.cs and the sign-cleanup tooling, kept at a stable signature specifically so both call sites benefit from either one's fixes.
X.6Files
| File | Purpose |
|---|---|
| UltimateNPC.cs (extended, Version 11) | Home bed field block (m_Bed, m_HomeAcquired, InBed, HomeAcquired); TryUseBed()/TryLeaveBed()/FindBedSpot(); OnHomeAcquired(); pub-visit field block (m_PubPoint, m_VisitedPubToday, AtPubNow); IsPubTime()/IsPubReturnTime()/TeleportToPub()/TeleportHome(); ForcePubVisit()/ForcePubReturn(); new AtPub work state; OnThink integration for both the day/night-cycle and always-on activity branches; m_HomeAcquired added to the save format. |
| UltimateNPCTaverns.cs (rewritten) | GetNearestTavern() (single Brit-pub destination with scatter), IsUsable(), IsWaterTile() (widened to Impassable), ReportTo() diagnostic. |
| UltimateNPCPubCommands.cs (new) | [UNPCPubCheck, [UNPCPubTest, [UNPCPubEnd GM commands. |
X.7GM Command Reference
| Command | Description |
|---|---|
| [UNPCPubCheck | GameMaster. Reports the Brit pub's coordinate, raw land tile ID/flags, fit/water verdict, and overall usability — checked against Map.Felucca specifically regardless of which facet the GM happens to be standing on. |
| [UNPCPubTest | GameMaster. Forces every UltimateNPC to the pub immediately, bypassing the 6pm-11pm window and the once-per-day gate — for confirming the whole population actually goes, in one shot. |
| [UNPCPubEnd | GameMaster. Forces every UltimateNPC currently AtPub straight home to bed immediately, bypassing the 11pm gate. |
X.8Known Follow-Ups, Not Done This Pass
Collapsing every tavern visit onto one shared Brit-pub destination was a deliberate, owner-requested simplification once the real bug (wrong facet) was found, not a fallback forced by a lack of alternatives — a future pass could restore multiple genuine regional pubs, now correctly targeting Felucca instead of Trammel, if per-region flavor is wanted back. IsUsable()'s water/impassable check has only actually been exercised against the one live Brit-pub coordinate; it hasn't been re-run against any other candidate location. Because both trips are instant teleports, travel distance/direction from an NPC's work site to the pub is cosmetically irrelevant by design, but that also means there's no in-fiction acknowledgment of how far a given NPC's evening “walk” would have been.
X.9Status
Implemented, deployed, and confirmed live on the dev shard 2026-08-19: [UNPCPubTest sent 57 of 57 live UltimateNPCs to the pub in a single forced pass, and [UNPCPubCheck confirms the Brit pub coordinate reads fit and not-water on Felucca. Not yet confirmed unforced — i.e., watching the real 6pm clock trigger send NPCs on its own, in real time, without [UNPCPubTest — or across a server restart the way Appendix L's AI-Town memory system was.
X.10SUPERSEDED 2026-08-24 - the bed half of this appendix is retired superseded
The nightly pub visit described here is still live, and is now load-bearing in a way it never was in August: it is the venue the pub social system of Appendix AB.10 runs on. The home-bed half, however, is switched off.
UltimateNPC.DisableBedsAndHousing (AB.3) makes TryUseBed() return immediately, so no NPC searches for, creates, or claims an EcoFarmBed any more. X.1's "auto-provisioned, not optional" guarantee is deliberately reversed. What replaced it is a flat per-region night-quarters point (AB.4), reached by exactly the teleport X.4 argued for.
X.2's BuyHouse goal-completion hook is a casualty. The goal is still generated and still weights activity scores, but with housing off nothing completes it - which is the state Appendix Q.3 originally described and X.2 was written to fix. Recorded here as a regression accepted on purpose, not an oversight.
X.3's Trammel-vs-Felucca lesson and X.5's water/impassable check both survive and are reused directly: UltimateNPCNightQuarters.cs calls UltimateNPCTaverns.IsUsable rather than duplicating it, so there is still exactly one definition of "can a land NPC stand here".
Real Small Houses & the Housing Bootstrap Bugs — the 2026-08-19 Housing Pass
Follow-on to Appendix X the same day: once every UltimateNPC had a guaranteed bed, the next ask was making that bed live somewhere that actually looks lived-in — “today it looks like this and they just wander around the beds and don’t sleep/lay down etc. Not very lifelike” was the concrete complaint, alongside screenshots of a campsite of open-field cots. This appendix covers the real-house bootstrap that replaced the bare cot as the default, the communal fallback that followed once crowded farming clusters turned out to have no room for one house per NPC, and — the bulk of this appendix — three separate bugs the same-day real-time testing surfaced and fixed, each one a different way a housed NPC could end up visibly stuck.
Z.1From Bare Cot to Real House
TryUseBed() (Appendix X.1) already guaranteed every UltimateNPC a bed the first time it went to sleep with none nearby. That guarantee now tries once, and only once, to secure a real small house first — m_HouseBootstrapAttempted gates it exactly the same one-shot way m_HomeAcquired already gated the original BuyHouse goal completion (Appendix X.2). Success relocates the bed inside the new house instead of onto open ground; failure (no valid nearby plot) falls all the way back to the original bare-EcoFarmBed behavior with no other change in effect — a real house is a visual upgrade layered on top of an already-working system, never a new requirement for “this NPC has a home.” The one-shot attempt applies retroactively to already-homed NPCs too, not only new spawns, since the gating flag defaults to “never attempted” for every save written before this pass existed.
Z.2Reusing SimPC’s House Catalog, Not Reinventing It
The new UltimateNPCHousingService.cs deliberately reuses SimPCHouseCatalogService’s real, reflection-discovered house-deed catalog (Appendix M) rather than hardcoding deed type names that might not exist on every shard build, filtered down to only the smallest footprint plans — UltimateNPCs have no equivalent of SimPC’s wealth-tier system, so there is no upgrade path and no larger-house selection, just one modest small-house tier. Placement validity reuses the same HousePlacement.Check the game itself uses, generalized to take any Mobile rather than requiring a SimulatedPlayerMobile specifically, plus a bounded local search (24 tiles) around the NPC’s existing home point rather than SimPC’s full-facet fallback search — a real house here is a nice-to-have upgrade, not worth an expensive whole-map search if nothing’s available nearby. No house key, lock, or account-security model was added: every arrival in this whole system (TeleportHome, TeleportToPub, the housed-bed itself) is a direct teleport, never a walk through a door, so a lockable door on the house is cosmetic only.
Z.3The Communal Fallback for Crowded Clusters
Real-world testing immediately surfaced the obvious limit: several of the shard’s NPC clusters — small farms with five or more UltimateNPCs sharing two or three buildings and a single field — had no free footprint for a house per NPC at all. Rather than leave every NPC in a crowded cluster back on a bare cot, TryFindCommunalBedSpot() now runs as a fallback the moment the solo search fails: it looks for any OTHER UltimateNPC’s already-placed house within a wider 32-tile radius that still has floor space (capped at three beds per house, so a shared house never turns into a dormitory), and drops a spare EcoFarmBed inside it. No ownership or access change was needed for this to work — EcoFarmBed (Appendix X.1) has no permission check at all, so any NPC finding any unoccupied bed within range already worked exactly this way for the original open-field cots; a shared house is the same mechanism with walls around it. The moving-in flavor line and memory differ from the solo case (“moved into a spare bed in a neighbor’s house” rather than “a small house of their own”) so a shared-bed NPC doesn’t narrate owning a house that isn’t hers.
Z.4Bug 1 — Stuck Inside: PathFollower Can’t Open a House’s Own Door
The first symptom, caught within minutes of deployment via the new staff Real Houses panel (Z.8): several housed NPCs showed State “Working” and In House “Yes” indefinitely. Root cause — this NPC type’s TravelStep()/PathFollower-based movement (Appendix X.4’s reasoning for why the pub trip is a straight teleport applies here too) can’t be relied on to open a house’s own door the way a player client does automatically, and for “always working, no day/night cycle” activity types (Shepherding and similar — see UltimateNPC.cs’s !UsesDayNightCycle branch) nothing in that branch ever issues a movement command at all once marked Working, so an NPC that landed inside its own house via the bed simply had no way to ever leave again. Fixed with a self-correcting safety net in OnThink: every tick, if a housed NPC is found standing on one of its own house’s floor tiles while not genuinely Sleeping or AtPub, it’s teleported straight to the house’s own BanLocation — the same stock “just outside the front door” point BaseHouse itself already computes to eject someone.
Z.5Bug 2 — The Rescue That Became a Trap
The first version of that safety net checked “within 6 tiles of the bed” rather than the house’s actual floor footprint. For a small house that radius comfortably covers the area just outside the door too, so the instant a rescued NPC took its first real step toward work, the very next tick’s safety-net check saw it was still “close enough to the bed” and teleported it straight back to BanLocation — one step out, snapped back, forever. Reported directly from a GM screenshot as “NPC hanging (stuck) next to house door and sign.” Fixed by checking the house’s real multi-component floor footprint instead of a distance guess (IsInsideHouseFootprint(), reusing the same rectangle check placement validity and the communal bed scan already used), plus a small additional guard for the case where BanLocation itself happens to land on a footprint tile such as a doorstep.
Z.6Bug 3 — The Wander-Anchor Loop, and the Self-Heal
A third, superficially identical “walks three steps then zaps back to the house sign” report turned out to be a different bug entirely, specific to always-working activity types like Shepherding. Those never run a real travel state machine — they simply let ordinary RunUO wander AI roam near a fixed Home point all day. Home was being set to the new house’s raw placement anchor, which for most small house deeds sits right at or on the door threshold itself, so routine wandering kept drifting the NPC back across that threshold — at which point the (by-then-correct) Bug 2 fix rescued it right back out again, correctly, but endlessly. Fixed by anchoring Home at the house’s BanLocation instead of its raw placement anchor — matching what the communal-house path (Z.3) already did by coincidence. Because this NPC had already been housed before the fix existed, a one-time self-heal was also added that runs every tick for every NPC (not just inside TryUseBed, which an always-working NPC only ever calls once in its lifetime) and quietly corrects both the tracked home point and the live wander target the moment it notices a mismatch — no manual per-NPC reset required on the shard.
Z.7Ambient Flavor: Settling In For the Night
Following directly from the pub chatter feedback (“Pub good — I like it. Want this kinda feel for beds and other activities”), TryUseBed() now fires a one-shot overhead line the moment a bed is actually used each night, drawn from one of two pools depending on HasRealHouse — a housed NPC gets lines like “*shuts the door and settles in for the night*”, a still-on-a-cot NPC gets “*stretches out on the cot and turns in*” and similar. Purely cosmetic, same never-touches-trade/mood/goal-state discipline as the pub chatter in Appendix X.
Z.8GM Visibility: the Real Houses Panel
StaffWebStatus.cs gained a new Real Houses panel alongside the existing Down at the Pub panel (Appendix X.6) — a live “Housed: N of Total” count plus a per-NPC table of Name, Region, House Location, current State, and an “In House?” yes/no flag. That In House column was deliberately added specifically to let a GM spot an NPC stuck inside its own walls at a glance from the web, without needing to be online in the game — it was, in fact, exactly this panel’s live data (six of eight housed NPCs reading “Working” / “In House: Yes”) that caught Bug 1 within minutes of the housing feature’s first deployment.
Z.9Files
| UltimateNPCHousingService.cs (new) | TryBootstrapSmallHouse() (solo house search/placement, reusing SimPCHouseCatalogService), TryFindCommunalBedSpot()/TryFindFreeBedSpotInHouse() (communal fallback), IsInsideHouseFootprint(), FindInteriorSpot(). |
|---|---|
| UltimateNPC.cs (extended, Version 12) | m_RealHouse/m_HouseBedSpot/m_HouseBootstrapAttempted fields (persisted); TryUseBed() extended with the one-shot solo/communal bootstrap and the shouldActuallySleepNow gate; OnRealHouseAcquired(bool owned); the per-tick BanLocation self-heal and IsInsideHouseFootprint safety net in OnThink; bed settle-in flavor lines. |
| StaffWebStatus.cs | New “Real Houses” panel — Housed count plus a per-NPC Name/Region/House Location/State/In House? table, the diagnostic that caught Bug 1. |
Z.10Status
Deployed to the dev shard and, as of this revision, under active real-time GM testing — all three bugs above were found and fixed the same day, in production-adjacent conditions, not in isolation. Confirmed working: solo house placement, bed-inside-house, the communal fallback finding and using a neighbor’s spare bed, and all three fixes described in Z.4–Z.6. Not yet confirmed: multi-hour unattended stability (the plan going into this revision is exactly that — leave it running for an hour or two and watch the Real Houses panel) and behavior at full population scale rather than the handful of clusters spot-checked so far.
Z.112026-08-23 Update — Three More Bed Bugs Found and Fixed, MaxBedsPerHouse Raised
Continuing directly from Z.10's open items, three more housing/bed bugs surfaced during the days of live GM testing that followed and were fixed the same night as the SimPC/command-gump pass documented in Appendix AA.
Going-forward self-heal for restart-orphaned beds (UltimateNPC.RecoverOrphanedBed, called from TryUseBed): m_Bed is deliberately not persisted, so every server restart wipes an NPC's reference to whatever bed it was actually asleep in at save time - but EcoFarmBed's own Asleep/Mobile fields ARE persisted, so the bed itself kept reading occupied forever with nothing to revisit it. RecoverOrphanedBed now re-finds "its own" bed(s) by scanning near its house-bed-spot or home point on every sleep attempt, keeping the nearest if more than one has piled up and deleting the rest - a few at a time, no separate sweep needed for the steady state. [UNPCBedCleanup (Appendix AA) is the matching one-shot sweep for litter that had already piled up before this fix shipped.
AssignRealHouse idempotency: re-running [UNPCAssignHouse/[UNPCAssignHouseRegion against an NPC that was already housed - e.g. targeting the same region a second time - used to silently create a second, permanently-unclaimable bed every time, since TryUseBed's very first check ("already tracking a bed") means the NPC never even looks at the freshly recomputed spot. Live evidence: re-running AssignHouseRegion on an already-fully-housed Britain produced 6 fresh orphan beds plus 8 surplus deletions on the very next cleanup pass - net negative. Fixed with an early return when the NPC already owns the target house (a harmless no-op re-confirmation), and by having a genuine house change release the NPC's old bed immediately rather than leaving it as litter.
Bulk-assignment race on freshly-placed beds: [UNPCAssignHouseRegion calls AssignRealHouse back-to-back for many NPCs in one pass against a house with zero real beds yet at calculation time, so every call independently computed the same "best empty spot" - only the first NPC to actually reach its own next sleep cycle ever got a real bed; every other one found its spot already taken and gave up for the day, forever. Confirmed live: 11 of 12 Cove NPCs stuck sharing one spot, standing outside the door. Fixed by having AssignRealHouse create the real bed immediately instead of leaving it for lazy creation on next sleep, so each subsequent call's own spot search is forced onto a genuinely different tile.
MaxBedsPerHouse (UltimateNPCHousingService.cs) was also raised from 5 to 15 the same session, loosening the per-house communal cap now that the litter/race bugs above are no longer artificially inflating bed counts. A new [UNPCListRegion <regionKey> command was added too, listing every UltimateNPC in a region regardless of current location/state, with a housed/homeless summary - the direct way to check a region's housing coverage without waiting on the Real Houses panel's own refresh cycle.
Combined with Z.10's three original fixes, six total housing/bed bugs have now been found and fixed across two consecutive sessions of live testing - worth reading as: this subsystem is still actively hardening, not a closed chapter yet. Z.10's "not yet confirmed stable unattended" caveat stands until a genuinely quiet multi-hour unattended run is observed with none of these six recurring.
Z.12SUPERSEDED 2026-08-24 - the housing subsystem is retired superseded
Everything in this appendix - the real-house bootstrap, the communal fallback, the three stuck-NPC bugs of Z.4-Z.6, and the three further bed bugs of Z.11 - is switched off behind UltimateNPC.DisableBedsAndHousing (Appendix AB.3). NPCs no longer bootstrap houses, no longer seek communal bed spots, and no longer own beds at all.
This was a product decision, not a rollback forced by a defect. The owner's instruction was to "stop them worrying about finding a bed or house or anything" - after roughly a week in which this appendix and Z.11 between them record six separate housing bugs, each found in live testing, each fixed, each followed by another. The subsystem worked. It simply cost more to keep working than the behaviour was worth.
Two pieces of Z were kept alive deliberately rather than deleted. The RealHouse / HouseBedSpot / HomeAcquired properties remain, because StaffWebStatus.cs, UltimateNPCCommands.cs and UNPCBedCleanupCommands.cs all still reference them and removing them would break the compile in four files - the behaviour is killed, the surface area other files compile against is not. TryLeaveBed() also still functions, so any bed an NPC was holding when this shipped is released properly rather than stranded.
One live consequence was not obvious until AB.9 found it. The housing self-heal at the top of OnThink() kept re-pinning m_HomePoint to a persisted m_RealHouse.BanLocation on every tick, long after housing was off. Since HomePoint is the resource-search origin for four job types and the work site itself for ten more, retired housing was still quietly steering where NPCs worked. Both housing safety nets are now gated on the flag.
Z.11's [UNPCBedCleanup and [UNPCFixHouseBeds remain useful as one-shot historical sweeps. AB.3 adds a third, [UNPCWakeStuckBeds, for the exact asymmetry Z.11 itself diagnosed: m_Bed is not persisted but EcoFarmBed's Asleep/Mobile fields are, so any bed already claimed at the moment the off-switch shipped would have read occupied forever with nothing left to release it.
SimPC-as-Player Move 1, Trader-Chain Fixes & Command Gump Audit — the 2026-08-23 Pass
Owner instruction for this pass, verbatim: "It looked good SO i moved EVRYTHING to prod - swapped the save out even - so its ALL in Prod. need a big update to Koster i imagine - Get started - review IDOCA - Review all work today - Update all in koster - also so look at the idoccommands and see if we missed anything. Good day - get this done and I will leave it overnight." Everything below was authored and shipped unattended, against the same DEV-folder script tree used throughout the project - no alternate path was given and the folder structure was unchanged after the prod swap (confirmed by directory listing).
AA.1The Reframe: SimPC as the Missing Player
Every player-facing item still open on the roadmap - dynamic demand, reactive behaviors, favor/quest gating, reputation-gated trade - was written assuming a real person would be online to trigger it. There is no live player population on the dev shard right now to exercise any of that against, which had been the actual blocker on this whole branch of work, not complexity. The fix: stop building features that need a human to show up, and instead let SimPC take the "player" role - buying, trading, forming reputation, eventually requesting favors and reacting to crises - while UltimateNPC and the Eco-Chain economy stay the living shard substrate SimPC acts on. Nothing here is about entertaining a real player; it's about the shard trading and reacting to itself continuously, with SimPC as the active variable instead of a person who may never log in on dev.
AA.2What Was Already True
Checked directly against the live files before scoping any new work, three things turned out bigger than expected. Direct SimPC to UltimateNPC trading already worked: SimPCEcoShoppingActivity.FindAnyVendorWithStock already scanned UltimateNPC traders across nearly the whole roster and bought via each activity's TryDirectPurchase/TrySell - the original integration vision assumed a new UltimateNPC.TrySellTo(SimPC) method was needed; it wasn't, TrySell(UltimateNPC npc, Mobile from, int amount) was already generic over any Mobile, SimPC included. Dynamic pricing already worked: both EcoTraderNPC.EffectivePrice() and UltimateNPCTradingActivityBase.EffectivePrice() scale price up to +50% as harvest pressure climbs, and a SimPC buying during a shortage already pays shortage prices like a real player would. And the SimPC to UltimateNPC memory bridge (one direction only, by design) already reaches the regional gossip bus for notable SimPC events.
AA.3The Real Gap
Reputation/recognition was hard-gated to PlayerMobile, and SimPC got none of it. UltimateNPC.OnSpeech/OnDoubleClick only called UltimateNPCPlayerReputation.RecordSeen when e.Mobile is PlayerMobile; DescribeForPersona explicitly cast speaker as PlayerMobile and returned empty for anything else, with a comment reading "SimPCs and other NPCs don't get a recognition line - this is about real players." Same story for this week's violence-reputation extension (OnDamage only recorded when the attacker is PlayerMobile). Net effect before this pass: a SimPC could already genuinely buy from an UltimateNPC trader, shortage pricing and all, and that trader would never remember it, never grow to trust it, never treat it as a regular - the trade worked, but the relationship didn't exist. The gossip bridge was also still one-directional (SimPC events reach UltimateNPC memory, not the reverse), and no favor/quest system or reactive crisis behavior existed for anyone yet.
AA.4Move 1 — Reputation/Recognition Extended to SimPC
Nine files, all additive, no removed or renamed members. UltimateNPC.cs's OnSpeech/OnDoubleClick now call UltimateNPCPlayerReputation.RecordSeen for SimulatedPlayerMobile as well as PlayerMobile. UltimateNPCPlayerReputation.cs's DescribeForPersona now accepts either type, using the base Mobile throughout instead of the PlayerMobile downcast; RecordSeen/HasEverMet and the violence ledger needed no changes, already generic over Mobile. Seven trading-activity files (UltimateNPCMiningActivity, UltimateNPCWoodcuttingActivity, UltimateNPCFarmingActivity, UltimateNPCFishingActivity, UltimateNPCTradingActivityBase, UltimateNPCShepherdingActivity, UltimateNPCOrchardingActivity) each now call UltimateNPCPlayerReputation.RecordSeen(npc, buyer) alongside their existing UltimateNPCCustomerLedger.RecordSale(...) call. Live effect: a SimPC that buys from an UltimateNPC trader now registers as "met" on first contact, builds a real purchase count against the ledger's 3/5/10/20 "becoming a regular" milestones, gets a real DescribeForPersona recognition line, and shows up in [UNPCPlayerRep output going forward. Deliberately out of scope: violence/OnDamage tracking was not extended to SimPC, since SimPC has no attack-NPC behavior today - that branch would be dead code until a SimPC role exists that fights an UltimateNPC.
AA.5The CS0103 Bug and a Verification-Methodology Lesson
Shortly after Move 1 shipped, the owner's own full build reported a real compile error: UltimateNPCPlayerReputation.cs line 259, CS0103, "The name 'player' does not exist in the current context", inside DescribeForPersona. Root cause: the Move 1 edit renamed DescribeForPersona's local variable usage from a PlayerMobile-typed player to the Mobile-typed speaker parameter directly, but missed one stray usage - if ( HasEverMet( npc, player ) ) still referenced the old name. Root cause of why this session's own compile-tree verification hadn't caught it: verification runs against a trimmed subset of the engine tree (roughly 120 files) that does not include the real Mobile/UltimateNPC type definitions, so DescribeForPersona's own parameter types fail to resolve inside that trimmed tree - and the Mono C# compiler suppresses cascading semantic-error checking, including undefined-identifier checks, inside a method once its signature fails to bind. The stray player reference therefore produced zero errors in either the baseline or edited trimmed-tree compile, in both directions. Fixed by changing player to speaker on that line and confirming via a whole-file grep that no other stray references to the old name remained. Adopted as a standing process rule for all work following this pass: any edit that renames or removes a local variable requires a manual grep sweep for the old identifier, in addition to the compile-tree diff - the compile tree alone is provably insufficient for that specific class of edit.
AA.6SimPCEcoShoppingActivity Trader-Gap Fix — Closing the Documentation Gap Too
SimPCEcoShoppingActivity was written before the trader roster was finished. Its vendor-discovery method (FindAnyVendorWithStock) and purchase switch (BuyOnce) only ever recognized 10 of 15 trader/producer types - Shepherding, Farming, Orcharding, Woodcutting, and the six shared TradingActivityBase converters (Weaving, Tanning, Cooking, Milling, PieBaking, CiderMaking). Five fully-built, fully-working types were invisible to SimPC's shopping loop: Mining, Fishing, Fishmongering, Blacksmithing, Carpentry. All five had real working purchase methods already - Mining/Fishing via their own TryDirectPurchase, Fishmongering/Blacksmithing/Carpentry via the same shared TrySell engine as Weaving/Tanning - a real player standing next to one could always buy from it; SimPC just never looked. Fixed by adding all five to the candidate scan and the purchase switch. This also matters for Move 1 above: Mining and Fishing already record purchase-reputation, but with SimPC never buying from them, that tracking sat permanently at zero - this fix is what actually puts traffic through those two. Confirmed while diagnosing this: vendor discovery is not distance-based. Every UltimateNPC carries a RegionKey - a shared logical tag (e.g. "Britain") assigned once via UltimateNPCRegionFinder, based on the nearest EcoGrassNode to that NPC's home point, not its worksite. SimPC resolves its own region the same way. So a Fisherman on the docks and a Shepherd inland end up tagged identically as long as their homes are near each other, and FindAnyVendorWithStock is pure string-matching on that tag; actual travel is handled separately by SimulatedTravelService's recall-based movement, which works over any distance. Physical position genuinely doesn't affect discovery or reachability. This fix also closes a documentation gap noted in Appendix D: the Fish and Ore chains had no prose coverage anywhere in this document until now, and Table C.3 (Appendix C) has been corrected in this revision to list all 15 jobs, including the two that were missing.
AA.7StaffWebStatus Orchard/Wood Panel — Missing Carpenter Count
Same-shaped gap, smaller scope: StaffWebStatus.cs's Orchard/Wood region-table panel tallied orchardists, woodcutters, bakers, and cidermakers, but never counted Carpenters despite Carpentry being a fully live job. Fixed by adding a carpenters counter alongside the existing four, incrementing it on UltimateNPCActivityKey.Carpentry, and adding it to the panel's output line.
AA.8IDOCCommandGump Command Audit
Audited every CommandSystem.Register call across the full IDOCA custom-script tree (UltimateNPC, Eco-Chain, Web Facing, SimPC, Reports, and world-utility folders - roughly 330 unique live commands) against every entry already in IDOCCommandGump_1_1.cs (roughly 300 unique entries). Forty-nine live, GM-callable commands had no entry anywhere in the gump - findable only if the exact command name was already known. Added, purely additively with no existing entry touched: six new UltimateNPC sections (Housing, Pub/Tavern, Goals & Commitments, Relationships, Web Export, Testing/Diagnostics - this category previously had only Population, Inspect/Control, and Migration, roughly 11 entries total before this pass), the entire Mining and Fishing command sections under Eco-Chain (these two chains had zero gump presence despite being fully live - the same blind spot as AA.6's fix), two new World sections (Reports & Diagnostics; Felucca Sign Audit), and several SimPC entries (SimPCGuildHallTrace, SimPCHousePanel, SimPCDecorCleanup, SimPCGuildRelicCleanup) plus WildMonsterCatalogDump under Wilderness. Deliberately excluded: CommandCenter and IDOCCommands, the two aliases that open this very gump - no value documenting the launcher inside itself; and SimPCAnim, a registered alias for the exact same handler as the already-listed SimPCAnimation.
AA.9What's Next — Moves 2 Through 6
Move 1 (AA.4) was scoped as the foundation move because every later move either depends on it directly or is meaningfully weaker without it. Five moves remain on the SimPC-as-Player roadmap, none started this pass: Move 2, reverse the gossip bridge so regional UltimateNPC events (raids, Critical/Stressed pool state, a notable raider's fall) feed into SimPC's own rumor/decision inputs, at a gated importance threshold. Move 3, a favor/quest system scoped so a SimPC with enough standing from Move 1 can be offered and autonomously pursue a favor from an UltimateNPC, fitting into SimPC's existing goal-driven activity scoring rather than a bolted-on quest engine. Move 4, reactive economic behavior: make each trading activity's existing BuyOrderActive-style shortage signal legible to SimPC's shopping scoring, so a market-savvy SimPC preferentially responds to an active regional shortage. Move 5, reactive defense behavior: extend SimPCRolePlan's existing Guardian trigger (currently "my own home was robbed") to also fire on a nearby UltimateNPC-region-raided signal once Move 2 makes that visible. Move 6, relationship-weighted pricing extended to SimPC, once Move 1 gives SimPC a real relationship record. Recommended sequencing (per the roadmap doc): Moves 2 and 4 next, in parallel - both small-to-medium, independent of each other and of anything else outstanding.
AA.10Status
This entire pass - Move 1, the trader-gap fix, the StaffWebStatus fix, the command gump audit, and this appendix - was authored and shipped unattended overnight, per explicit owner instruction, after the owner confirmed the shard's DEV build had been moved fully to production earlier the same day. Every code change was compile-verified against the project's trimmed engine tree before shipping (Move 1, the trader-gap fix, and the StaffWebStatus fix all diffed clean against baseline); the command gump audit was verified structurally instead - a comment-aware brace/paren balance check plus a pattern-consistency check against roughly 300 pre-existing identical entries - since the trimmed compile-tree scaffolding from earlier in the session did not carry into that portion of the pass. None of this has been reviewed by the owner yet. Recommended morning checks: a real [recompile or client load-test of IDOCCommandGump_1_1.cs; a few real SimPC purchases against Mining/Fishing/Fishmongering/Blacksmithing/Carpentry traders followed by [UNPCPlayerRep on each to confirm Move 1 and AA.6 together actually produce reputation entries; and the StaffWebStatus Orchard/Wood panel showing a nonzero Carpenter count if any Carpenters are currently active.
The Great Simplification - Beds Retired, One Cycle, and the Pub as a Social Venue (the 2026-08-24/25 Pass)
This pass began as a rollback request - "things are fracked on my server" - and ended with the housing subsystem of Appendices X and Z deliberately switched off, every NPC on one identical daily cycle for the first time, and the pub turned into the shard's social hub. Eight bugs were found and fixed along the way. Three of them predated this pass and one had been silently freezing NPCs out of the entire day/night state machine, undetected, for as long as the code had existed.
AB.1The Lost Session - a Verification Failure, Recorded First
The session opened with a request to roll back recent work. Investigation found the opposite problem: the previous session's work had never been applied at all.
That session's own completion log described a full redesign in detail - EnsureHousing(), TeleportToWork(), DebugForceNight, [UNPCWakeStuckBeds, [UNPCSimulateNight, a two-minute rest grace period. A grep for each symbol across every .cs file in the DEV tree returned nothing. The on-disk UltimateNPC.cs was still the pre-redesign version, carrying TryUseBed(), TryLeaveBed(), RecoverOrphanedBed() and BeginTravelHome(). Extracting the owner's own backup archive settled it: its copy of UltimateNPC.cs was byte-identical, at 121,045 bytes, to the unmodified file on disk.
A day of work existed only in a chat transcript. The shard had been running the pre-fix code the whole time, with every one of the original bed bugs still live - which is exactly what the owner was seeing and reporting as a regression.
The lesson is recorded here because it is cheap to repeat: verify that a deploy landed by re-staging the file and grepping for a symbol from the change, before writing the log that claims it shipped. Every file in this pass was staged back off disk and byte-compared after deployment. The same discipline caught a second failure two days later (AB.15).
A related failure mode surfaced in the same session. This document's own predecessor briefing recorded "300+ UltimateNPCs"; the live [UNPCList headcount was 57, later 65 after migration. The wrong figure went unchallenged and was believed until a live command was actually run. Prefer a live command's output over any number written in a document, including this one.
AB.2Job Churn - Four Compounding Faults
The owner's second complaint was that NPCs changing jobs was "causing chaos". The cause was a single block in OnThink()'s morning branch that re-ran CanStart() every day and handed the NPC back to ChooseActivity() whenever it returned false.
| Fault | Effect |
|---|---|
| CanStart() is environment-dependent | Farming needs a claimable EcoFarmPlot nearby, so the answer flickers as other NPCs claim and release sites. |
| ChooseActivity() gives the incumbent no bonus | Highest score wins outright, so a one-point scoring wobble flips a career. |
| Assigning ActivityKey calls old.End() | That RELEASES the plot or region, changing the environment for every other NPC and cascading the churn outward. |
| The commitment guard is minutes, not days | 15 + Industry/10 minutes (Appendix Q.5), set only on an actual change - long expired by the next morning, so it damped nothing. |
The fix is a deletion: the morning re-check is gone entirely. A job is now permanent once chosen. The only remaining live ChooseActivity() call is the brand-new-NPC case at first tick. Deliberate change is still possible - a GM [props edit of ActivityKey, UltimateNPCMigration, or the new [UNPCRechooseJob command.
For a specific job, [props on ActivityKey is the better tool than [UNPCRechooseJob: the command re-runs the scoring and may return something else, whereas the property setter ends the old activity, releases its plot or region, clears m_Configured, and updates the displayed title.
AB.3Beds and Housing Retired - One Flag
The owner's instruction was blunt: "I want to stop them worrying about finding a bed or house or anything." Appendices X and Z between them record six housing bugs across a week, each found live, each fixed, each followed by another. The decision was to retire the subsystem rather than fix it a seventh time.
The implementation is a single static field on UltimateNPC, DisableBedsAndHousing, defaulting to true, and a single early return at the top of TryUseBed(). That one return kills the whole chain: bed search, bed creation, TryBootstrapSmallHouse, TryFindCommunalBedSpot and RecoverOrphanedBed. Setting the flag false restores the old behaviour wholesale - it is the single point of reversal, and no other edit is needed.
Three things were deliberately left working. TryLeaveBed() still functions, so a bed an NPC was holding at the moment the switch shipped is released rather than stranded. The RealHouse / HouseBedSpot / HomeAcquired properties remain, because four other files compile against them. And m_BedSearchAttempted is still set before the early return, so callers' "only try once" gates behave exactly as before and nothing spins.
[UNPCWakeStuckBeds was added for the beds already claimed in the live world. This is the asymmetry Z.11 diagnosed: m_Bed is not persisted, so a restart wipes the NPC's reference, while EcoFarmBed's own Asleep and Mobile fields ARE persisted - the bed keeps pointing at an NPC that no longer knows about it, and TryLeaveBed() no-ops because m_Bed is null. The sweep releases every such bed, skips non-UltimateNPC beds belonging to SimPCs or players, and is safe to re-run. On the live shard it eventually reported zero, which is the all-clear.
AB.4Night Quarters - Replacing the Bed With a Place
With beds gone, NPCs still needed somewhere to be at night. UltimateNPCNightQuarters.cs is a flat region-key to Point3D table, deliberately the same shape as UltimateNPCTaverns.cs and reusing its IsUsable()/IsWaterTile() checks rather than duplicating them.
All six regions currently point at the same spot, [1295,1761,10], at the owner's instruction ("for now just use this for all 6"). The per-region table shape was kept anyway so that six real coordinates later is a one-line-per-region edit and nothing else. A live [UNPCList confirmed every one of the 65 NPCs resolves to a real region key - not one sits on "default" - so the nearest-anchor fallback built for unresolved keys will never need to fire.
Two traps were hit here and are worth recording, because both would recur in any similar change.
The wander-anchor trap. The Sleeping case ran Home = m_HomePoint on EVERY tick. RunUO's wander AI walks a creature back toward Home whenever it strays past RangeHome, so teleporting an NPC to night quarters would have marched it straight back within seconds. The fix is a non-persisted m_NightPoint field that holds the overnight anchor; the Sleeping case uses it when set, and dawn clears it.
The trap that was avoided rather than hit: never overwrite m_HomePoint with the night point. That is the obvious way to write TeleportHome() and it would have been catastrophic - all ten stationary job types would have relocated their WORKPLACE to the night spot, and all four roaming resource jobs would have started searching for ore, trees, fish and fruit within 200 tiles of Britain, abandoning their regions permanently. m_NightPoint exists as a separate field for precisely this reason.
AB.5One Cycle For All - Removing the !UsesDayNightCycle Branch
The shard had two classes of NPC living by different rules, and had since UltimateNPC was written. A 47-line block in OnThink() sat ABOVE the day/night switch and ended in a return.
It covered Shepherding plus every UltimateNPCTradingActivityBase subclass - Weaving, Tanning, Cooking, Milling, PieBaking, CiderMaking, Carpentry, Blacksmithing, Fishmongering. Ten of the fifteen job types. Those NPCs never slept, never commuted, and never reached night quarters. They worked permanently at HomePoint and only ever left for the pub.
Deleting it was safe for two specific reasons, both verified before the change rather than assumed. First, both stationary GetWorkSiteLocation() implementations already return npc.HomePoint, so "travel to work" resolves to the exact tile they were already standing on - unification costs those roles nothing and gains them the nightly cycle. Second, m_State is NOT serialized; Deserialize() resets every NPC to Sleeping on load, so all 65 flowed into the unified cycle naturally on the next restart with zero save-compatibility risk.
activity.Tick() and IsWorkSiteStillValid() are both still called every tick, from the Working case, so the economic behaviour of those ten job types is unchanged.
Live confirmation: with the clock pinned to night, all 65 read State: Sleeping and gathered at the night-quarters point - including every stationary type, which before this change would have stayed Working indefinitely.
AB.6Teleport Both Ways - Why the Dawn Walk Had To Go
Appendix X.4 already established teleporting rather than pathing for the pub trip. This pass extended that to both ends of the working day, for a reason that only appeared once every NPC slept in one place.
Dawn used to hand off to the TravelingToWork state and walk via TravelStep/PathFollower. RunUO's pathfinder has a node limit and simply cannot path the distances involved from a single shared night point:
| Region | Tiles from [1295,1761,10] |
|---|---|
| Britain | 115 |
| Skara Brae | 825 |
| Yew | 966 |
| Trinsic | 1,035 |
| Cove | 1,111 |
| Vesper | 1,808 |
Those NPCs made no forward progress, so TravelStep's own StuckPulseThreshold (default 10) tripped and teleported them to the destination anyway. The walk was never a walk - it was a ten-tick delay in front of an inevitable teleport. Dawn is now symmetric with the night return.
TeleportToWork() is the shared core behind both the natural dawn transition and the new [UNPCForceWork command, so the two cannot drift apart. The TravelingToWork walking state still exists and is used only when DisableBedsAndHousing is false.
AB.7The Farmer Freeze - Three Stacked Bugs, All Pre-Existing
After the unification, the owner reported that farmers stayed behind while everyone else went to night quarters. The cause was three separate bugs stacked on top of each other, none introduced by this pass.
First, a failed work-site claim skipped the entire day/night cycle. The claim block sat ABOVE the state machine and returned outright on failure, so an NPC that could not claim a work site never slept, never visited the pub, and never reached night quarters. It stood still, every tick, indefinitely.
Second, Deserialize() clears m_Plot and m_Configured, so all 65 NPCs re-claim simultaneously on every restart. The fourteen region-based job types just re-set a string and always succeed. Farmers have to re-claim a physical EcoFarmPlot, and plot ordering decides who loses the race.
Third, a farmer that lost that race was locked out permanently. The PlotLocation branch of TryClaimWorkSite returned false without ever falling through to FindNearestUnclaimed, so a farmer whose remembered plot had been deleted or taken could never claim ANY plot again.
Stack them and a farmer that lost the plot race was frozen out of the cycle for good. This was invisible before the unification only because a farmer standing in its own field looks entirely normal. It is also not attributable to the new job lock: CanStart() returns true whenever PlotLocation is set, so the old morning re-pick would never have rescued these NPCs either.
The fixes: the claim attempt now always falls through to the cycle, with departure for work gated on m_Configured at the dawn transition instead; and Farming falls back to FindNearestUnclaimed with a console log when it supersedes a GM assignment. The useful by-product is a precise diagnostic - an NPC standing at night quarters in broad daylight now means exactly one thing, that it cannot claim a work site.
A fourth, smaller bug was found in the same file. PlotLocation's setter does m_Plot = null and m_Configured = false as a side effect, so the original sequence of npc.Plot = found followed by npc.PlotLocation = found.Location threw the reference away immediately. It self-healed a tick later only because EcoFarmPlot.Claim() returns true for an existing owner, but it burned a full claim/invalidate cycle every time. Location first, then Plot.
AB.8Resource Workers Now Stay At Their Resource
The owner's next observation: miners, fishermen, woodcutters and orchardists were standing around where their beds used to be rather than at their seams, water and trees. Two independent causes.
The first is self-inflicted and self-worsening. FindNearestMineable(), FindNearestFishable(), FindNearestChoppable() and FindNearestRipe() all filter on the node's CURRENT harvestable state - IsMineable, IsFishable, IsChoppable, IsRipe. A node worked below its threshold drops out of the search entirely and the call returns null, at which point GetWorkSiteLocation() fell back to npc.HomePoint. The harder an NPC worked, the more it idled at home.
Each of the four activities now falls back to a new private FindNearestAnyState() - the nearest node of that type regardless of state - so the NPC waits at the seam while it regrows. HomePoint is used only when the region genuinely has no node of that type at all. This was implemented using each node class's existing public static AllNodes list; no ECO-Chain file was modified.
The second cause was that the work site was resolved exactly once per day, at dawn. Nodes deplete and regrow constantly, so that choice went stale within minutes and nothing ever re-evaluated it. A Working NPC now re-resolves its site every 30 seconds and repositions if it has drifted more than 3 tiles off, with an InRange guard that makes it a no-op in the common case.
Worth being clear about what this did and did not change: the WORK was always happening. EconomicPulse runs its own FindNearest*() from HomePoint, so these NPCs were harvesting nodes up to 200 tiles away while standing at home doing visibly nothing. This pass fixed the appearance, not the economy - regional pool throughput is unchanged.
The work-site wander range was also changed from RangeHome = 1 to WanderRange (3). At 1 an NPC was pinned to a single tile and read as frozen; at 3 it potters around its plot or seam. This costs nothing functionally - EconomicPulse works through the stored Plot reference or its own node lookup, neither of which range-checks the NPC's position.
AB.9HomePoint - the Most Load-Bearing Field, and Why It Would Not Stick
The owner had placed NPCs at an orchard in Yew and found them drifting back to where their houses used to be. The [props evidence made the cause obvious once looked at directly.
The housing self-heal at the top of OnThink() re-pinned m_HomePoint to m_RealHouse.BanLocation on EVERY tick, and m_RealHouse is persisted. With beds and housing off, it was still dragging HomePoint back to a house that no longer meant anything. Because HomePoint is the search ORIGIN for the four roaming resource jobs and the work site ITSELF for Shepherding and the nine trading jobs, retired housing was still steering where NPCs worked.
Worse, it made the problem unfixable by hand: a GM [props edit of HomePoint was silently reverted on the very next tick. Both housing safety nets - the HomePoint self-heal and the doorway-ejection teleport to BanLocation - are now gated on !DisableBedsAndHousing.
Two commands were added so NPCs can be placed deliberately. Stand where they should work, then [UNPCSetHome for one or [UNPCSetHomeNear <range> [job] for a group. SetHomePoint() also drops the stale house link and clears m_Configured so a work site is re-claimed from the new position on the next tick. Stock, reputation, memories, goals and relationships are untouched.
Re-homing means different things per job family, and the job lock means an NPC keeps whatever job it had:
| Job family | What a new HomePoint does |
|---|---|
| Farming | Only job using a real Plot. Hunts for an EcoFarmPlot within WorkSearchRadius of the new HomePoint; if there is none it cannot claim a site. |
| Mining, Woodcutting, Fishing, Orcharding | Claim a RegionKey, not a plot. The node is re-found by proximity every pulse and never reserved - two miners will work the same vein. HomePoint is the 200-tile search origin. |
| Shepherding + the 9 trading jobs | Stationary. The new HomePoint simply BECOMES the workplace. |
AB.10The Pub as a Social Venue
The cycle unification produced something nobody planned: every NPC on the shard now gathers in one building from 18:00 to 23:00 game time, every day. Before AB.5, ten of the fifteen job types never shared that room as part of a common cycle. That is a venue, and UltimateNPCPubSocial.cs uses it.
Every 90 seconds, for NPCs currently AtPub: shuffle (Fisher-Yates) and take up to six disjoint pairs. Not every pair - with 65 NPCs that is 2,080 combinations, and pairing them all would make everyone equally close to everyone, which is the same as nobody being close to anybody.
Each pair drinks together: +2 to the relationship in BOTH directions, since relationships are stored per-NPC and a one-way call would leave one of them not knowing about the other. The +2 is deliberately the smallest positive event in NPCRelationship, against OnFairTrade's +5 and OnGift's +7, because the pub fires far more often than any other relationship event. A bigger number would have the whole population best friends inside a week and make the bonds meaningless. At +2 a pair must drink together roughly 25 times to reach Friend at 50 - weeks of evenings. Familiarity should be slow.
With 35% probability the pair also exchanges real news, reusing UltimateNPCGossipHooks.TryGossipBetween rather than reimplementing the memory rules - one definition of what counts as news, what counts as secondhand, and how the "Heard from" prefix works. Measured live at 39% across 18 pairs, which is the expected variance.
This is the property that makes the single pub worth keeping. The regional gossip pass of Appendix L groups strictly by region, so a Vesper miner could never hear anything out of Yew. The pub mixes all six regions - farmers beside blacksmiths, miners beside weavers, pairings that can never meet at work sites up to 1,800 tiles apart. It is the only place cross-region news can travel.
Crossing into Friend or Romantic is real shard news: both NPCs get a memory at importance 7, there is a visible emote, and the event goes into the next day's chronicle. With 12% probability a pair also says something audible, so a player standing in the pub sees the socialising rather than only its consequences.
No AI or LLM call appears anywhere in this loop - the same rule the memory stream and gossip system have followed since Appendix L. The simulation stays deterministic and cheap; AI only ever reads the results later via UltimateNPC.Persona. No new serialised field was added either, so this cannot affect the save format.
Multiple pubs were considered on 2026-08-25 and rejected. Two changes would have been needed: GetNearestTavern() does not actually find the nearest (the signature takes a from point but the 2026-08-19 rewrite hardcoded a single return), and the pub pairing is venue-blind by design, so with four pubs a Vesper NPC could "share a drink" with someone a thousand tiles away. Migration would have been free, since m_PubPoint is not serialized. The decision was to stay at one pub precisely to preserve cross-region mixing, which per-region pubs would eliminate.
AB.11The Daily Chronicle - Sosaria in Discord
The shard produces a great deal of emergent narrative that is invisible unless you happen to be logged in and standing in the right place. UltimateNPCChronicle.cs posts a daily dispatch to Discord through the existing DiscordRelay webhook, at 08:00 UTC.
Content is assembled from things that already exist. Nothing new is stored. Bonds forged comes from an in-memory day log written when a relationship crosses into Friend or Romantic; Word got around is a count of pub gossip exchanges; Talk of the day quotes the most notable first-hand memories of the last 24 hours (importance 6 or above, one per NPC, up to six) straight out of the Appendix L memory streams; The regions is a live headcount.
The day log is deliberately not persisted - it is a 24-hour scratchpad and a restart legitimately starts a fresh page. It is capped at 250 entries so a runaway writer degrades to "some entries dropped" rather than eating memory.
DiscordRelay.SendToDiscordBlock() was added for this and is purely additive - SendToDiscord is untouched and every existing caller behaves exactly as before. It exists because the original flattens newlines to spaces, which is correct for one-line event notices and destroys a formatted report. Two details matter: DrainQueue builds its JSON by raw concatenation with no escaping, so newlines must be converted to a literal backslash-n escape (and backslashes stripped first, or the escapes would be mangled); and Discord rejects messages over 2,000 characters, so reports split at 1,800 on line boundaries with each chunk queued separately.
The first live dispatch exposed a content bug worth recording, because whole-string comparison would not have caught it. The same dragon attack was quoted three times and the same orc raid twice. This is not a fault in the memory system: AddMemoryToRegion gives every witnessing NPC (25-75% chance each) their own first-hand memory of the same event, then appends a random reaction fragment per NPC. Three NPCs really did each see that dragon, and their memory strings differ in the tail. Deduplication therefore keys on the FIRST SENTENCE, which is the event itself, giving six distinct stories instead of three plus repeats.
Confirmed live: Discord received the dispatch and rendered the line breaks correctly - the one thing a compile check could not verify.
AB.12Migration Completed - No Legacy Eco NPCs Remain
Appendix C stated that all six legacy Eco-chain NPC types had been "migrated shard-wide as of this writing". That turned out to be aspirational. It is now actually true.
The discovery came from a farmer named Vernon who stayed behind when everyone else went to night quarters. His [props gump read EcoFarmerNPC, not UltimateNPC - a different class, in a different file, with its own FarmerState machine, its own m_Bed, its own TryUseBed/TryLeaveBed and its own day/night switch. It never reads DisableBedsAndHousing and was untouched by any of this work.
The tell is quick: a legacy NPC's [props gump lacks ActivityKey, Industry, Greed, CurrentMood, HasRealHouse and SimReputationScore, all of which an UltimateNPC shows. A second tell was Vernon's CropStock of 25,994 against UltimateNPCFarmingActivity's MaxStock of 40 - the legacy class has no ceiling at all, the exact uncapped-shelf shape Appendix N.2 documented.
Migration carried him across cleanly and he joined the unified cycle. All three batch commands were then run and each returned 0 converted, 0 failed, 0 skipped, unrecognised types: none - the all-clear. The population rose from 57 to 65 as legacy NPCs converted, which is a swap and not an addition: MigrateFarmer does old.Delete() then new UltimateNPC(), so [UNPCList climbs by exactly the number converted while the legacy population falls by the same. World population is unchanged.
Two cautions for any future migration. Serials change, so ConversationMemory resets - the migration file's own header acknowledges this as unavoidable. And large legacy stock survives intact: Vernon's 25,994 is safe under Farming because that activity clamps with Math.Max(0, Math.Min(yield, room)), so the shelf simply stops growing until sales bring it under 40. Migration is also the one irreversible step in the whole deployment - old.Delete() destroys the legacy mobile and there is no un-migrate command.
AB.13Files
| File | Change |
|---|---|
| UltimateNPC.cs | DisableBedsAndHousing flag, job lock, m_NightPoint, TeleportToWork(), IsNightNow(), DebugForceNight/Day, ForceGoWork(), SetHomePoint(), the !UsesDayNightCycle branch removed, claim-failure fix, housing self-heals gated, 30s work-site re-check. |
| UltimateNPCNightQuarters.cs | NEW. Region-key to Point3D table, scatter and standability checks reused from UltimateNPCTaverns, nearest-anchor fallback, [UNPCNightCheck. |
| UltimateNPCPubSocial.cs | NEW. The 90-second pub socialising pass; [UNPCPubSocialCheck. |
| UltimateNPCChronicle.cs | NEW. UNPCChronicleLog day log plus the daily Discord dispatch; [UNPCChronicle, [UNPCChroniclePost. |
| UltimateNPCCommands.cs | [UNPCForceWork, [UNPCSimulateNight/Day/Normal, [UNPCRechooseJob, [UNPCSetHome, [UNPCSetHomeNear; [UNPCForceHome now auto-pins the clock. |
| UNPCBedCleanupCommands.cs | [UNPCWakeStuckBeds. |
| UltimateNPCShepherdingActivity.cs | UsesDayNightCycle now returns true. |
| UltimateNPCTradingActivityBase.cs | UsesDayNightCycle now returns true (covers nine trading jobs). |
| UltimateNPCFarmingActivity.cs | Plot re-claim falls through to FindNearestUnclaimed; PlotLocation/Plot assignment order corrected. |
| UltimateNPCMining/Woodcutting/Fishing/OrchardingActivity.cs | FindNearestAnyState() fallback so a depleted node no longer sends the NPC home. |
| NPCRelationship.cs | OnSharedDrink() at +2. No serialization change. |
| RelationshipService.cs | OnDrankTogether() applying the drink in both directions and reporting threshold crossings. |
| UltimateNPCGossip.cs | TryGossip made public and returns bool, as TryGossipBetween, so the pub reuses it. |
| DiscordRelay.cs | SendToDiscordBlock() for multi-line reports. Purely additive. |
| IDOCCommandGump_1_1.cs | UltimateNPC category rebuilt from 4 sections to 7. |
No serialization version was bumped anywhere in this pass and no new persisted field was added to UltimateNPC. Relationships already persist on their own per-NPC list, memories go through the existing AddMemory call, and both the chronicle day log and m_NightPoint are in-memory only. This pass cannot corrupt existing save data.
AB.14GM Command Reference
The IDOCCommandGump UltimateNPC category was rebuilt from four sections to seven. The audit found it had drifted further than expected: six commands that predate this pass had never been added to the gump at all - [UNPCBedCleanup, [UNPCFixHouseBeds, [UNPCAssignHouse, [UNPCPubTest, [UNPCListRegion and [UNPCNearby - on top of the thirteen added here.
| Command | Access | Effect |
|---|---|---|
| [UNPCSimulateNight | GM | Pins the day/night check to NIGHT so NPCs go to night quarters and stay. |
| [UNPCSimulateDay | GM | Pins it to DAY. Also suppresses the pub trip. |
| [UNPCSimulateNormal | GM | Clears both pins. Always run this when testing finishes. |
| [UNPCForceWork | GM | Everyone to their work site now; auto-pins DAY. Names anyone that cannot claim a site. |
| [UNPCForceHome | GM | Everyone to night quarters now; auto-pins NIGHT. |
| [UNPCNightCheck | GM | Checks all six night anchors for standability - tile ID, flags, fit, water. |
| [UNPCSetHome | GM | Target one NPC; your position becomes its HomePoint. Drops the stale house link. |
| [UNPCSetHomeNear <r> [job] | GM | Re-homes every UltimateNPC within r tiles, with an optional job filter. |
| [UNPCRechooseJob | GM | Escape hatch from the job lock. Target one, or pass all. |
| [UNPCWakeStuckBeds | GM | Releases every EcoFarmBed still flagged Asleep by an UltimateNPC. Preview, then apply. |
| [UNPCPubSocialCheck | GM | Forces one pub socialising pass. Only affects NPCs currently at the pub. |
| [UNPCChronicle | GM | Previews the daily dispatch in-game. Does not post and does not clear the day log. |
| [UNPCChroniclePost | Admin | Posts to Discord now and clears the day log. |
The clock pins exist for a reason worth stating plainly, because it cost a previous session three separate bug reports for one root cause. IsNightTime() reads the real in-game clock, so any force command run at the wrong time of day APPEARS broken: the NPCs move correctly, then the next OnThink tick - about one real second later - re-reads the clock and correctly undoes it. Both force commands now pin the clock themselves. IsNightNow() resolves as DebugForceNight ? true : DebugForceDay ? false : IsNightTime(); neither flag is persisted and neither touches lighting, spawns or any other clock-driven system.
The Simulate pair is also the better test tool. It pins only the clock, so the NPCs still decide and move under their own logic - a genuine test of the real dusk and dawn transitions, compressed from roughly two hours to about twenty seconds. The Force commands shove them directly and prove less.
AB.15Deployment - Code and Save Promoted Together
The promotion to production took a different route than the runbook anticipated, and the route mattered.
Rather than copying code alone and re-running migration on production, the owner promoted the eleven changed files AND the DEV world save together - the DEV save being itself a snapshot of production taken earlier the same day, with no players online in between. Because the save came across, the completed migration came with it: production inherited 65 already-migrated, already-re-homed UltimateNPCs and never needed the batch migration step at all.
Production came up clean, NPCs were observed in the pub during the correct evening window with chatter firing, and [UNPCWakeStuckBeds returned zero. This is worth recording as a valid promotion pattern when the target's save is already a snapshot of the source and nothing has happened on it since - but it overwrites all world state on the target, so it is only safe under exactly those conditions.
Two file-handling failures occurred during the DEV deployment and both are recorded because they will recur. Three modified files silently did not land when saved manually, producing CS0117 errors for methods that existed in the delivered file but not on disk; the diagnosis was comparing file SIZES against the manifest, which took two minutes. Separately, a browser saved a second copy of the command gump as IDOCCommandGump_1_1_3.cs alongside the real one, producing seven CS0101 duplicate-definition errors. A full recursive scan of all 325 files confirmed it was the only such collision. After any manual file copy, compare sizes against the manifest before restarting.
AB.16Status
Deployed to DEV, verified in-game, and promoted to production 2026-08-25. Compiled with 0 errors and 0 warnings. Confirmed live: all 65 NPCs sleeping at night quarters under a pinned night clock, including every previously always-working job type; all 65 returning to their work sites at day; orchardists and woodcutters visibly among the trees rather than at a doorstep; [UNPCNightCheck reporting 6 of 6 anchors usable; [UNPCPubSocialCheck reporting 65 at the pub with 6 pairs socialising per pass; 7 gossip exchanges from 18 pairs; [UNPCWakeStuckBeds reporting zero; and the chronicle received and correctly rendered in Discord.
Not yet observed: a bond actually crossing into Friend under natural play. By design - at +2 per drink that is roughly 25 shared evenings away. To watch a crossing sooner, [props a pair's relationship up to 48 and run [UNPCPubSocialCheck until they are drawn.
AB.17Known Follow-Ups, Not Done This Pass
| Item | Detail |
|---|---|
| Six per-region night coordinates | All six rows in UltimateNPCNightQuarters.cs still point at [1295,1761,10]. Every NPC resolves to a real region, so this is one line per region and no other change. Until then all 65 leave their region nightly and return at dawn. |
| Cove ecology regrowth | Live nodes at Cove showed CurrentAmount 0 with an EcoOrchardNode needing 15 growth ticks to become workable and an EcoTreeNode needing 80 (RegrowthRate 1 against ChoppableThreshold 80). Not a code bug, but migration has since increased the number of workers drawing on those sites. |
| Missing Math.Max(0, ...) guard | Mining, Woodcutting, Orcharding and Fishing compute keep as Math.Min(x, room) without the zero clamp Farming has. If stock ever exceeds MaxStock the shelf snaps to 40 in a single pulse with no message, since the profit line is gated on keep > 0. Latent rather than live - no migration path feeds those four jobs. |
| Phase3A double-initialisation | Both NPCGoalsEngine_Phase3A.cs and Phase3A_Initialization_FIXED.cs define Initialize(), so RunUO calls both and two World_Load hooks are registered. Visible in the startup log as a duplicated banner. Probably harmless, possibly wasteful. |
| No startup log line for the pub timer | UltimateNPCPubSocialHooks prints nothing when it arms, unlike the chronicle. [UNPCPubSocialCheck is currently the only way to confirm it started. |
| Manual re-homing | Some NPCs remain at locations chosen by the retired housing system rather than deliberately. [UNPCSetHomeNear is the tool; the work is manual and ongoing. |
Consequence — What the Live World Corrected, and the Criers Who Now Announce It (2026-08-25)
A day of closing loops rather than opening systems. Nine faults were found and fixed, three of the previous day’s own findings were overturned by live data, and the shard’s news finally reached somebody standing in a town. The through-line is stated plainly in AC.13: this shard has never suffered from a shortage of simulation. It has suffered from simulation without consequence.
Three of the corrections below were forced by production data contradicting a conclusion drawn from a snapshot. That pattern recurred often enough in one day to be worth treating as a rule, so it is recorded first.
AC.1The Counter That Resets — a Verification Lesson, Recorded First
The 2026-08-25 web audit concluded that nothing on the shard ever buys from the Eco-Chain vendors, and built a whole forward plan on that finding. The evidence was the staff page reporting “Buys today 0, Buys all-time 0”, alongside every resource pool sitting at its cap.
The finding was wrong. The counter is labelled “all-time (since last restart)”, and the snapshot was taken roughly ninety minutes after a restart. A freshly restarted shard reads zero because the counter has been reset, not because nothing happens. Seven hours later the same page read 35 buys, with one simulated player actively shopping.
Worse, SimPCEcoShoppingActivity.cs documents its own history in its comments — the flat baseline being raised from 4 to 22, the note that this was “confirmed via [SimPCActivityScores”, the Shopper archetype being introduced in SimPCRolePlan.cs, and a distance filter being “tried (twice) and reverted”. That work was done in earlier sessions and had succeeded. Those comments were read and quoted during the audit, and the audit still declared the system dead.
Two rules follow, and they generalise past this one mistake:
| Rule | Why |
|---|---|
| A counter scoped “since last restart” proves nothing until the shard has been up for hours | Uptime on the audited snapshot was 0d 0h 0m. Every since-restart figure on that page was structurally incapable of being non-zero. |
| Documented prior work outranks a single cold snapshot | The file’s own comments recorded a working system. When live data and a source comment disagree, sample the live data again over a longer window before believing the disagreement. |
| Prefer a live command’s output over any figure in a document | Already recorded after the 300-vs-57 population error (Appendix AB). This is the same lesson arriving through a different door. |
What the longer window actually shows is more useful than either reading. Demand exists at roughly five purchases an hour, and production still outruns it: forty of the forty-eight region-resource cells remain at cap, harvest pressure is 0% in all six regions, and two pools that had been below cap in the morning had refilled by evening. The single exception is Cove Fruit, which fell from 142/200 to 68/200 — the one place where a live orchard and enough buying coincide. Cove Fruit is therefore the shard’s first genuine scarcity gauge.
AC.2Restart at Night Stranded the Entire Population
A restart at 23:40 game time left all sixty-six UltimateNPCs standing in the pub until dawn, while the staff page correctly reported them “asleep at night quarters”. State was right; position was wrong.
The chain has three links, and each is individually reasonable:
| Link | Detail |
|---|---|
| m_State is not serialised, and its field initialiser is Sleeping | Every NPC therefore comes out of a restart in the Sleeping case, standing exactly where the world save caught it. |
| m_NightPoint is not serialised either | So it is Point3D.Zero on load, and the wander anchor falls back to m_HomePoint. |
| The Sleeping case only set the anchor and waited for dawn | It never asked whether the NPC was actually AT night quarters. There was no code path that could move a sleeping NPC to where it was supposed to be sleeping. |
The comment above that branch had already named the exact case — “an NPC that landed in Sleeping without going through TeleportHome” — and had fixed only the wander anchor, not the body. A comment that identifies a hazard and then addresses half of it is more dangerous than no comment, because it reads as covered.
Fix: the Sleeping case now relocates once when it is night, beds are disabled and m_NightPoint is Zero. The guard is a dedicated non-persisted flag, m_NightRelocateAttempted, rather than a test on m_NightPoint alone — if a relocation ever failed to find a usable landing spot, keying off m_NightPoint would retry the teleport on every tick forever. A failed attempt now costs one try per NPC per server session. The flag clears at dawn and in TeleportToWork.
The bug was restart-at-night only. Daylight was always healthy, which is exactly why it survived undetected: the shard is normally restarted during the day.
AC.3Equilibrium Zero — Why the Pub Could Not Produce a Friendship
This is the most important finding in this appendix, and it is a design law rather than a bug. The pub social system deployed on 2026-08-24 was working exactly as written and could never have produced a friendship.
The evidence, from unpc_relationships.json on a shard that had been running the system for a day and the relationship system for far longer: 739 relationships across 65 NPCs, an average of 11.4 each — so the machinery was plainly running — and a highest value anywhere on the shard of 12. Friend begins at 50. All 325 top-ranked relationships were typed Neutral, and 320 of them sat in the 0–4 band.
That is not slow progress. It is an equilibrium, and the arithmetic is unforgiving:
| Quantity | Value and source |
|---|---|
| Possible pairs | 2,080 — 65 NPCs, all in one room every evening |
| Encounters per night | ~108 — 6 pairs per 90-second pass over a pub window of about 28 real minutes |
| Game-clock rate (measured live) | ~10.7 game minutes per real minute, so the 18:00–23:00 window is ~28 real minutes |
| Chance a given pair meets on a given night | 5.2%, i.e. once per ~19 nights |
| Gain when they do meet | +2 (NPCRelationship.OnSharedDrink) |
| Decay while they do not | −1 per week (ApplyTimeDecay, after 7 idle days) |
| Net per cycle | Approximately zero — +2 earned, about 2 lost over the 19-day wait |
A pair gains two points every nineteen days and loses roughly two waiting for the next meeting. The system was holding station, not building, and the observed distribution is exactly what that predicts.
The affinity pairing added the same day was the one mechanism that can break the equilibrium: once a pair meets deliberately they gain +2 a night and decay never touches them. But ReunionThreshold was set to 20, reasoned as “safely below Friend at 50”. Nothing on the shard had ever reached 20. The escape hatch was behind a door that required the thing it existed to produce, and reunion pairing had never fired once.
ReunionThreshold is now 4 — two chance meetings, reachable in a fortnight. On crossing it a pair enters the reunion channel, meets most nights, climbs at +2 a night with decay permanently outrun, and reaches Friend about 25 nights later. MaxReunionPairs still caps reunions at half a pass, so the fresh-blood channel keeps introducing strangers and no lock-in occurs.
Within one session of the change the 5–9 band went from 4 pairs to 30, total relationships rose from 739 to 1,030, and 68 pairs sat at exactly 4 — on the threshold, about to start compounding. The maximum was still 12, which is correct: this is a 25-night arc, not an overnight one.
The general lesson is worth stating separately from the specific fix. Any system that pairs N agents at random and applies time decay has a population-size-dependent equilibrium. Adding a preferential-attachment channel does not help unless its activation threshold is reachable by the random channel alone. Threshold design must be checked against the random channel’s actual throughput, not against the target value.
AC.4Naming a Bond Honestly
Lowering ReunionThreshold made [UNPCBonds useful at low values for the first time, which immediately exposed a labelling fault: CollectBonds typed everything below 60 as “Friend”, so a pair who had shared two drinks was reported as friends at value 4. That is not a cosmetic quibble — the entire purpose of the command is judging whether bonds are forming, and a label reading “friend” at 4 destroys the reading.
| Band | Value |
|---|---|
| Romantic | 60+ |
| Friend | 50+ |
| Familiar | 4+ (ReunionThreshold) |
| Passing | below 4 |
[UNPCBonds also now reports how many pairs sit at or above the reunion threshold, which is the number that actually predicts whether friendships will form, and labels its third column “interactions” rather than “evenings” — InteractionCount includes trades and gifts, not only shared drinks. A live pair at value 5 with a single interaction proved the point: that was OnFairTrade at +5, not a drink.
AC.5The Dashboard Was Structurally a Day Behind
All seven UltimateNPC and SimPC web exports were anchored to the next UTC midnight and then repeated every 24 hours, while status.json and notable_raiders.json refreshed roughly once a minute. Every NPC-facing page was therefore up to a full day stale.
The visible symptom was that every page reported 65 NPCs while the shard ran 66, and the region breakdown on The Living World disagreed with the in-game chronicle. The structural problem was worse: relationships now move every night at the pub, so a daily snapshot could not show anything but yesterday, and the Relationships page was incapable of ever showing the system working.
| Export | New cadence |
|---|---|
| UltimateNPCListWebExport | 15 minutes |
| UltimateNPCGoalsWebExport | 15 minutes |
| UltimateNPCRelationshipsWebExport | 15 minutes |
| UNPCThoughtsWebExport | 15 minutes |
| SimBiographyWebExport | 60 minutes — a 4.2 MB file whose content (a sim’s life history) moves slowly |
Each keeps its original method name so the diff stays small, and each file’s existing 7/8/9/10-second boot stagger is preserved so they do not all fire together. The initial delay is one minute rather than the old until-midnight, so the dashboard is current within a minute of a restart instead of at the following midnight.
AC.6One Event, One Hundred and Forty-Three Names
UltimateNPCMemoryStream.AddMemoryToRegion broadcasts a single event to every NPC in the region who witnesses it — 25–75% chance each — and appends a different reaction fragment per NPC. That is correct simulation: they really did all see it. It is unreadable as a report.
Measured across the live shard: 488 memory entries collapsing to fifteen distinct events, with two of them accounting for 293. “The wild dragon’s been spotted roaming these parts” appeared 147 times and “The dragon took one of the flock right out in the open” 146 times. Sixty-one percent of all entries were secondhand retellings of the same two events.
EventKeyOf() had been written for the chronicle on 2026-08-24 to solve exactly this, keying on the first sentence because the appended fragments differ. It was private and chronicle-only, so the Thoughts page and the Staff Status memory feed still read as one dragon story on a loop. It is now public and shared.
The first shared version was still wrong, and the live data said so within minutes. Gossip writes secondhand memories as GossipPrefix + teller name + “: ” + original, so “Heard from Cytheria: The dragon took...” keyed differently from the eyewitness account and from every other reteller. One event fragmented across as many keys as it had tellers: 500 entries produced 143 distinct keys where there were really about eighteen events. EventKeyOf now strips the prefix and teller name before taking the first sentence, which collapses those 143 keys to 20.
The Staff Status feed keeps the newest witness per event as the representative and reports the rest inline as “(+N others saw this)”, with a footer counting folded accounts — 3,467 on the first run. The witness count is surfaced rather than hidden, because nine people seeing a dragon is a different fact from one person noticing it. The Thoughts export dedupes within each NPC’s own stream so a single card can no longer be one event repeated five times.
AC.7Phase 3A Initialised Twice — and Appendix AB Named the Wrong Cause
AB.17 recorded this follow-up as “Both NPCGoalsEngine_Phase3A.cs and Phase3A_Initialization_FIXED.cs define Initialize(), so RunUO calls both”. That diagnosis was incorrect. The cause is one file.
Phase3A_Initialization_FIXED.cs declared a static constructor that called Initialize(), and RunUO’s ScriptCompiler also calls every public static Initialize() it can find. Merely touching the class ran the static constructor first, so Initialize() ran twice at every boot. The doubled console banner was cosmetic; the real cost was subscribing AutoPopulateGoalsForExistingNPCs to EventSink.WorldLoad twice, so goal population walked the entire population two times on load.
The static constructor is removed. The ScriptCompiler call is the one kept, because it is ordered alongside every other Initialize() on the shard whereas a static constructor fires at whatever arbitrary moment something first touches the type. An m_Initialized guard makes any second call harmless rather than duplicating the subscription.
AC.8The Town Crier Was Already There
The forward plan called for a town crier reading the day’s news aloud, estimated as a small build. Inspection of C:\idoca showed RunUO’s stock TownCrier at Scripts/Mobiles/Townfolk/TownCrier.cs — untouched since 2013, fully functional, and referenced by not one line of custom code on this shard. It already provided the global entry list, per-crier lists, a one-minute auto-shout timer, automatic expiry, the *news* speech keyword within 12 tiles, and a [TownCriers staff gump.
The work was therefore a patch panel, not a system. The chronicle already knew what happened; the criers already knew how to shout; nothing connected the two.
The tee sits inside UNPCChronicleLog.Record, which is already the single funnel every notable event passes through. The consequence is that any future event source which records to the chronicle gets an in-game voice for free, without knowing the criers exist — Phase 1’s shortage announcements will start speaking on their own.
The daily digest was deliberately rejected as the content source. UltimateNPCChronicle.Build() returns a long block of Discord-flavoured markdown that is a day old by the time it would be read. Events pushed live with a 20-hour duration make the crier’s list a rolling window of things that genuinely just happened. A newspaper is less alive than a ticker.
Constraints worth keeping: the 40-entry per-crier cap is load-bearing, because TownCrier has none of its own and picks a random entry each shout — an uncapped list would not spam, it would bury today’s news under a week of stale items. Announce() never throws, because it is reachable from UltimateNPC.OnDelete, which runs during mass deletes and world shutdown.
AC.9Criers, Regions and the Outlands
A crier’s region is resolved from the nearest EcoGrassNode — they are seeded per region, 28–40 in each, so the answer is reliable and needs no new coordinate table to drift out of date. Place a crier and it adopts its region by itself.
The first version took the globally nearest node with no distance cap, copied from SimPCEcoShoppingActivity.ResolveRegionKey. That function deliberately has no distance filter, and correctly so — it is choosing a travel destination, and recall makes distance irrelevant. It is the wrong pattern for deciding what a crier IS.
The live world proved it within minutes. Of the 22 criers placed on the shard, nine were assigned to a region they are nowhere near: Rosalind at (5290,3988) speaking for Trinsic from 3,651 tiles away, Agustin for Vesper from 3,720. Every one of the eleven genuinely in-town criers sat within 256 tiles of its region, so a 300-tile radius separates the two populations cleanly.
Rather than silence the far ones, they became a third class:
| Class | Definition and behaviour |
|---|---|
| Home | Within RegionRadius (300) of a region. Speaks for that region only. |
| Outland | On the living world’s map but beyond every region. Carries the news of all regions, attributed: “Word from Yew: ...”. True, better than silence, and it quietly teaches a player where the settled world is. |
| Off-map | Trammel, Tokuno, anywhere the ecology does not reach. Hears nothing — Sosaria’s gossip has no business there. |
The global fallback from the first version was removed. It pushed regional news onto every crier on every map, which is the exact flattening this design exists to prevent. If nothing takes a piece of news it is now simply dropped; the chronicle still holds it.
AC.10Three Corrections to the Same Day’s Audit
Beyond the Eco-Chain error in AC.1, source-level tracing overturned two further findings from the 2026-08-25 web audit. All three are recorded because the pattern — a symptom read as a fault without tracing it to source — is the thing to avoid, not the individual mistakes.
| Audit finding | What tracing showed |
|---|---|
| “regionActivity exports as an empty object” | Correct behaviour. UltimateNPCListWebExport emits only regions that currently have active militia or active raiders, and Staff Status independently confirmed 0 orc raiders in all six regions. The empty object was the truthful answer. The Living World page had never read the field at all, so a Militia & Raiders panel was added with an explicit quiet state. |
| “Work commitment reports 0 committed and 0 idle — dead panel” | Dead by design, not broken. CommitToActivity has exactly one caller, UltimateNPCBrain.ChooseActivity, and only fires when the job changes. Removing the morning job re-check for the permanent job lock (Appendix AB.2) left ChooseActivity reachable only by brand-new NPCs. Commitment was the anti-job-switching mechanism; the job lock is a stronger version of the same idea and superseded it. The panel should be retired, not repaired — two systems solving one problem is what produced the original job churn. |
| “No NPC has ever formed a belief” | Config, not code. A belief is the most recent reflection. UltimateNPCReflectionService produces one via a live AI call and, on failure, deliberately leaves LastReflectionUtc untouched so the accumulated importance is not discarded — which means a failing backend is completely silent, forever. The console log showed 145 failed calls in one day, all HTTP 400: “Your credit balance is too low to access the Anthropic API.” Data/AIConfig.cfg is present, Enabled=true, key valid. Add credit and all 66 NPCs acquire a belief within about 3.5 hours, five per 15-minute sweep, with no restart. |
AC.11Goals — 192 of 217 Have No Path Forward
unpc_goals.json reported 217 active goals, zero completed, and averageProgress of 0 for all 65 NPCs. Tracing found the cause is structural rather than a bug: only two goal-advancement paths exist in the entire codebase.
| Path | Reach |
|---|---|
| UltimateNPC.AdvanceGoalProgressOnProfit() | Fires from OnProfitableAction. Advances four types only: SaveGold, BuyHouse, StartBusiness, BecomeWealthy. |
| UltimateNPC.OnHomeAcquired() | Completes BuyHouse. Permanently closed — DisableBedsAndHousing (Appendix AB.3) means no NPC ever acquires a home again. |
Against a census of BuildReputation 44, WinHeart 35, PrepareForWar 23, ProtectFamily 21, GainRespect 18, UnderstandWorld 18, SaveGold 17, PerfectCraft 13, FindLove 12, StartBusiness 6, BreedRareVariant 5, FindMeaning 3, BuyHouse 2 — 192 of 217 goals have no way to progress at all.
The 23 that could are also nearly static, because the one live hook needs profit and purchases were running at roughly five an hour. Note also that 47 of the 217 are romance goals (WinHeart and FindLove), which the relationship system could not satisfy at all while AC.3 stood. Several findings in this appendix are one finding wearing different clothes.
This is explicitly not a patch. Reputation, war-readiness and family goals have no event to hook, and adding advancement for them requires a design pass, not a fix. No new goal types should be added until the 192 that cannot advance can.
AC.12The Demand Dial
SimPCShopperCommands.cs adds two commands and modifies nothing else. It exists because EcoShopping wins the activity draw occasionally rather than never (AC.1), and more demand is a dial worth having rather than a fault worth fixing.
The score is 22 + MarketSavvy/6 + RoleBias(sim, EcoShopping), and RoleBias returns a meaningful value — RoleCommitment/2 — only for a sim whose RolePlan is Shopper. The Shopper archetype arises naturally only at MarketSavvy >= 65 and Greed <= 35, and across the twenty sims on this shard nobody had rolled it.
Critically, the command changes role assignment, not the score baseline. The 22 was a deliberate tuning decision from earlier sessions — EcoShopping sits in the middle of the pack rather than winning by default — and that tuning is untouched. Every non-Shopper sim behaves exactly as before.
RoleCommitment is set to 90, and the number is load-bearing. SimPCRolePlanner.MaybeUpdateRolePlan returns early, leaving the role alone, when RoleCommitment >= 75; anything below that and the planner quietly undoes the assignment within the hour. 90 clears the bar and makes RoleBias’s strong value worth 45 points, lifting EcoShopping from roughly 25 to 70–77 against Hauling at 60 and Mining at 59. It is deliberately not 100: Desperation >= 80 or RecentVictimization >= 60 still permits re-planning, because a starving or repeatedly robbed sim should be allowed to stop shopping. RolePlan and RoleCommitment are both serialised, so the assignment survives restarts.
Recommended starting point is two rather than four, with Cove Fruit as the gauge (AC.1). If the pool keeps refilling, raise it.
AC.13The Diagnosis: Simulation Without Consequence
Reading the codebase to write a forward plan produced a conclusion worth recording in this document, because it reframes what “more alive” means for this shard.
There is no shortage of systems. Memory streams, gossip, relationships, moods, goals, reputation, an eight-resource ecology across six regions, weather, seasons, orc raiders with bounties, a wild dragon with 1,754 kills, twenty simulated players with houses and guilds and boats, sixty-six townsfolk with jobs and friendships. What is missing is consequence. Events are recorded faithfully and change nothing. The world writes a diary nobody reads back.
The SimPC-versus-UltimateNPC duplication that has felt like wasted effort is, on inspection, the opposite. SimPCs are consumers and adventurers; UltimateNPCs are producers and townsfolk. They are the two halves of one economy that were never fully connected. The chain runs producers deposit into the regional pool, traders withdraw and turn it into finished stock — and then very nearly stops, because consumption runs at about five purchases an hour against production that refills faster.
The ordered plan that follows from this is recorded separately (Living_World_Roadmap_2026-08-25). Its shape: switch on what already exists; let scarcity exist so gossip becomes true rather than flavour; make events leave physical marks; give the cast a lifecycle so the world changes over time; and put all of it in front of players from inside the game rather than only on web pages. Almost none of it means building another system.
One sequencing note belongs here. The NPC lifecycle — aging, death, succession — is the highest-ceiling item and there is currently no lifecycle code of any kind. It should not begin until the relationship system has produced real history. A death only lands if the deceased had bonds worth losing, and after AC.3 the strongest bond on the shard was 12 out of 100.
AC.14Files Touched
| File | Change |
|---|---|
| UltimateNPC/UltimateNPC.cs | Restart-at-night relocation with m_NightRelocateAttempted guard; death news attributed to a region captured before deletion. |
| UltimateNPC/UltimateNPCPubSocial.cs | ReunionThreshold 20 → 4 with the equilibrium arithmetic recorded in the constant’s doc comment; BandFor() bond bands; [UNPCBonds reporting; bond news attributed to a region. |
| Web Facing/UltimateNPCChronicle.cs | EventKeyOf() made public and taught to strip the gossip prefix; Record(line, regionKey) overload teeing to the criers. |
| UltimateNPC/UltimateNPCCrier.cs (new) | The whole crier bridge: Announce(), region resolution with RegionRadius, outland/off-map classes, four GM commands. |
| Web Facing/StaffWebStatus.cs | AI-Town memory feed deduped by event, newest witness kept, folded-account count reported. |
| Web Facing/UNPCThoughtsWebExport.cs | Per-NPC event dedupe; 15-minute cadence. |
| Web Facing/UltimateNPCListWebExport.cs, UltimateNPCGoalsWebExport.cs, UltimateNPCRelationshipsWebExport.cs | 15-minute cadence. |
| Web Facing/SimBiographyWebExport.cs | 60-minute cadence. |
| UltimateNPC/Phase3A_Initialization_FIXED.cs | Static constructor removed; m_Initialized guard added. |
| SimPC/SimPCShopperCommands.cs (new) | [SimPCMakeShoppers and [SimPCShoppers. |
| wwwroot/The_Living_World.html | Militia & Raiders panel added with an explicit quiet state. |
AC.15GM Command Reference — New This Pass
| Command | Purpose |
|---|---|
| [UNPCCrierList | Every town crier, its resolved region or outland/off-map class, distance to the nearest region, and entry counts. Run this first — with no criers placed, every announcement is silently swallowed. |
| [UNPCCrierTest <text> | Pushes a line of test news to the criers of your current region, exactly as a real event arrives. |
| [UNPCCrierRescan | Forgets cached crier-to-region mappings. Run after placing, moving or deleting a crier, or after seeding new grass nodes. |
| [UNPCCrierClear | Empties every crier’s news list and the global list. Does not touch the chronicle or NPC memory. |
| [SimPCMakeShoppers [count] | Gives the highest-MarketSavvy eligible sims the Shopper role at commitment 90. Default 4; start with 2. |
| [SimPCShoppers | Who holds the Shopper role, what EcoShopping scores for them, and how many sims are structurally unable to shop. |
| [UNPCBonds [minValue] | Extended this pass: honest band names, reunion-threshold count, and an interactions column rather than “evenings”. |
AC.16Status
All items in AC.2 through AC.9 and AC.12 are deployed to DEV, compile-verified against the on-disk tree by error-code-set comparison, and byte-verified after commit. The crier bridge was confirmed working in game, shouting over a crier’s head in Britain. No serialised field was added or changed anywhere in this pass, so every item is safe to roll back by restoring files.
Working practice unchanged: DEV is tested for a period and promoted to the cloud PROD shard once or twice a day.
AC.17Appendix AB.17 Revisited — What Closed, What Remains
| AB.17 item | State after this pass |
|---|---|
| Phase3A double-initialisation | CLOSED — and AB.17’s stated cause was wrong. See AC.7: one file, a static constructor racing the ScriptCompiler, not two files defining Initialize(). |
| No startup log line for the pub timer | CLOSED — UltimateNPCPubSocialHooks now announces itself at boot, and announces being disabled. |
| Manual re-homing | CLOSED — the orphaned-house audit was completed by hand on 2026-08-25. Standing constraint recorded: some houses in the world belong to SimPC and must never be treated as UltimateNPC leftovers. |
| Six per-region night coordinates | STILL OPEN — all six rows in UltimateNPCNightQuarters.cs remain [1295,1761,10], so the entire population sleeps in one building. The scatter should widen from ±1 to ±3 or ±4 at the same time. |
| Cove ecology regrowth | STILL OPEN. |
| Missing Math.Max(0, ...) guard | STILL OPEN in Mining, Woodcutting, Orcharding and Fishing. |
New follow-ups opened this pass:
| Item | Detail |
|---|---|
| Anthropic API credit | Blocks all reflection and therefore all beliefs. See AC.10. |
| Region seeding | Ore empty in four of six regions, fish in three, orchard and tree nodes present only in Cove and Yew. Population is also uneven — Skara Brae 7 against Cove 17. |
| history_backfill.json | Re-run [WorldHistoryBackfill; the file dates from 2026-08-02 and understates houses by 29. |
| RegionLabelFor() duplication | Still duplicated across four web-export files while UltimateNPC.EffectiveRegionKey does the same work. |
| Retire the commitment panel | See AC.10 — dead by design, should be removed rather than repaired. |
| Goal advancement design pass | See AC.11 — 192 goals have no path. Needs design, not a patch. |
Lag, Goals, and a Stale Key (2026-09-01)
A week of running repairs rather than one clean day of them — the ecology, the lag, the goals and the AI key all came back at different points and had to be chased down in whatever order they surfaced. None of it opens a new system; all of it is the standing constraint (wire together what exists) applied to four unrelated corners of the shard. As with Appendix AC, more than one conclusion below was corrected mid-week by better data than the finding that came before it, so the corrections are kept in, not smoothed away.
AD.1The Guess That Should Have Been a Question
The first pass at this week’s lag investigation opened with a guess: that the sleep-policy fix documented in earlier sessions (hardcoded PlayerRangeSensitive => false keeping roughly 21,766 mobiles’ AI timers permanently awake) had never actually reached PROD, based only on the shape of an overnight hitch report. It had not been checked against PROD’s own code, because there is no device-bridge access to PROD’s files from this session — only to Richard’s DEV box.
Richard confirmed directly, the same day, that PROD already carries the identical fix DEV has. The guess was wrong, and is struck from the record here rather than quietly dropped, so a future session does not repeat it. Live command output settled it in one line — [ShardHealth timers on PROD shows BaseAI+AITimer at roughly 300—325 entries on the FiftyMS priority, not the old ~21,766.
Appendix AC.1 already recorded this rule after a “since last restart” counter was mistaken for proof a system was dead. It recurred this week in a different shape: a plausible-sounding inference from symptoms, asserted before the underlying code or a live command had actually been read. Two data points is enough to call it a pattern worth watching for on this project specifically — a guess about a system this session cannot directly inspect (PROD’s files) is a hypothesis to state as one, not a finding to hand to Richard as settled.
AD.2Yew Ecology Collapse — Grass Alone Wasn’t the Cause
The Yew region was stuck in ecological collapse: grass fill 12—16%, 0 sheep, 0 wolves, 18 rabbits sitting at cap. The chain is documented in EcologySystem.cs: sheep will not respawn below AverageGrassFill >= 0.30, and wolves need 10+ sheep before they respawn either, so a region that falls far enough below the sheep threshold locks itself out of both tiers at once — a self-reinforcing dead state, not a slow recovery in progress.
[EcoGrassFill yew 60 forced grass above the threshold and worked immediately: sheep respawned to 22, the region read [HEALTHY]. Roughly nine minutes later, grass had crashed back to 14% and the region read [STRESSED] again — even though the identical trick had held cleanly on other regions in earlier sessions (Cove and Skara Brae). That difference was the actual question worth answering, rather than simply re-running the same command and hoping it held the second time.
AD.3The Rabbit That Wasn’t Supposed to Matter
EcoRabbit.cs was read directly rather than assumed clean. Its own header comment describes rabbits as a “texture” herbivore, deliberately kept out of EcologySystem.cs’s population balancing, with no wool or economy tier and, in the file’s own words, no downstream tier “actually depending on it existing.” The OnThink loop, however, is a fully working hunger/grazing cycle: rabbits path to the nearest EcoGrassNode and call node.Consume(BiteSize: 6, this) — the same method EcoSheep calls with BiteSize: 15. Consume() unconditionally reports to EcologySystem.OnGrassConsumed, so rabbit grazing counts toward the region’s grass-consumed totals; it is only invisible to the sheep/wolf population-balancing logic and to [EcoPurge, which culls sheep and wolves and, confirmed by reading EcoCommands.cs, has no rabbit-cull path at all.
Yew was the only region carrying a rabbit population — 18, sitting at MaxRabbitsPerRegion — every other active region reported 0. So Yew was carrying sheep grazing and rabbit grazing on the same 25-node pool that a region like Britain, also 25 nodes, was covering with sheep alone. That extra, EcologySystem-invisible load is almost certainly why the grass-fill trick alone would not hold.
No code change was needed. The existing [EcoNodeFill <regionKey> command places a 5×5 grid of already-full EcoGrassNodes, spaced 10 tiles, centered on the caster. Run near the actual herd location across a couple of passes, it took Yew from 25 to 115 grass nodes — against 28—40 for every other region. Confirmed across two separate checks roughly twenty minutes apart: Yew holding [HEALTHY], grass fill 61—63%, sheep 13—15, rabbits 18, wolves 1—3. It did not crash back down the second time.
EcoRabbit quietly competes with EcoSheep for the same finite grass resource in any region carrying both, despite being designed and intended as pure atmosphere. Rabbits breed and could in principle spread to another region, and the same collapse could recur there. Two levers exist, both using tools already in the codebase, per the standing no-new-systems constraint: [EcoNodeFill to add capacity (what worked here), or — not built, flagged only — a small code change adding rabbit culling to [EcoPurge, or lowering MaxRabbitsPerRegion, if node-flooding ever stops being enough on its own.
The same status check that confirmed Yew’s recovery also showed Cove newly [Stressed]: grass 24%, 27 sheep, 0 rabbits, 40 nodes, 5 wolves. This is the plain sheep-outrunning-regrowth pattern, with no rabbit complication — Cove has none. Likely the same [EcoNodeFill cove treatment, possibly [EcoPurge cove if sheep are meaningfully over the per-region cap. Not investigated in depth this week; picked up next session (see AD.16).
AD.4Lag, Reopened — What the Overnight Review Actually Showed
Richard asked for a focused pass on lag and goals after an overnight PROD review flagged both. With AD.1’s correction in hand — the sleep-policy fix already live and confirmed — the hitch source had to be something else; the goals thread is picked up separately in AD.12. Richard ran [ShardHealth profile on, waited 30 minutes, then [ShardHealth profile report and [ShardHealth profile off on PROD. Top CPU consumers by total time over the window:
| Timer | Calls | Avg | Peak | Total |
|---|---|---|---|---|
BaseAI+AITimer | 1,130,633 | 0.0ms | 168.1ms | 37.30s |
Mobile+CombatTimer | 454,508 | 0.0ms | 165.8ms | 13.39s |
StaffWebStatus | 25 | 302.0ms | 505.9ms | 7.55s |
UnifiedStatusPage | 25 | 138.4ms | 280.3ms | 3.46s |
SimulatedPlayerDirector.OnTimerTick | 779 | 3.8ms | 75.2ms | 2.94s |
UnifiedWebStatusJson | 25 | 79.8ms | 179.4ms | 2.00s |
AITimer and CombatTimer are cheap per call despite huge call counts — not the hitch cause. The three bolded status/web-export timers each do synchronous work directly on the single-threaded main game loop, confirmed by reading the actual code — no Thread, async, or Task.Run anywhere in any of the three. StaffWebStatus (Web Facing/StaffWebStatus.cs) runs roughly 70 foreach loops over nearly every live collection on the shard plus File.ReadAllLines on console/error logs, then writes status.html. UnifiedStatusPage (Web Facing/UnifiedWebStatus.cs) loops every house on the shard, includes an O(n²) insertion sort on the decaying-house list, and re-reads youjustmissed.html/deathlog.html. UnifiedWebStatusJson re-scans EcologySystem.AllWolves and EcoGrassNode.AllNodes in full per region, per call.
All three were coded as flat 60-second timers but were actually landing every ~72 seconds in the profile (25 calls in a 30-minute window) — slipping under their own load, which is corroborating evidence, not just correlation. UnifiedStatusPage and UnifiedWebStatusJson also shared an identical 60.0-second initial delay, meaning they landed in the exact same tick every cycle, stacking two heavy synchronous timers together. This is a strong, verified match for the shape of the overnight symptom: 293 hitches in the 250—900ms range, once the always-awake-mobile problem is ruled out.
AD.5Three Timers, Slowed With Room to Spare
Richard chose the low-risk option — slow the interval — over the deeper O(n²)/scope-trim rewrite (taken up separately in AD.7). A first pass slowed all three to 300 seconds. Richard then asked to go further — “we really dont leave these live or even close to it” — so the final intervals were spread further apart, scaled roughly to each timer’s cost and reach:
| File | Original | First pass | Final |
|---|---|---|---|
Web Facing/StaffWebStatus.cs | base(45.0, 60.0) | base(45.0, 300.0) | unchanged — Richard’s “Staff 5 mins” already matched |
Web Facing/UnifiedWebStatus.cs (UnifiedStatusPage) | base(60.0, 60.0) | base(145.0, 300.0) | base(700.0, 900.0) — 15 min |
Web Facing/idoc-companion/UnifiedWebStatusJson.cs | base(60.0, 60.0) | base(245.0, 300.0) | base(200.0, 600.0) — 10 min |
(UnifiedMapStatusPage, the fourth timer in UnifiedWebStatus.cs, was left untouched throughout — it already runs every 200s and never showed up in the profile top 10.)
Each timer’s initial delay mod 300s is now distinct — StaffWebStatus 45, UnifiedWebStatusJson 200, UnifiedStatusPage 100 — and every interval (300s, 600s, 900s) is a whole multiple of 300s. Since each timer’s fire-time residue mod 300s never changes across ticks when the period is a multiple of 300, and the three residues are pairwise distinct, no two of these timers can ever land in the same tick — not just “usually don’t,” but structurally can’t, at any point in the server’s uptime. This cuts StaffWebStatus’s frequency 5x, UnifiedWebStatusJson’s 10x, and UnifiedStatusPage’s 15x versus the original. Each edit is a one-line constructor change with a comment explaining why and how to revert. Richard tested on DEV, then promoted to PROD and restarted the same day.
Confirmed live on PROD roughly an hour after the restart:
| Timer | Calls | Avg | Peak | Total |
|---|---|---|---|---|
BaseAI+AITimer | 2,369,558 | 0.0ms | 916.5ms | 95.45s |
Mobile+CombatTimer | 984,766 | 0.0ms | 177.8ms | 30.83s |
SimulatedPlayerDirector.OnTimerTick | 1,634 | 8.9ms | 718.3ms | 14.55s |
StaffWebStatus | 11 | 350.4ms | 1,149.6ms | 3.85s |
UnifiedMapStatusPage | 16 | 51.0ms | 96.8ms | 0.82s |
UnifiedStatusPage | 4 | 159.7ms | 347.0ms | 0.64s |
Call counts confirm the fix is live and correctly spaced: 11 StaffWebStatus calls and 4 UnifiedStatusPage calls in roughly an hour match their new 300s/900s intervals, not the old 60s-for-everyone pattern. UnifiedWebStatusJson no longer places in the top 10 at all — cheap enough now to fall off the list entirely. Cumulative CPU cost dropped substantially — StaffWebStatus’s total time, normalized to a comparable window, is down roughly 4x.
StaffWebStatus’s peak single-call cost got worse, not better — 1,149.6ms now versus 505.9ms in the original profile (average also crept up slightly, 350ms vs 302ms). The interval change only reduced how often it fires, not what it does per call, so this likely reflects the shard’s live data (NPCs, houses, log file size) growing since the first measurement, independent of this fix. Net effect: hitches from this timer are rarer now, but the individual hitch can be bigger when it lands, over a full second. This is the concrete case for the deeper fix taken up in AD.7, rather than relying on interval-spacing alone.
Also observed, unrelated to this fix and not chased down at the time: SimulatedPlayerDirector.OnTimerTick peaked at 718.3ms in this window versus 75.2ms in the original profile — a new, much larger spike, picked up in AD.6.
AD.6The SimPCDirector Spike — Uncached Reflection and Rebuilt Geometry
After promoting the timer-interval fix, a fresh [ShardHealth profile report on PROD showed SimulatedPlayerDirector.OnTimerTick spiking to 718.3ms peak — a timer that had not been part of the original three-timer investigation. Richard asked for this plus the deferred StaffWebStatus rewrite (AD.7) together.
SimulatedPlayerDirector itself ticks every 2.0 seconds and processes up to 20 SimPCs per tick — that part was already cheap. The spike traced to SimPCHouseDecorationService, which the director calls into: a 3.5%-chance-per-sim-per-tick roll to decorate a SimPC’s house after creation. Two problems compounded on the rare, but real, tick where that roll hits a house with many decoration slots. TryCreateItemByName did a type-name lookup, including a reflection-based fallback search, for every single decoration item placed, with no caching — including for several ServUO-only item names that do not exist in RunUO 2.2, meaning the expensive reflection fallback ran and failed, every time, for those names. TryFindInteriorDecorationSpot rebuilt the entire interior-tile-map and spot geometry for the house from scratch on every call, and it was being called once per decoration item, not once per house — for a large TowerOwner-style house with roughly 140 decoration items, that is up to 140x redundant geometry rebuilds in one 2-second tick.
Fix, in SimPC/SimPCHouseDecorationService.cs: added an m_TypeNameCache (Dictionary<string,Type>) and a ResolveItemType helper, so a name is resolved — or confirmed to fail — once, ever, not on every placement; failures are cached too, so the ServUO-only names stop paying the reflection-search cost on every hit. TryFindInteriorDecorationSpot was split into a thin public wrapper plus a private overload that takes a pre-built List<DecorationSpot> spots; DecorateAfterHouseCreated now builds that list once per house-decoration pass, before the item loop, instead of once per item. No behavior change — same items, same spots, same randomness, only redundant work removed.
AD.7StaffWebStatus and UnifiedStatusPage — the Deferred Rewrite, Done Carefully
A research pass catalogued every hot path in both files. No string-concatenation anti-pattern was found — both already use StringBuilder/TextWriter correctly. What it found instead, and fixed with no behavior change: StaffWebStatus.cs gained a ReadAllLinesCached helper (caches file reads keyed on File.GetLastWriteTimeUtc, so a log file that has not changed since the last tick is not re-read from disk), used by TailProblemLines/TailLog, and a 3-pass scan over EcoOrc.AllOrcs (regionKeys, orc list, and raid tally were three separate loops) was fused into one. UnifiedWebStatus.cs (UnifiedStatusPage) got the same ReadAllLinesCached pattern for ReadRecentLogouts/ReadRecentDeaths (previously re-read youjustmissed.html/deathlog.html from disk on every single tick regardless of whether either file had changed), fused its own 2-pass EcoOrc.AllOrcs scan in the Raider Sightings section into one, replaced the manual O(n²) insertion sort on the decaying-house list with List<T>.Sort(Comparison<T>) — same ordering, O(n log n) instead of O(n²) — and removed a dead line that called Server.Ecology.EcologySystem.GetStatusReport() and discarded the result, since that string was never used. Both files committed to DEV.
Both files have a bigger duplication: an overview table computes per-region counts, and a per-region detail section then re-scans the same underlying collections again, once per region, to build the drill-down. That is roughly a 2x cost on the most expensive report sections, and in UnifiedStatusPage’s Ecology table specifically, the per-region loop re-scans all four collections for every region rather than grouping once — with N regions, that is N passes over each collection instead of 1. Fixing this properly means restructuring the data flow (build one region-to-counts dictionary up front, feed both the overview and the detail sections from it) rather than a one-line swap, carrying more risk of a subtle output change. Left alone pending Richard’s go-ahead. Also flagged, not a code fix: youjustmissed.html grows unboundedly — JustMissed.EventSink_Logout only ever appends, with nothing that rotates or trims it. A data-lifecycle question for Richard, not touched.
DEV validation, confirmed 2026-09-01, versus the pre-fix PROD baseline:
| Timer | Before (PROD) | After (DEV) | Change |
|---|---|---|---|
SimulatedPlayerDirector.OnTimerTick peak | 718.3ms | 73.7ms (561 calls, avg 2.8ms) | ~90% down |
StaffWebStatus peak | 1,149.6ms | 30.0ms (4 calls, avg 29.3ms) | ~97% down |
UnifiedStatusPage peak | 347.0ms | 2.79ms (1 call) | ~99% down |
Both status pages were also visually verified against the live export — Orc Raid Activity (the fused-loop section) and Houses In Decay (the sorted-list section) both render correct real data with no breakage. Aside, unrelated to this work: DEV’s Staff Status error-log panel showed its own AI NPC key hitting a (400) Bad Request — credit balance... error — a billing issue, distinct from PROD’s earlier 401-invalid-key problem (AD.11). Not blocking, just needs a top-up whenever convenient if AI NPC chat on DEV matters. All three files, plus AD.8’s goal-eval fix, were promoted to PROD the same day — confirmed in AD.10.
AD.8The Real Bug — a One-Minute Cooldown Where the Comment Said Six Hours
Richard sent a fresh PROD console log and a [ShardHealth timers census (session started at the restart of 8/31/2026 6:14:35 PM, still running roughly 22 hours later). The log showed a cluster of severe lag hitches concentrated in the first ~70 minutes after that restart, far worse than anything in the earlier three-timer investigation:
| Time | Overshoot |
|---|---|
| 00:38:06 | 85,750ms (86.75s actual) |
| 01:08:30 | 51,516ms |
| 00:47:21 | 46,734ms |
| 01:09:26 | 35,188ms |
| (and dozens more) | 1—20s each, scattered through the same window |
After roughly 70 minutes these settled into much milder, roughly-hourly 1—4 second hitches for the rest of the day — a separate, lower-priority pattern taken up in AD.10. Cross-referencing timestamps, the severe-hitch window is saturated with [Phase3A] X: Generated goal “...” lines — the same NPCs (Conrad, Meka, Bialy, Brad, India, Renaldo, Anita, Hector, Chane, and many others) regenerating their entire goal set repeatedly, exactly 60 seconds apart, verbatim identical goals each time (Conrad generates the same 5 goals at 00:22:15 and again at 00:23:15).
UltimateNPC.TryEvaluateGoals(), called from exactly one place — the Sleeping-state’s dawn-wakeup block — is documented as “once per 6-hour in-game cycle,” but the actual guard read:
TimeSpan timeSinceLastEvaluation = DateTime.UtcNow - m_LastGoalEvaluationTime;
if ( timeSinceLastEvaluation.TotalMinutes < 1.0 )
return; // evaluated too recentlyTotalMinutes < 1.0 is a real-world one-minute cooldown, not six hours — a straightforward unit mismatch against the method’s own doc comment. The call site only runs when m_State == Sleeping && !night, and per an adjacent comment, an NPC that cannot claim a work site “stays Sleeping and retries the claim each tick,” meaning this whole block, including TryEvaluateGoals(), runs every single tick, all day, for any NPC that has not successfully claimed a site. With only a one-minute gate instead of six hours, such an NPC silently regenerates its full goal set every minute, all day, until it either claims a site or the server restarts.
NPCGoalsEngine_Phase3A.cs’s GetGoalsForNPC() was read to check whether a single call could itself explain multi-second stalls: it is pure in-memory scoring over roughly 16 candidate goals, no I/O, no world scans — cheap in isolation. So this is a confirmed, real bug regardless, but the causal mechanism to multi-second freezes was not fully proven at the time the fix was written — see AD.9 for what the post-fix data actually showed.
Fix, in UltimateNPC.cs, TryEvaluateGoals(): changed timeSinceLastEvaluation.TotalMinutes < 1.0 to timeSinceLastEvaluation.TotalHours < 6.0, matching the method’s own documented intent. One-line change, no other behavior touched.
AD.9The Fix Held, But It Wasn’t the Whole Story
DEV validation, 2026-09-01: restarted DEV, staged and grepped the full Logs/console_2026-09-01.log directly (both restarts that ran that day). Result: exactly two goal-generation bursts total (933 matching lines split cleanly across the two restarts), one per restart, zero evidence of any NPC regenerating goals a second time. The fix works as intended, confirmed against the actual persisted log rather than a short pasted excerpt.
The same DEV log still showed two severe hitches this session — 8.6s at 16:47:17 and 68.3s at 17:41:27 — neither lining up with goal generation, a web export, or any other visible console activity nearby. The 68-second one happened roughly 57 minutes after restart, with ordinary periodic ticks right up until the freeze, then a dead gap, then a client that had disconnected during the freeze reconnecting the instant it ended. This means the goal-eval bug was not the sole, and maybe not even the primary, cause of the severe hitches — a separate, still-unexplained freeze source predates this investigation. The goal-eval fix is still correct and worth keeping — it stops real, confirmed wasted work — it just is not the whole lag story. See AD.10 for what the PROD data then showed.
Promoted to PROD the same day, together with AD.6/AD.7’s SimPCDirector and status-page work; a full PROD restart was still needed to take effect.
AD.10PROD Promotion — the Severe Hitches Turn Out to Be the World Save
Richard promoted everything — the goal-eval fix and the SimPCDirector/status-page perf work — to PROD and restarted at 5:46:37 PM, 2026-09-01. Worth remembering from the DEV investigation: Logs/console_*.log is not chronologically ordered on disk — ShardHealthMonitor’s severe-hitch lines are written well ahead of the buffered ConsoleTeeLogger output for the same period, so timestamps, not file position, are what matter when reading these logs.
PROD verification covered the full day’s log (7,887 lines, spanning six restarts: 11:51 AM, 12:58 PM, 2:12 PM, 2:48 PM, and finally 5:46:37 PM with the promoted fixes) plus a [ShardHealth profile report taken at 21:32:48, roughly 3h46m into the current session, with just the current session isolated for analysis. Goal-eval fix: confirmed holding. Every NPC name in the current session’s “generated N goals” lines appears exactly once, except “Chane,” which appears twice — but those are two different individual NPCs that happen to share a name (serials 0x000004F3 and 0x00000093, the same pair seen distinct from each other on DEV too). No actual repeat evaluation.
Severe hitches: root cause found, and it is not the 68-second mystery. All of PROD’s severe-hitch warnings this session:
| Time | Overshoot | |
|---|---|---|
| 17:47:50 | 1,062ms | boot-time blip, trivial |
| 18:47:01 | 3,715ms | ↓ |
| 18:47:56 | 1,844ms | |
| 19:47:16 | 1,953ms | ↓ |
| 19:48:01 | 1,312ms | |
| 20:47:44 | 2,212ms | ↓ |
| 20:48:01 | 1,500ms |
The marked rows line up exactly with world-save completion timestamps also in the log: “Save done in 4.30 seconds” at 18:47:01, “2.71 seconds” at 19:47:16, “2.41 seconds” at 20:47:44. The save itself blocks the main thread for its full duration, and the watchdog is correctly flagging that as a severe hitch — a known, ordinary RunUO pattern (blocking single-threaded save), not a mystery, and nowhere near DEV’s 68-second event (worst here is 3.7s). The second, smaller hitch in each pair, roughly 45—60 seconds after the save, is still unexplained — possibly a backup or archive step (the DEV machine’s root folder has .rar backup files suggesting an archival step exists somewhere) — but it is a minor, well-bounded add-on to an already-understood cause, not a new mystery.
The current session ran roughly 3h46m — well past the ~57-minute point where DEV’s 68-second freeze occurred — with nothing bigger than 3.7s. Profile data at the same checkpoint confirms the reading: Server.Mobiles.BaseAI+AITimer peak across 8.2M calls is only 242ms, nothing like DEV’s 9.4s peak from its equivalent-stage profile; Server.Misc.AutoSave shows 3 calls, avg 3.19s, peak 4.39s, total 9.58s — matching the save-blocking theory almost exactly. This is genuinely good news: it suggests DEV’s 68-second event may be specific to that machine or environment (disk contention, AV, VM-host noise) rather than a universal problem that will also show up on PROD, though it has not run long enough yet to call that settled.
One thing to watch, not urgent: StaffWebStatus on this PROD run shows avg 303ms / peak 510ms over 37 calls — better than the original PROD baseline (1,149.6ms peak) but noticeably worse than DEV’s validation numbers (30—248ms peak across different DEV runs). PROD naturally carries more houses and content than DEV, so some gap is expected; flagging only that it is not quite as dramatic an improvement in practice as the DEV numbers suggested.
AD.11AI NPC 401 Unauthorized on PROD — a Stale Key
PROD’s console was spamming, roughly every 15 minutes (on the auto-export cadence discussed in AD.13): AI NPC: API call failed - The remote server returned an error: (401) Unauthorized. - {"type":"error","error":{"type":"authentication_error","message":"API key is invalid."}}. Live AI NPC chat was silently falling back to static dialogue.
AI and House June 2026/AIConfig.cs reads Data/AIConfig.cfg exactly once per process, cached in a static field (EnsureLoaded() guarded by m_Initialized). PROD’s ApiKey= value no longer matched either of the two currently-active console keys (idoc3, Default-workspace-scoped; IDOC2, all-workspaces) — it had been rotated or revoked at some point and the config file was never updated. There is no live-reload command for this file ([AIReloadKnowledge only reloads Data/ServerGuide.txt, unrelated). Worth remembering: the Anthropic Console only shows a key’s full value once, at creation — afterward the only actions are Disable/Delete, no reveal; if the old key’s value is not saved elsewhere, the only fix is generating a brand new key.
Fix: Richard generated a fresh key, dropped it into PROD’s Data/AIConfig.cfg as ApiKey=, and restarted PROD.
No WorkspaceId= was needed here — that header is only required for a multi-workspace key (like IDOC2); a key scoped to a single workspace (idoc3, Default-scoped) does not need it, since there is nothing to disambiguate. Flagged plainly because the first read of this was backwards: single-workspace-scoped key means no WorkspaceId needed; multi-workspace key means it is required.
One diagnostic wrinkle worth remembering: AIConfig.EnsureLoaded() only fires — and only then prints AI NPC: Config loaded... — the first time an NPC actually attempts an AI call, not at server boot. Right after a restart with nobody online, the console shows neither a success line nor an error; that is expected, not a sign of failure. Confirming the fix requires an actual player interaction with an AI-enabled NPC (GuideNPC, InnkeeperNPC, HermitNPC, GuardCaptainNPC, WolfCullerNPC, HatchlingHunterNPC, Osric, or a custom SmartNPC).
Post-restart, talking to Aldric (a SmartNPC-derived guide) produced live, on-theme generated replies, correctly explaining “IDOC” in character, with AI NPC: Config loaded. Enabled=True, Model=claude-haiku-4-5 and no follow-on 401s in console.
Housekeeping still open: the key value passed through this chat during troubleshooting. Not urgent since it is now live and working, but worth rotating to a freshly-generated key at some convenient point, copied directly server-side rather than through chat, and deleting the old dead idoc3/IDOC2 entries in the console if confirmed unused or unrecoverable.
AD.12Four Goals Wired — Appendix AC.11 Revisited
Appendix AC.11 found that only 4 of the (then) 24 NPCGoalType values had any progress-advancement code at all, leaving 192 of the 217 active goals with no way to ever progress, and closed with an explicit standing constraint: no new goal types until the existing ones can advance. This week’s follow-up does not close that gap, but it narrows it, and the exact size of the narrowing is worth being precise about rather than rounding up.
A fresh count this week, taken before any fix, found the same structural gap on a larger population: 226 active goals, still only the four economic types (SaveGold, BuyHouse, StartBusiness, BecomeWealthy) with any advancement path, and roughly 209 of the 226 sitting at Progress = 0 forever across 20 goal types with zero code path to ever advance, regardless of runtime: WinHeart, FindLove, Marry, ForgetLove, BuildReputation, GainRespect, LeadFaction, BecomeElder, PerfectCraft, BreedRareVariant, WriteStory, DiscoverSecret, ProtectFamily, SurviveRaid, PrepareForWar, EscapeDanger, FindMeaning, UnderstandWorld, AchieveEnlightenment, SeekDeletion.
Richard chose to proceed with the two confirmed-feasible pairs and hold reputation-based goals for later, since those need a real design decision first.
The plan going in had assumed BuildReputation/GainRespect could use the existing SimReputationScore/PlayerReputationScore signal. It could not: RecordGoodDeed/RecordPredatoryAct exist on UltimateNPC.cs with correct logic (range -1000 to +1000, clamped, serialized) but are never called from anywhere in the codebase. Every NPC’s reputation score sits at 0 forever — there is a write path and no signal to read. Wiring these two needs a prior product decision (what counts as a good deed or a predatory act for an UltimateNPC, and where to trigger it) — bigger than wiring together what exists, so deliberately not attempted this pass.
What was actually wired: WinHeart/FindLove, via the existing RelationshipService.ApplySharedDrink (called every 90 seconds by the already-running UltimateNPCPubSocialHooks timer when NPC pairs share a drink at the pub). That method already computed, per direction, whether an NPC’s relationship with another had just crossed into “Romantic” — previously that only fed flavor text and chronicle entries. Now, when a bond crosses into Romantic, OnRomanticBondFormed() (a new method on UltimateNPC.cs) completes any active WinHeart/FindLove goal outright (Progress = 100, IsCompleted = true). Completing outright rather than granting partial progress was deliberate: reaching Romantic already requires NPCRelationship.Value >= 60, which at OnSharedDrink’s deliberately slow +2 per evening is roughly 30 shared pub nights — a rare, meaningful, already-gated event.
Goals as actually generated (NPCGoalsEngine_Phase3A.GetGoalsForNPC, via new NPCGoal(candidate.Type, candidate.Urgency)) never set TargetSerial or TargetName, so there is no way to check whether an NPC specifically won a particular target’s heart; any Romantic crossing with anyone completes the goal. That gap predates this pass and cannot be fixed without touching goal generation itself.
ProtectFamily/PrepareForWar, via the existing UltimateNPCMoodDriftHooks timer (runs every 10 minutes, already loops every UltimateNPC and scans each one’s new memories since its last pass). An independent check, IsRaidWitnessMemory(), was added alongside the existing ClassifySentiment mood check: when a new memory’s text contains “struck again” — confirmed by reading EcoOrc.cs, where both RaidPool() and RaidFields() memory templates always end their opening clause that way, whether generic (“Orc raiders struck again”) or a notorious UltimateOrc’s (“{Name} has struck again”) — the NPC’s OnRaidWitnessed() bumps any active ProtectFamily/PrepareForWar goal by Progress += 12, completing after roughly 9 witnessed raids.
Scope note, already given to Richard: this is “this NPC became aware of a raid” (an already-existing random 25—75% witness roll baked into UltimateNPCMemoryStream.AddMemoryToRegion), not “this NPC’s own goods were personally stolen” — raids hit a shared regional pool and grass nodes, never an individual NPC’s property, so no stronger signal exists anywhere in the raid code to wire to instead. Deliberately not reused: the existing ClassifySentiment keyword match (“orc raiders”) for mood-drift, which would have silently missed every raid credited to a named UltimateOrc. The new IsRaidWitnessMemory check is independent and separately verified against both raid-memory templates.
Files changed on DEV: UltimateNPC/UltimateNPC.cs (added OnRomanticBondFormed() and OnRaidWitnessed()), UltimateNPC/RelationshipService.cs (ApplySharedDrink now calls OnRomanticBondFormed() on a Romantic crossing), and UltimateNPC/UltimateNPCMoodDrift.cs (ApplyDriftFor’s per-memory loop now also calls OnRaidWitnessed() when IsRaidWitnessMemory() matches). No new timer, no new serialized field, no changes to existing goal generation or to ClassifySentiment’s mood behavior — purely additive. As of this writing, this had not yet been promoted to PROD; see AD.16.
What this narrows, precisely: of the roughly 209 goals with zero path per the fresh count above, the four newly-wired types account for roughly 100 of the 226 currently active goals (WinHeart 37, FindLove 20, ProtectFamily 23, PrepareForWar 20, by the same census). By straightforward subtraction — a figure neither source note states outright, given here for clarity rather than as a directly-measured total — that leaves roughly 109 goals, across the remaining 16 goal types, still with no path, including BuildReputation (42) and GainRespect (22), now confirmed to need a design decision before any code can be written at all, not just a wiring pass. And “wired” here means “has a path to progress,” not “will complete promptly” — WinHeart/FindLove still need a rare relationship-threshold crossing (~30 pub nights), and ProtectFamily/PrepareForWar still need roughly 9 witnessed raids.
AD.13The Web Exports Were Never Daily
NotableRaidersWebExport.cs, UltimateNPCListWebExport.cs, UNPCThoughtsWebExport.cs, UltimateNPCRelationshipsWebExport.cs, UltimateNPCGoalsWebExport.cs, and SimBiographyWebExport.cs were all still labeled “Daily auto-export” in their log lines and code comments, despite actually running every 15 minutes (or, for SimBiographyWebExport, every 60 minutes) since the 2026-08-25 pass recorded in AC.14 above — that appendix’s own Files Touched table shows the 15-minute and 60-minute cadences being set on these same files, just without the label that describes them ever being corrected to match. Widened today to every 3 hours across all six files at Richard’s request, since he judged the actual cadence far more frequent than intended.
AD.14Files Touched
| File | Change |
|---|---|
EcoRabbit.cs / EcologySystem.cs | No code change made this pass; diagnosed only (AD.2/AD.3) — the fix used the existing [EcoNodeFill, not a code edit. |
Web Facing/StaffWebStatus.cs | Timer interval unchanged from an earlier “Staff 5 mins” setting (AD.5); ReadAllLinesCached added, 3-pass EcoOrc scan fused to one (AD.7). |
Web Facing/UnifiedWebStatus.cs (UnifiedStatusPage) | Interval widened to base(700.0, 900.0) — 15 min (AD.5); ReadAllLinesCached added, 2-pass EcoOrc scan fused to one, O(n²)→O(n log n) house sort, dead GetStatusReport() call removed (AD.7). |
Web Facing/idoc-companion/UnifiedWebStatusJson.cs | Interval widened to base(200.0, 600.0) — 10 min (AD.5). |
SimPC/SimPCHouseDecorationService.cs | Type-resolution cache (m_TypeNameCache/ResolveItemType); decoration-spot list built once per house instead of once per item (AD.6). |
UltimateNPC/UltimateNPC.cs | TryEvaluateGoals() cooldown fixed from TotalMinutes < 1.0 to TotalHours < 6.0 (AD.8); added OnRomanticBondFormed() and OnRaidWitnessed() (AD.12). |
UltimateNPC/RelationshipService.cs | ApplySharedDrink now calls from.OnRomanticBondFormed() on a Romantic crossing (AD.12). |
UltimateNPC/UltimateNPCMoodDrift.cs | ApplyDriftFor’s per-memory loop now also calls npc.OnRaidWitnessed() when IsRaidWitnessMemory() matches; new private IsRaidWitnessMemory() added near ClassifySentiment (AD.12). |
Data/AIConfig.cfg | ApiKey= value replaced with a freshly-generated key (AD.11). Configuration, not code — still needed a restart, since EnsureLoaded() only reads it once per process. |
Web Facing/NotableRaidersWebExport.cs, UltimateNPCListWebExport.cs, UNPCThoughtsWebExport.cs, UltimateNPCRelationshipsWebExport.cs, UltimateNPCGoalsWebExport.cs, SimBiographyWebExport.cs | Log lines and comments corrected from “Daily auto-export”; interval widened to 3 hours across all six (AD.13). |
AD.15GM Command Reference — Used, Not Added, This Pass
No new GM commands shipped this week — unlike most appendices in Part II, this pass was diagnosis and targeted fixes rather than new tooling. The commands below are existing ones that did the actual verification work, recorded here because a future session will need the same ones.
| Command | Purpose |
|---|---|
[EcoGrassFill <region> <percent> | Forces a region’s grass fill above a threshold. First, insufficient attempt at the Yew fix — AD.2. |
[EcoNodeFill <region> | Places a 5×5 grid of already-full EcoGrassNodes, spaced 10 tiles, centered on the caster. The actual fix for Yew — AD.3. |
[EcoStatus / dashboard | Per-region grass/sheep/wolf/rabbit counts and health state. Used throughout AD.2/AD.3 to confirm the fix held. |
[ShardHealth timers | Census of always-awake versus sleeping mobile AI timers. Confirmed the sleep-policy fix already live on PROD — AD.1/AD.4. |
[ShardHealth profile on / report / off | Per-timer CPU time over a window. The single most-used diagnostic this week — drove AD.4, AD.5, AD.6, and AD.10. |
[UNPCPubSocialCheck | Forces an evaluation of the pub social timer; used to test the WinHeart/FindLove wiring — AD.12. |
[MoodDriftCheck | Forces an evaluation of the mood-drift timer; used to test the ProtectFamily/PrepareForWar wiring — AD.12. |
[UNPCInfo / [props ActiveGoalsSummary | Per-NPC goal state, used to confirm a completed goal after either wiring change — AD.12. |
AD.16Status
Confirmed fixed and promoted to PROD, restart included: the sleep-policy/AI-timer fix (already live, reconfirmed rather than newly shipped — AD.1); the three status/web-export timer intervals (AD.5); the SimPCHouseDecorationService type-cache and once-per-house geometry build (AD.6/AD.7); the StaffWebStatus/UnifiedStatusPage cached reads, fused scans, and O(n log n) sort (AD.7/AD.10, though PROD’s improvement is real but less dramatic than DEV’s numbers); the TryEvaluateGoals() one-minute-cooldown fix (AD.8/AD.9/AD.10); and the AI NPC 401 fix on PROD (AD.11). PROD’s hourly 1—4 second hitches are fully explained as the blocking World Save and are not being treated as a problem to chase (AD.10). The web-export cadence has been widened to 3 hours across all six files, with labels corrected (AD.13).
Wired on DEV, not yet confirmed promoted to PROD: the WinHeart/FindLove and ProtectFamily/PrepareForWar goal-advancement paths (AD.12). Richard still needs to confirm DEV compiles clean and test both paths with [UNPCPubSocialCheck and [MoodDriftCheck before promoting; the next Goals of Sosaria export after that promotion is what will actually show completion rate moving off 0% for the first time.
| Still open | Detail |
|---|---|
| DEV’s 68-second freeze | Has not recurred on PROD after roughly 3h46m of runtime as of this writing (AD.9/AD.10); filed as lower priority unless it reappears there. |
| Post-world-save follow-on hitch | A smaller hitch roughly 45–60 seconds after each save, possibly a backup or archive step. Not investigated (AD.10). |
| Roughly 109 of 226 active goals | Across 16 goal types, including BuildReputation and GainRespect, which now need a product decision rather than a code fix, before any advancement path can be built (AD.12). |
| Cove ecology stress | Grass 24%, 0 rabbits, 27 sheep, 5 wolves — flagged during the Yew recovery check, not yet investigated (AD.3). |
| StaffWebStatus peak cost under PROD’s real volume | Improved, but not as dramatically as DEV’s numbers suggested; worth a re-check (AD.7/AD.10). |
| Overview/detail duplication in both status pages | A roughly 2x cost from re-scanning the same collections for the overview and the per-region drill-down. Needs a data-flow restructure, deferred pending Richard’s go-ahead (AD.7). |
| youjustmissed.html unbounded growth | Only ever appended to, never rotated or trimmed. A data-lifecycle question for Richard, not a code fix (AD.7). |
SimulatedPlayerDirector.OnTimerTick’s spike was traced and fixed this week (AD.6); worth one more profile pass once the fix has had more time on PROD, simply to confirm it stays down. This mirrors AC.17’s own format — a revisit table would repeat most of the above one line at a time, so it is given here as prose and a still-open table instead; the state of every item above is exactly what the next appendix’s own opening subsection should check against.
Cleaner Exports, the Bombers’ Notebook, and a Reboot Nobody Rang the Bell For (2026-09-03)
A three-part day: finishing a web-export cleanup Richard asked for in plain terms — “make the multiple outputs to web cleaner” — then, once that was live, two new player-facing pieces pulled straight from Appendix AD’s own precedent list (AD never existed under that name in this doc, but the ideas-pass document behind it did); and finally an unrelated PROD incident that arrived mid-session by upload, in the middle of writing this appendix, and got worked the same way everything else here does: read the actual evidence before asserting a cause. Richard confirmed at the end of the day that all of it — web exports, restyle, both new features — is now promoted to PROD.
AE.1Web Exports, One Hub Instead of Five
Richard’s request had four parts, all kept: fewer separate pages/URLs, fix the expensive export, one consistent look, and less redundant scanning. Two new files did the structural work. UltimateNPCWebExportShared.cs pulled WebServerPath, Esc() and RegionLabelFor() out of seven files that each carried a byte-identical copy. UltimateNPCWebExportHub.cs gives UltimateNPCListWebExport, UltimateNPCRelationshipsWebExport, UNPCThoughtsWebExport, UltimateNPCGoalsWebExport and UltimateNPCCommitmentWebExport one shared Timer.DelayCall instead of five, fetching UltimateNPC.AllUltimateNPCs once per tick and handing the same list to all five RunExport() calls.
AllUltimateNPCs is a cheap static list reference, confirmed by reading UltimateNPC.cs directly — not rebuilt per call. So the Hub is not a meaningful CPU saving; the real win is one shared schedule instead of five, and UltimateNPCCommitmentWebExport finally running on the same cadence as its siblings (see AE.1 continued below). Said plainly in the file’s own header comment for the next session, rather than left to be discovered as an overclaim.
UltimateNPCCommitmentWebExport was still exporting once a day at UTC midnight — it had quietly missed the 2026-08-25 cadence fix that widened its five siblings to 3 hours. It now shares the Hub’s cadence too. NotableRaidersWebExport and SimBiographyWebExport were deliberately left OUT of the Hub — different data sources (the orc bounty board; the SimPC population, not UltimateNPC) — and keep their own independent timers.
AE.2The Actual Cost of SimBiographyWebExport
SimBiographyWebExport was the one Richard specifically flagged as expensive. Root cause, found by reading SimBiographyService.cs rather than guessing: FindBooksBySim(string) did a full World.Items.Values scan for EVERY sim, individually, on every export cycle. Fix: BuildBookIndex(string onlySimID) now does that scan ONCE (pass null to index every sim’s books in one pass), returning a Dictionary<string, List<SimBiographyBookEntry>>. The single-sim GM command path (BuildBiography(sim)) still calls BuildBookIndex(sim.SimID) per call, unchanged cost, because it only ever needed one sim’s books anyway. The new BuildBiographies(List<SimulatedPlayerMobile>) — what the web export actually calls — builds the index once and loops a new private BuildBiographyCore() per sim against it.
Not independently re-measured against a live restart as of this writing — that verification is on Richard’s side once DEV is back up. Worth a before/after profile once it’s had a real cycle, the same way AD.7 checked StaffWebStatus against PROD’s real volume rather than trusting DEV’s numbers alone.
AE.3Two Pages Restyled, Two Left Alone
Richard’s choice, from two rounds of clarifying questions before any code was written: standardize the mismatched pages on The_Goals_of_Sosaria.html’s existing dark gothic theme (Cinzel/Crimson Pro fonts, --bg:#0a0c10 and friends) rather than invent a new one. biographies.html and Thoughts.html got the restyle — every element ID and effectively all of the JavaScript left byte-for-byte unchanged, only the CSS repainted (and, in Thoughts.html, the per-job-category badge colors recolored for legibility on a dark background, keeping the same keyword-based classifier). The_Living_World.html and relationships_dashboard.html already matched the target theme and needed no restyle at all.
Thoughts.html also carried a large block of stale sample-NPC-card HTML baked in from an old browser “Save Page As” — harmless (the page’s own fetch()-driven renderGrid() overwrites it on load) but dead weight, removed as a safe cleanup. Separately, unpc_commitment.json had zero pages actually consuming it — confirmed by grepping every page’s JS for its filename before touching anything — so rather than give it a standalone page, Richard chose to fold it into The_Living_World.html as an optional supplementary badge (“Committed to X · Ym left”) on each NPC card, wrapped in its own try/catch so a missing or malformed fetch degrades silently instead of breaking the page.
AE.4The Delivery Blocker — Controlled Folder Access
All three finished HTML files (biographies.html, Thoughts.html, The_Living_World.html) failed to write to C:\Inetpub\wwwroot\ — Windows denied access with a Controlled Folder Access message. This is a folder-level ransomware-protection block, distinct from the per-file read-only flag seen earlier in the same pass on The_Goals_of_Sosaria.html (moot in the end, since nothing was ever written there). Worked around by delivering all three files to Richard directly for manual placement, with the exact Windows Security menu path (Virus & threat protection → Manage ransomware protection → Allow an app through Controlled folder access) given for a permanent fix if he wants direct writes in future.
AE.5The Bombers’ Notebook
Richard asked what else could make the shard feel more alive, inside the standing constraints (no new simulation systems, no new goal types until existing ones can advance). Offered a short menu drawn from the project’s own “ideas pass 2” precedent document; he picked two. The Bombers’ Notebook was that document’s own top recommendation — a Majora’s Mask precedent, “zero new simulation — a read view over existing data.”
New file: UltimateNPC/UltimateNPCBombersNotebook.cs, a BrownBook subclass. It never writes its pages once — OnDoubleClick rebuilds Pages[] from LIVE data every time it’s opened. Confirmed feasible by reading BaseBook.cs directly first: OnDoubleClick just ships whatever is currently in Pages[] over the wire, no cached gump state to fight. So the notebook needs no serialized state of its own beyond being a normal item in a player’s pack, and it self-heals rather than going stale.
Per NPC where UltimateNPCPlayerReputation.HasEverMet(npc, player) is true: job via UltimateNPCPubSocialHooks.JobLabel, region via UltimateNPCWebExportShared.RegionLabelFor, firm friends via UltimateNPCPubSocialHooks.CollectBonds(FriendValue) filtered to that NPC, and the last first-hand (non-reflection, non-“Heard from”) memory via UltimateNPCMemoryStream. Flagged in the file’s own header, per the project’s standing “is this persisted?” question: HasEverMet is in-memory only and resets on every restart, so right after a restart the notebook will look sparse even for a player who’s met everyone — accurate reporting of reset state, not a bug, and it fills back in as the player re-encounters NPCs. Acquisition is GM command only for now ([BombersNotebook, target a player) — deliberately not wired into new-character starting gear this pass, since that touches character-creation code not read this session.
AE.6The Sosaria Almanac
The second pick: the “ideas pass 2” document’s own “dream item” — a book, sold by a vendor, reprinted periodically, containing the chronicle, standing friendships, prices and notable dead. Not borrowed from a precedent; called out in that document as mostly wiring, since UltimateNPCChronicle.Build() already writes this exact report once a day for Discord, and SimPCProceduralBook.cs already generates in-world books.
The wiring turned out less trivial than “mostly wiring” suggested. UNPCChronicleLog is a 24-hour scratchpad, fully drained every day for the Discord post — it holds no rolling week of material, so a naive “call Build() once a week” would only ever show the most recent day, stale for the other six. New file SimPC/SosariaAlmanacService.cs instead hooks UltimateNPCChronicle.PostDaily() with one extra, independently try/catch’d call — after the real Discord post has already succeeded — to bank a plain-text copy of that day’s already-built dispatch into a 7-entry rolling in-memory buffer. Once a week, PrintWeeklyEdition() compiles whatever it has into one SimPCProceduralBook, credited to and stocked on whichever SimPC currently holds the “Chronicler / Librarian / Storyteller” job, riding the exact vendor-restock path (SimPCChronicleBookService.StockBooksOnVendor) their own handwritten flavor books already use. If no SimPC is currently on that job when the timer fires, that week is skipped and logged — an explicit trade-off Richard chose over building a dedicated fallback vendor.
Richard chose weekly cadence knowing the content-window caveat above (a naive weekly call would go stale for six days) — which is exactly why the rolling-buffer design exists instead of the simpler approach. Per-day text capped at 900 characters before compilation so seven days plus headers stays inside SimPCProceduralBook’s fixed 40-page (~9,600 character) budget; content flows through that class’s existing word-wrap (no preserved paragraph breaks), same as every other SimPC-authored book. The rolling buffer is in-memory only — a mid-week restart means that week’s Almanac only covers the days since the restart, not a true seven, the same trade-off UNPCChronicleLog itself already makes.
AE.7A Client Rendering Bug, Caught From a Screenshot
Richard’s first live test of the Notebook, on a GM character named Henry, produced “NOTEBOOKKept by Henry” and “lookthem over” — words glued together with no space at all, visible directly in his screenshot. Diagnosed from the screenshot alone, without live client access: the client (TazUO, per an earlier upload this same session) re-wraps a server-supplied line when it overflows the row’s actual pixel width, which turned out to be narrower than assumed — a 21-character ALL-CAPS line (“THE BOMBERS’ NOTEBOOK”) already overflowed a 30-character budget. Worse: wherever that happens, the client glues the overflow straight onto whatever the NEXT array line was, with zero separator, not even a space — confirmed by working backward from exactly where each glue point landed against the source strings.
Fix, same file: MaxLineLength dropped from 30 to 20 for a real safety margin, and every physical line produced by WrapEntriesIntoPages now carries a trailing space, so even a client that joins adjacent lines with no separator of its own still gets a gap. Not yet re-tested live as of this writing — the fix is committed to DEV, next restart is the real check.
AE.8PROD Down — Not a Crash
Unrelated to everything above: Richard came home mid-session to find PROD down, no auto-restart configured on the service, worried it was memory-related, and uploaded PROD’s System event log (37,052 records, full machine history back to 2018) for diagnosis. Read with the evtx Python library rather than guessed at.
| Time (UTC) | Event |
|---|---|
| 01:41:16 | SCM: IDOC-PROD service Start Type changed demand→auto (this pass’s own NSSM-service context) |
| 01:41:45 | IDOC-PROD reports running |
| 04:27:17 | User32 1074: wmiprvse.exe, account WIN-HJ7HS4240QQ\Administrator, initiates a restart, flagged Planned |
| 04:27:23–04:30:22 | Clean OS shutdown and reboot (~3 minutes) — EventLog 6006, Kernel-Power 109, Kernel-General 13→12, normal Kernel-Boot chain |
| 04:30:23–04:40 | ~115 other services auto-start normally. IDOC-PROD is not among them — no start attempt, no failure, nothing logged for it at all. |
| 04:27:23 | IDOC-PROD reports stopped |
| 08:09:33 | IDOC-PROD reports running — Richard’s manual restart on getting home |
Downtime: 3h42m, entirely because the service didn’t come back after a routine reboot — not because it crashed while running. Directly answering Richard’s memory worry: zero Microsoft-Windows-Resource-Exhaustion-Detector events anywhere in the log’s entire history, not just today. Zero Kernel-Power EventID 41 (unexpected/dirty shutdown) anywhere near this incident — the shutdown sequence itself is textbook clean. Unrelated to the ServerGC/memory work in earlier appendices, and unrelated to the still-open recurring hitch-storm thread — a different failure shape entirely (a host reboot, not an in-process lag event); does not tell us whether the hitch storm has recurred since the NSSM conversion.
AE.9What Actually Triggered It
Richard’s natural follow-up — why did it restart at all — led to pulling every User32 1074 (restart/shutdown-initiated) event across the log’s full history, not just today. Found a recurring pattern going back to 2026-06-12: RuntimeBroker.exe (and, this time, wmiprvse.exe) initiating restarts under the Administrator/SYSTEM account roughly every one to four weeks, with no visible local precursor — no logon, no script, nothing — in the minutes before each one. That shape (an internal Windows broker process, not a person, not a distinct third-party fingerprint) is characteristic of an unattended Windows maintenance restart rather than anything Richard or a script did.
Windows Update itself was checked and mostly ruled out on the visible evidence: the classic WindowsUpdateClient provider’s last logged event anywhere in the file was 2026-06-02, three months stale. But “Update Orchestrator Service for Windows Update” (the modern replacement mechanism) is confirmed present and actively started-then-stopped during this exact boot — the newer service doesn’t necessarily log to the older provider, so its absence there isn’t proof of absence. No cloud-hosting-agent-style provider (checked for anything not prefixed Microsoft-Windows-) was found either.
MBAMInstallerService (Malwarebytes) logs a startup-failure error at almost every single reboot in this log going back to June — including today’s. Tempting to read as a cause. It isn’t: it fails on EVERY boot regardless of what triggered that boot (confirmed against reboots with no restart-event correlation at all), meaning it’s a pre-existing broken/stale installer service, unrelated to why any particular reboot happened.
This session has no access to the Security event log (only System was uploaded), so the exact authenticated session behind the WMI call can’t be confirmed with certainty — the case for Windows Update / unattended maintenance is strong circumstantial evidence from the process fingerprint and recurrence pattern, not a signed confession.
AE.10Two Settings Changed on PROD, and Everything Promoted
Two settings changed directly on PROD by Richard, following from AE.8/AE.9: Recovery/auto-restart actions configured on the IDOC-PROD service itself — the actual gap, since Start Type=Automatic alone did not bring the service back after the 04:27 reboot (Windows never even attempted to start it, per the boot-window evidence in AE.8); and Windows Update’s automatic-restart behavior changed, to stop this recurring unannounced. Per Richard, verbatim: “done auto and changed update.”
Separately, and volunteered at the end of the day: “I pushed EVERYTHING into prod” — the web export consolidation, the restyled pages, the Bombers’ Notebook and the Sosaria Almanac (AE.1–AE.7) are, per Richard, now live on PROD. Not independently verified from this session, which has no PROD access — the DEV-side checklist Richard was working from is the “GM Command Reference” table below, plus a clean-restart/no-compile-error check.
AE.11Files Touched
| File | Change |
|---|---|
Web Facing/UltimateNPCWebExportShared.cs | New. WebServerPath, Esc(), RegionLabelFor() consolidated out of seven duplicated copies (AE.1). |
Web Facing/UltimateNPCWebExportHub.cs | New. Single shared Timer.DelayCall driving five of the six UltimateNPC web exports off one fetched list (AE.1). |
UltimateNPC/SimBiographyService.cs | FindBooksBySim replaced with BuildBookIndex(onlySimID); new BuildBiographies(list) builds the index once instead of once per sim (AE.2). |
Web Facing/SimBiographyWebExport.cs | Calls SimBiographyService.BuildBiographies(...); WebServerPath/Esc() delegate to the shared helper. Kept its own independent timer — different population than the Hub (AE.1/AE.2). |
Web Facing/UltimateNPCListWebExport.cs, UltimateNPCRelationshipsWebExport.cs, UNPCThoughtsWebExport.cs, UltimateNPCGoalsWebExport.cs | Timer removed from each (moved to the Hub); RunExport now takes the shared list as a parameter instead of fetching it independently (AE.1). |
UltimateNPC/UltimateNPCCommitmentWebExport.cs | Stale UTC-midnight cadence replaced with the Hub’s shared 3-hour schedule; RunExport/BuildJson/BuildStatsJson parameterized on the shared list (AE.1). |
Web Facing/NotableRaidersWebExport.cs | WebServerPath/Esc() delegate to the shared helper only — kept fully independent otherwise (AE.1). |
wwwroot/biographies.html, Thoughts.html | CSS repainted to the shared dark-gothic theme; all element IDs and JS behavior unchanged; Thoughts.html’s stale baked-in sample cards removed (AE.3). |
wwwroot/The_Living_World.html | New optional unpc_commitment.json fetch; commitment badge added to each NPC card (AE.3). |
UltimateNPC/UltimateNPCBombersNotebook.cs | New. Live-rebuilding player journal item; [BombersNotebook GM command (AE.5); MaxLineLength 30→20 and trailing-space fix after the first live test (AE.7). |
SimPC/SosariaAlmanacService.cs | New. 7-day rolling daily-edition buffer, weekly compiled book, Chronicler-SimPC vendor stocking; [SosariaAlmanacPreview / [SosariaAlmanacPrint commands (AE.6). |
Web Facing/UltimateNPCChronicle.cs | PostDaily() gained one extra, independently try/catch’d call to SosariaAlmanacService.RecordDailyEdition() after the real Discord post succeeds (AE.6). |
Reports/IDOCCommandGump_1_1_3.cs | New “Player-Facing — shipped 2026-09-03” section under UltimateNPC, listing all three new commands (AE.12). |
AE.12GM Command Reference — Added This Pass
All three commands are new this pass — none existed before today.
| Command | Purpose |
|---|---|
[BombersNotebook (GM) | Target a player to give them a Bomber’s Notebook — rebuilds itself live every time it’s opened, from that player’s own UltimateNPC encounters. AE.5. |
[SosariaAlmanacPreview (GM) | Previews this week’s compiled Almanac text in-game. Safe any time — no book created, day buffer untouched. AE.6. |
[SosariaAlmanacPrint (Administrator) | Forces this week’s Almanac to print now: writes a book into the current Chronicler SimPC’s home storage and queues it onto their vendor, then clears the day buffer. The weekly timer does this automatically. AE.6. |
AE.13Status
Confirmed on DEV and, per Richard, now promoted to PROD: the web export consolidation and Hub (AE.1); the SimBiographyWebExport fix, not independently re-measured but shipped (AE.2); the biographies.html/Thoughts.html restyle and the The_Living_World.html commitment badge (AE.3); the Bombers’ Notebook, including the line-wrap fix from AE.7 (AE.5); the Sosaria Almanac (AE.6); the command-reference update (AE.12).
Separately, on PROD directly (not DEV-tested first, since it’s an OS/service setting rather than a code change): Recovery/auto-restart actions on the IDOC-PROD service, and a change to Windows Update’s restart behavior (AE.10).
| Still open | Detail |
|---|---|
| PROD’s reboot trigger, exactly | Strong circumstantial case for Windows Update / unattended maintenance; not provable without the Security log (AE.9). |
| SimBiographyWebExport’s fix, re-measured | Shipped on the strength of the code-level diagnosis (AE.2); worth a before/after profile once PROD has run it a while. |
| The line-wrap fix (AE.7) | Committed to DEV; not yet re-tested live as of this writing. |
| The recurring hitch-storm thread | Entirely separate from everything in this appendix; Richard explicitly deferred it (“wait another day”) — unaffected by today’s PROD reboot, which was a different failure shape. |
| Bombers’ Notebook new-character acquisition | Still GM-command-only by choice (AE.5); wiring into starting gear deliberately out of scope this pass. |
closes the day: three separate threads (web/export cleanup, two new player-facing features, an unrelated PROD incident) that happened to land in the same session, kept in this appendix as one dated record rather than split apart, since that’s genuinely the order they happened in.
Visual Reference
The field guide — how the systems actually talk to each other, drawn the way a GM would sketch it on a napkin.
Visual Reference — The Living World of Sosaria (Infographic)
A single-page HTML infographic (“The Living World of Sosaria”, 2026-08-19) built as a GM-facing field guide to how the systems documented across this appendix list actually talk to each other — the Eco-Chain, the two-layer militia response, AI-Town's memory/mood/goals stack, and a map of every other system back to its own appendix letter. Rendered here as images, in the same order the page presents them, purely for quick visual reference alongside the appendices themselves — nothing in this section documents new behavior; every claim it makes is sourced from the appendices above.