SECS Province / Location / Building / Feature / Site / Resource Shape — Consolidated Research
Status: non-authoritative research. Output of 18 parallel Opus research agents dispatched 2026-05-03. Consolidates findings on the SECS shape for Valenar's economy, building, and content systems. Authoritative spec lands in docs/design/ only after sign-off.
NON-AUTHORITATIVE — pre-channel-migration shape; preserved for design history. Prose paragraphs in this file still use the older
statengine-layer vocabulary in places where rewriting would require re-doing the underlying analysis; per.claude/rules/secs-concepts.md:49the canonical engine-layer noun ischannel. The explicit code blocks below have been rewritten to canonicalchannelsource syntax.
Naming convention used throughout: the Victoria-3 concept "Production Method" is renamed Production Recipe (or just Recipe) per the naming agent's recommendation. The word fits high-fantasy medieval flavour (potion recipes, smithing recipes), works across Forge / Mine / Mage Tower / Druid Grove, and is the shortest UI-friendly name. Fallback if rejected: "Technique". The acronym "PM" is not used anywhere.
1. Existing game state (the baseline)
The game-style agent surveyed examples/valenar/Host/, Generated/Systems/, Content/{economy,settlement,world}/systems/, all 12 Province templates, and the founding code path. Findings:
- Implementation today is a Stage-1-only prototype.
Settlement.Stageis set to 1 byFoundBaseCampand never advanced. Stages 2–5 exist only indocs/stages.mddesign intent, not in code. - Scale today: 1 Region, 2–3 Areas, 4–9 Provinces, 40–171 Locations (typically ~70–100), 1 Settlement (entity id hardcoded to 1), 0 multi-character roster, 0 stage transitions. Initial Population = 10, initial Gold = 100.
- Resource flow: stockpiles live as hardcoded fields on Settlement (
Gold,Food,Wood,Metal,Stone,Production).ResourceProductionSystemwalks each owned Location daily, sums per-resource location stats, increments Settlement fields.FoodConsumptionSystemsubtractsPopulation × 1daily. - Build path: SignalR →
GameService.BuildInLocation→GameRuntime.EnqueueBuild→ cost validated →BuildOrderentity created →ConstructionSystem(PhaseConstruction) feeds Location.ProductionOutput into the order's progress → on completionctx.Activator.Create(templateId)instantiates the Building entity →OnBuiltruns and attaches modifiers. - What Valenar is NOT (verified by code absence): no diplomacy, no other realms, no faction/nation/empire types, no companions/governors/leaders/courtiers, no succession/dynasty, no trade routes, no army/units/battles (combat is
garrison >= enemyStrengthscalar), no tech tree, no policy/law system, no demands/treaties/casus belli. Single-MC, single-settlement design is honest in the code. - Scale-flexibility: most queries iterate via
AllByContractForTickwith load distribution, so adding more Provinces or Locations costs nothing architecturally. The 200–500 Location target is well within the engine's per-tick budget. Single-Settlement assumption (entity id = 1) is the one structural limit; multi-Settlement realms would need to lift this.
The 18 design recommendations below are sized against this real baseline, not against a Paradox-scale fantasy.
2. The architectural fixes (the load-bearing changes)
2.1 Resource becomes a real scope (the BIG fix)
Problem: Settlement currently has hardcoded fields int Gold; int Food; int Wood; int Metal; int Stone; plus matching Accumulative stats. Every new resource (15+ minerals planned: iron, copper, tin, silver, gold, mithril, adamantium, coal, sulfur, salt, marble, granite, gems, mana-crystal, dragon-bone) currently requires editing scope, stats, modifiers, and every system that touches resources. ~60 hand-written declarations for 17 resources.
Fix: make Resource a first-class SECS scope.
scope Resource
{
walks_to Settlement;
walks_to Resource;
int Amount; // host-owned stockpile, mutated by systems
int Capacity; // cached resolved Capacity stat; modifiers raise it
int FlowIn; // cached per-tick deposit rate
int FlowOut; // cached per-tick consumption rate
int IsExhausted; // sticky flag — was at zero recently
int InitialAmount; // designer tuning (per-template)
int BaseCapacity; // designer tuning (per-template)
}
contract Resource
{
root_scope Resource;
activation OnCreated;
deactivation OnDestroyed;
query bool CanAfford(int amount);
query bool IsCapped();
query bool IsEmpty();
query int ProjectedFlowIn();
query int ProjectedFlowOut();
method void OnCreated();
method void OnDestroyed();
method void OnGained(int amount);
method void OnSpent(int amount);
method void OnDepleted();
method void OnReplenished();
}
Four new top-level channels serve every Resource entity generically:
channel int Amount { kind = Accumulative; base = Resource.Amount; min = 0; }
channel int Capacity { kind = Contributed; /* contributed: no base = ... binding */ min = 0; }
channel int FlowIn { kind = Contributed; /* contributed: no base = ... binding */ min = 0; }
channel int FlowOut { kind = Contributed; /* contributed: no base = ... binding */ min = 0; }
Each resource is a template under the contract. Adding mithril is one new file:
template<Resource> Mithril
{
field string DisplayName = "Mithril";
field int InitialAmount = 0;
field int BaseCapacity = 25;
channel int Capacity = 25;
void OnCreated() { @root.increment(Amount, template_field(Mithril, InitialAmount)); }
}
Settlement loses its hardcoded resource fields:
scope Settlement
{
walks_to Settlement;
int Population;
int PopulationCap;
int Garrison;
int Defense;
int Morale;
int Stage;
int HomeLocationId;
collection Resources; // NEW — one Resource entity per resource template
collection Buildings;
collection Provinces;
}
Modifiers target a specific Resource entity by template id:
A new sigil @Settlement.resource(Wheat) lowers to a host helper that walks Settlement.Resources and returns the entity whose template is H.Wheat. Storehouse becomes:
template<Building> Storehouse
{
field BuildCost = new ResourceCostBundle({[Wood]: 40, [Stone]: 20, [Gold]: 30});
void OnBuilt()
{
@Settlement.resource(Wheat).add_modifier StorehouseCapacityBoost;
@Settlement.resource(Wood).add_modifier StorehouseCapacityBoost;
@Settlement.resource(Stone).add_modifier StorehouseCapacityBoost;
}
}
modifier StorehouseCapacityBoost
{
translation = "Storehouse Capacity";
stacking = stackable;
Capacity += 100;
}
Build costs become structured fields:
field ResourceCostBundle BuildCost { ... } // generated record with map<resourceId, int>
template<Building> Farm
{
field BuildCost = new ResourceCostBundle({[Wood]: 15});
bool CanBuild() { return @Settlement.Stage >= 1 && BuildCostRules.CanPay(this); }
void OnBuilt() { @Settlement.resource(Wheat).add_modifier FarmFoodFlow; }
}
modifier FarmFoodFlow
{
translation = "Farm Production";
stacking = stackable;
reads { Fertility }
FlowIn += (int)(5 * @owner.Location.Fertility / 33);
}
Two notes on what stays Settlement-scoped:
Productionis NOT a Resource — it's a throughput stat (build queue speed), not a stockpile. No Capacity, no IsExhausted. Stays as Settlement Contributed stat.GoldIS a Resource — fits the model cleanly. Treasury cap from a Vault drops out for free.
2.2 Building becomes a real scope
Problem: Today contract Building { root_scope Location; }. Building templates activate stat sources on Location entities. This worked for 1-per-Location markers but cannot model V3-style leveled buildings — there's no per-instance host-mutable state for Level, ActiveRecipe, Employment, etc.
Fix: promote Building to its own scope.
scope Building
{
walks_to Location;
walks_to Province;
walks_to Area;
walks_to Region;
walks_to Settlement;
walks_to Building;
int LocationId;
int TemplateId;
int Level;
int MaxLevel;
int LevelsUnderConstruction;
int ConstructionProgress;
int BuildOrderIndex; // monotonic, used for over-cap penalty stack ordering
int ActiveRecipeId; // ulong — points at currently-bound Recipe template
int JobsProvided;
int JobsFilled;
int Output;
int UpkeepCost;
int PotentialProfit;
int IsAutoFiring;
int IsPaused;
int IsLocked;
}
contract Building
{
root_scope Building; // CHANGED from Location
activation OnEstablished;
deactivation OnDemolished;
query bool CanBuild();
query bool CanUpgrade();
query bool CanSwitchRecipe(ulong newRecipeTemplateId);
query int MaxLevelOnLocation();
method void OnEstablished();
method void OnLevelBuilt();
method void OnDemolished();
method void OnDayTick();
method void OnRecipeSwapped();
method void OnPaused();
method void OnUnpaused();
context BuildCostContext { @Settlement; @Province; @Location; @Building; }
}
Archetype-specific static data stays in template field declarations (Forge.MaxHeat, Mine.OreClass). The "host-mutable per-instance state belongs on a scope" rule is the canonical SECS pattern from docs/design/01-world-shape.md § StatKind.Base.
Building.Level is kind = Base; source = building.Level; min=1; max=50; — modifiers can decorate effective Level. A "Master Craftsman" buff making a Level-5 Workshop act like Level 6 (more jobs, more output, more upkeep) is the action-idle reward the genre wants. ConstructionSystem reads the raw building.Level field for cost math (the same way template-field cost reads work today), shielding cost calculations from temporary buffs.
2.3 Site splits into two contracts (one scope)
Problem: today's scope Site + single contract Site carries merged vocabulary (OnEstablished, OnWorked, OnCompleted) that fits terminal sites (RawDeposit → Mine, gone) but cannot honestly express persistent threats (Dungeon, regenerates after each raid).
Fix: keep scope Site (single, with all current fields), but introduce TWO contracts on it.
contract DevelopmentSite
{
root_scope Site;
activation OnEstablished;
query bool CanInspect();
query bool CanSurvey();
query bool CanDevelop();
method void OnEstablished();
method void OnInspected();
method void OnSurveyed();
method void OnDevelopmentStarted();
method void OnDevelopmentTick();
method void OnCompleted(); // emits unlock + queues entity destroy
}
contract Threat
{
root_scope Site;
activation OnRevealed;
deactivation OnPermanentlyDestroyed;
query bool CanRaid();
method void OnRevealed();
method void OnRaidStarted();
method void OnRaidPartialClear();
method void OnRaidFullClear();
method void OnRegenTick();
method void OnPermanentlyDestroyed();
}
Templates pick a contract:
template<DevelopmentSite> AbandonedShrine— terminal, gets restored into a Temple.template<DevelopmentSite> RawIronDeposit— terminal, becomes an Iron Mine.template<DevelopmentSite> WildernessCamp— terminal, becomes a Hamlet.template<Threat> Dungeon— persistent, MC raids repeatedly.template<Threat> BanditCamp— persistent, regenerates after each clear.template<Threat> FaeRift— persistent, escalates if ignored.
Two systems iterate cleanly without discriminator branches:
system DevelopmentSiteProgression { phase Production; frequency Daily; ... }
system ThreatRegeneration { phase Maintenance; frequency Daily; ... }
The single Location.Sites collection stays — UI and on-map renderer continue reading one collection, discriminate by template kind for icon/colour, and get behaviour-correctness from the contract split they don't have to know about. Modifier bindings stay root_scope = Site, valid for both contracts since the scope is shared.
2.4 Production Recipe is a registry-only template referenced by Building.ActiveRecipeId
Decision: Recipe is NOT a runtime scope. It's a registry-only template. The Building scope has one ulong field ActiveRecipeId that points at the active Recipe template. Switching Recipe = host writes the new id AND removes the old Recipe modifier bundle, attaches the new one.
Each Recipe template carries its parameters as field declarations (no per-instance entity needed):
contract Recipe { ... } // metadata-only contract, no entity activation
template<Recipe> CharcoalForgeRecipe
{
field int InputCharcoal = 1;
field int OutputIronTools = 30;
field int LaborersPerLevel = 5;
field bool RequiresSteamTech = false;
}
template<Recipe> SteamForgeRecipe
{
field int InputCoal = 2;
field int OutputSteelTools = 50;
field int LaborersPerLevel = 3;
field bool RequiresSteamTech = true;
}
A Recipe-effects modifier bundle carries the runtime contributions to Building.Output / Building.JobsRequired / etc.:
modifier CharcoalRecipeBindings
{
translation = "Charcoal Recipe";
Output += 30 * resolve(@Building.Level);
JobsProvided += 5 * resolve(@Building.Level);
UpkeepCost += 2 * resolve(@Building.Level);
}
Switching Recipe lowers to two engine commands: RemoveModifier(buildingEntity, oldRecipeBundle) + AddModifier(buildingEntity, newRecipeBundle). Tech locks on Recipes use HardOverride bool stats on Settlement (SteamTechUnlocked = true); Recipe CanActivate() query reads @Settlement.resolve(SteamTechUnlocked).
Why not a scope: at ~50–200 active Buildings × 5 Recipes per Building template, scope-per-binding would double entity count for no semantic gain. Recipe doesn't need its own modifier-target identity because effects flow through the Building scope. Recipe doesn't need its own contract iteration because no system iterates "all active Recipes" — systems iterate Buildings and read ActiveRecipeId.
2.5 Outpost is a Settlement extension
Decision: new scope Outpost { walks_to Settlement; ... }, but shares treasury and pop pool with the main Settlement. Specialisation (Mining / Magic / Military / Trading) lives in Outpost template variants. Existing systems unchanged because Settlement remains the primary economic entity.
scope Outpost
{
walks_to Settlement;
walks_to Location;
walks_to Province;
walks_to Outpost;
int OwnerSettlementId;
int AnchorLocationId;
int Specialisation; // 1=Mining, 2=Magic, 3=Military, 4=Trading
int LocalGarrison;
int LocalDefense;
int Stage;
collection Locations;
collection Buildings;
}
Stage 3 unlocks the FoundOutpost(Location) action; cap of 2-3 Outposts at Stage 3, 4-5 at Stage 4-5.
Naming collision flagged: the existing 5-stage progression already uses the WORD "Outpost" for Stage 2 of the main Settlement (Base Camp → Outpost → Settlement → Town → City). The new entity needs a different player-facing label. Candidates: Holdfast, Frontier Post, Bastion. The SECS scope can stay Outpost internally; the UI label is what changes.
3. The 14 design questions answered
Q1 — Food pool location: Settlement-only with per-Province climate modifiers
Recommendation: keep int Food and int FoodCapacity on Settlement (or as the Wheat Resource entity once the Resource fix lands). Each Province carries int ClimateBand; Locations attach climate modifiers (TemperateClimate +10% FoodOutput, ColdClimate -40%, BlightedSoil) creating geographic friction without 30 separate stockpiles to track.
Why this beats Province-level pools: at Valenar's scale (10–30 Provinces, single settlement), per-Province stockpiles force a trade-route subsystem on day one, fragmenting attention across 30 HUD numbers — exactly what action-idle pacing rejects. The City Trap ("don't promote every Location to City") is preserved through climate-rate modifiers: a Cold Province with -40% FoodOutput cannot support a Town promotion without serious investment, even though the realm pool is shared.
Composes with future Outpost specialisation cleanly — when Outposts arrive, each gets its own Food field on its scope without breaking Settlement's central pool.
Q2 — Production Recipe as registry-only template + Building.ActiveRecipeId
See §2.4 above. Recipe is metadata-only template; effects flow through a modifier bundle attached to the Building when Recipe is selected. Tech locks via HardOverride bool stats on Settlement.
Q3 — Site partitioning: single scope, two contracts
See §2.3 above. scope Site stays single; two contracts (DevelopmentSite, Threat) on it; templates pick a contract.
Q4 — Building.Level as Base channel (modifiers can decorate effective Level)
channel int Level { kind = Base; base = building.Level; min=1; max=50; }. Modifiers can additively or multiplicatively decorate the resolved value. ConstructionSystem reads the raw building.Level field for cost math (un-decorated), so a temporary buff doesn't make level-ups cheaper. This is the action-idle reward feel: Master Craftsman makes a Level-5 Workshop act like Level 6 (jobs, output, upkeep all rise together).
Q5 — Output: formula on the template + system as the executor
Both, layered. Building.Output is a Contributed channel with a dynamic formula source registered on the Building template (RegisterDynamicChannelSource(scope.Owner, scope.Root, H.Output, H.Formula_BuildingOutput)). ResourceProductionSystem is the executor that iterates Buildings, calls resolve(Output) on each, and increments Settlement Resource stockpiles via IncrementScopeField. Modifiers (Master Craftsman, Harsh Winter, Pause, designations) attach to the Building and decorate Output through the standard 6-phase pipeline. UI and downstream systems hit the ChannelCache from the formula's per-tick resolution — formula evaluates once per tick per Building; cache amortises across all readers.
Q6 — Over-cap building penalty: per-Level on each Building's own excess
Slot allocator orders Buildings by Building.BuildOrderIndex (monotonic, host-set). Walks levels until cap is filled; every level past cap adds one stack of OverCapBuildingPenalty (UpkeepCost *= 1.05 per stack) to its own Building.
Eldwood-1 example: BuildingLevelsCap=10, current usage = Forge L4 + Mine L5 + Mage Tower L3 = 12. Forge built first (slots 1–4), Mine next (slots 5–9), Mage Tower next (slot 10 fits, slots 11–12 over) → Mage Tower gets 2 stacks (+10% upkeep). Forge and Mine pay normal upkeep. Demolishing a level reflows the slot allocation deterministically. Cap raise (Town → City) shrinks excess; system removes stacks naturally.
This is the "consequence tied to the specific over-spend" model — better than per-Location uniform penalties (which punish innocent buildings) or latest-built-only (which thrashes on demolition).
Q7 — Construction Work Bank: Settlement-only single bank, stage-gated concurrency
One int ConstructionCapacity and int ConstructionWorkBank on Settlement. Workshop modifiers feed Capacity. Each tick the bank gains Capacity, drains into queued Building.ConstructionProgress for buildings with LevelsUnderConstruction > 0. Stage-gated concurrency: Stage 1 = 1 concurrent build, Stage 2 = 2, …, Stage 5 = 5.
Outposts contribute Workshop modifiers upward and benefit from the realm-wide pool downward — they specialise in what they do, not in how fast they build alone. If Outpost-specific construction lanes become a major late-game feature, promote to per-Outpost banks with overflow rules; the migration is a clean field-split.
Q8 — Province completion: 30-day Boom + permanent Tail + tier-building unlocks
Hybrid reward fires on OnCompleted (when every Location in the Province shares one OwnerId):
ProvinceCompletedBoommodifier attached to every Location in the Province for 30 game-days, decaying linearly toProvinceCompletedTail. Effect:+20%to all output stats on the Boom;+5%permanent on the Tail.- Province-tier buildings unlock (
Cathedral,ProvincialCapital,Marketplace+) viaCanBuildreading parentprovince.Completed == 1. - Stage gate reads the same flag: Stage 3 needs 1 completed Province, Stage 4 needs 3, Stage 5 needs 5.
Reversibility: if a Location is lost, fire OnUncompleted, remove both Boom and Tail modifiers from every Location, clear Completed = 0. Province-tier buildings stop functioning until re-completed; do NOT demolish them.
Q9 — Threat respawn: deferred, basic shape unaffected
Confirmed: the basic Threat scope shape doesn't need respawn machinery. If Threat.RaidsToDestroy = 0, the Threat is forever (regenerates after each raid). If > 0, after that many full clears the Threat dies via OnPermanentlyDestroyed. Bringing back a destroyed Threat is a separate system (event spawns a new Threat at the same Location after a condition); designed later. Out of scope for the basic shape.
Q10 — "Production Method" → "Recipe" (or "Production Recipe")
Use Recipe consistently across Forge, Mine, Mage Tower, Druid Grove. Medieval-fantasy native (potion recipes, smithing recipes), short, UI-friendly, localises cleanly. Fallback: Technique if the kitchen-coding bothers anyone.
Q11 — Designation change cost: auto-abandon with 50% material refund + 10-tick transition
Free first designation (when DesignationType == 0). Subsequent change:
- Auto-abandonment with 50% refund. Walk Location.Buildings, call
OnAbandoned()contract method on each non-matching building. Refund 50% of cumulative wood/stone/gold/food paid. Refund ratio is aSettlement.AbandonmentRefundRatiostat (default 50, modifiable upward by future Civic building like a Surveyor's Office). RedesignationTransitionmodifier attached to the Location for 10 ticks withdecay = linear, applyingFoodOutput *= 0%,MetalOutput *= 0%, etc. — output ramps from 0 back to 100% over those 10 ticks while new designation buildings come online.- No treasury fee, no cooldown, no per-Location cap. The investment loss IS the cost.
- Detach old designation modifier, attach new one.
The cost lives entirely in existing SECS primitives: a contract method (OnAbandoned), a duration-decay modifier (RedesignationTransition), and the existing designation-modifier swap.
Q12 — Population growth: Hybrid (Food + Housing + Prosperity + Migration), Location-scoped, Daily
Move PopulationGrowthSystem from Settlement scope to Location scope, frequency Daily (not Monthly — too coarse for action-idle pacing).
system PopulationGrowth
{
phase Growth;
frequency Daily;
void Execute()
{
foreach loc in Location
{
if (loc.resolve(OwnerId) == 0) continue;
var food = loc.resolve(FoodNet);
var cap = loc.resolve(ResidentsCap);
var pop = loc.resolve(Residents);
if (food < 0) { loc.increment(Residents, -famine_rate); continue; }
if (food > 0 && pop < cap)
{
var rate = base_rate
* clamp(loc.resolve(Prosperity) / 50, 0.5, 2.0)
* (1 + loc.resolve(Fertility) / 200);
loc.increment(Residents, rate);
}
}
}
}
Concrete numbers (per game-day, clock-independent):
base_rate = 0.05 pops/Location/dayat default Prosperity (50) and Fertility (50)- Stage 1 → 2 (pop 15 → 25): ~60 game-days with 3 starting Locations
- Stage 2 → 3 (pop 25 → 100): ~250 game-days with 5–7 Locations
- Famine decline:
-0.2 pops/Location/daywhileFoodNet < 0 - Migration: rare MC-driven events grant +3..+10 pops (event-injected, not tick-driven)
ResidentsCap is a Contributed stat fed by housing buildings (Hut +5, Longhouse +15, Stone Hall +40) and Designation modifiers (Residential adds +10 floor).
Q13 — Stage gate: Hybrid pop-prereq + Stage Promotion ritual
Pop is the prereq; ritual is the trigger. When pop crosses N, an event fires offering a one-shot OnAction "Perform the Founding Charter" (or analogous ritual per stage). The OnAction pays a small resource bundle and bumps Stage. Stages are monotonic (no demotion).
| From | To | Pop prereq | Building prereq | Other | Ritual cost |
|---|---|---|---|---|---|
| 1 | 2 | 25 | first Farm | — | 20 Wood, 10 Gold |
| 2 | 3 | 100 | first Workshop | 1 province completed | 40 Wood, 30 Gold, 20 Stone |
| 3 | 4 | 500 | first Mage Tower | 3 provinces completed | 100 Stone, 50 Metal, 100 Gold |
| 4 | 5 | 2000 | first Citadel | 5 provinces completed | 250 Metal, 500 Gold, MC at HomeLocation |
StageGateSystem (Pre phase, frequency 8 ticks) iterates the singleton settlement, evaluates predicates, fires OnStageReady event. The ritual OnAction sets Stage and attaches Stage{N}Gate modifier — every other system gates on @Settlement.Stage like Farm already does today.
Q14 — Capacity origin: per-OreVein modifier with dynamic effect formula reading Yield
channel int MineralCapacity { kind = Contributed; /* contributed: no base = ... binding */ min = 0; }
modifier RichOre
{
translation = "Rich Ore Vein";
stacking = stackable;
tags = economic;
reads { Yield }
MineralCapacity += resolve(Yield); // dynamic, evaluates against owner = OreVein
}
template<Feature> OreVein
{
channel int Yield = 80; // per-instance, depletes with mining
void OnDiscovered()
{
@Location.add_modifier RichOre; // owner = this OreVein, target = Location
}
}
Yield depletion propagates automatically: when the mining system writes Yield -= 10 on the OreVein, the formula's reads { Yield } clause invalidates MineralCapacity on the Location, which re-resolves on next read. Multiple OreVeins → multiple stackable bindings, each evaluating against its own owner — total MineralCapacity is sum of contributing Yields. Surveyed gate: the modifier is attached in OnDiscovered, so hidden Features contribute zero. This mirrors the existing HousePopulationCapStack + Formula_HousePopCap pattern exactly.
Q15 — Rank-based modifier lifecycle: while triggers on OnLoaded
Attach all six rank-tier modifiers (RuralFoodTrap, TownFoodTrap, CityFoodTrap, RuralRankCap, TownRankCap, CityRankCap) once at Location's OnLoaded with while @root.Rank == N lifetime clauses. The engine's update pass re-evaluates each trigger each tick and toggles binding State between Active and Inactive. Promotion code is one-line Rank = Town write.
template<Location> Location
{
void OnLoaded()
{
@root.add_modifier RuralFoodTrap while @root.Rank == 1;
@root.add_modifier TownFoodTrap while @root.Rank == 2;
@root.add_modifier CityFoodTrap while @root.Rank == 3;
@root.add_modifier RuralRankCap while @root.Rank == 1;
@root.add_modifier TownRankCap while @root.Rank == 2;
@root.add_modifier CityRankCap while @root.Rank == 3;
}
}
modifier TownFoodTrap { FoodNet += -5; }
modifier CityFoodTrap { FoodNet += -15; }
modifier RuralRankCap { BuildingLevelsCap = 5; } // HardOverride
modifier TownRankCap { BuildingLevelsCap = 12; }
modifier CityRankCap { BuildingLevelsCap = 25; }
Save/load is idempotent because bindings serialise with TriggerId and the trigger re-evaluates on load. No imperative remove path exists, so no orphan path can exist. 6 bindings × 500 Locations = 3000 bindings total — ModifierBindingStore is built for this scale; inactive bindings are skipped by ChannelResolver.
4. Naming reconciliation
The synthesis enforces:
- "Production Method" / "PM" → Recipe (or "Production Recipe" in long form).
- "MC" stays "MC" — never "Hero" (per memory).
- The Stage 2 settlement label "Outpost" still applies to the main Settlement's Stage 2 progression. The new frontier-Outpost entity uses a different player-facing label: pick Holdfast / Frontier Post / Bastion. SECS scope name is
Outpost; UI string is decoupled. - "Production" stat stays as a Settlement Contributed throughput stat (NOT a Resource).
5. What gets DELETED (no backwards compatibility)
From Valenar domain-owned Content/**/scopes.secs files
Settlement.Gold,Settlement.Food,Settlement.FoodCapacity,Settlement.Wood,Settlement.Metal,Settlement.Stone→ DELETED. Replaced bycollection Resources.Location.Slots→ DELETED. Replaced byBuildingLevelsCap(Contributed stat) +BuildingLevelsUsed(system-computed).Location.FoodOutput,WoodOutput,MetalOutput,StoneOutput,ProductionOutput→ DELETED. Replaced by per-BuildingOutputflowing through Resource templates.Location.Jobs,Location.Residents→ DELETED. Replaced byJobsAvailable,JobsFilled,SurplusLabor,Population,PopulationCap.- The current scope
Sitekeeps its fields; what changes is the contract structure (one scope, two contracts).
From Valenar domain-owned Content/**/contracts.secs files
contract Building'sroot_scope Location→ CHANGED toroot_scope Building.contract Site's merged-vocabulary methods → DELETED. Replaced bycontract DevelopmentSiteandcontract Threat.- New:
contract Resource,contract Recipe,contract Outpost,contract DevelopmentSite,contract Threat.
From the historic common stat bucket
Gold,Food,FoodCapacity,Wood,Metal,Stone,FoodOutput,WoodOutput,MetalOutput,StoneOutput,ProductionOutput,GoldCost,WoodCost,StoneCost,MetalCost→ DELETED.- New:
Amount,Capacity,FlowIn,FlowOut(resource-scoped);MineralCapacity,FoodNet,Yield,Level,Output,UpkeepCost,JobsProvided,BuildingLevelsCap,Development,Significance,Control,Prosperity,IsLocked(mostly Building/Location/Province scoped). - New structured field:
BuildCost : ResourceCostBundle.
From examples/valenar/Content/common/modifiers.secs
StorehouseFoodCapacityand tier 2/3 variants (3 modifiers) → DELETED. Replaced byStorehouseCapacityBoost(one declaration, attached to multiple Resource entities).Famine→ DELETED. Replaced byFlowChokeattached to Wheat.- New:
StorehouseCapacityBoost,VaultCapacityBoost,BountifulHarvest,FlowChoke,PopulationFoodDemand,ResourceExhausted,RuralFoodTrap,TownFoodTrap,CityFoodTrap,RuralRankCap,TownRankCap,CityRankCap,OverCapBuildingPenalty,RichOre,ProvinceCompletedBoom,ProvinceCompletedTail,RedesignationTransition,BuildingAutoFired,RecipeTechLocked, plus per-(building, resource) flow modifiers (FarmFoodFlow,IronMineFlow,CoalSeamFlow, …).
From Generated/
Templates/Bare/BareLocation.cs— stripRegisterChannelSource(..., H.Slots, 4); activation becomes empty.- All
Templates/Buildings/*.cs(32 files) — changeContractId = H.BuildingContract,RootScopeId = H.Building, renameOnBuilt → OnEstablishedlifecycle binding, replace per-resourceFormula_FarmFood/Formula_WorkshopProductionregistrations with the uniformFormula_BuildingOutputreading Level × Recipe. Replacefield int GoldCostwith structuredfield BuildCost = new ResourceCostBundle({...}). Templates/Sites/Dungeon.cs→ moves toTemplates/Threats/Dungeon.cs. ContractIdH.ThreatContract, lifecycle method renames.Templates/Sites/AncientShrine.cs→ moves toTemplates/DevelopmentSites/AncientShrine.cs.- New:
Templates/Resources/{Wheat,Wood,Stone,Iron,Copper,Coal,ManaCrystal,DragonBone,Gold}.cs(9 files),Templates/Resources/ResourceQueries.cs,Templates/Bare/BareResource.cs,Templates/Recipes/{CharcoalForgeRecipe,SteamForgeRecipe,...}.cs. Declarations.cs— every section rewritten (Scopes, ScopeFields, ScopeMethods, Contracts, ContractMethods, Stats, Modifiers).Hashes.cs— deleteH.Gold/Food/FoodCapacity/Wood/Metal/Stone/Production/FoodOutput/...; addH.Resource/Amount/Capacity/FlowIn/FlowOut/Wheat/Iron/Copper/.../H.Building/Level/MaxLevel/.../H.DevelopmentSiteContract/H.ThreatContract/H.Outpost/.../(~80 new constants).SecsModule.cs— add ~16 new system registrations, ~60 new modifier registrations, all new template registrations.
Host code (Host/)
GameWorld.cs— drop per-Settlement resource fields. Add per-Settlement Resource handle list / dictionary keyed by(SettlementHandle, ResourceTemplateId).Bridge/HostBridgeReads.cs/HostBridgeWrites.cs— update for Resource scope reads/writes.Host/Systems/FoodConsumptionSystem.cs→ DELETED, replaced by SECS-sideResourceConsumptionSystem.Host/GameRuntime.cs— at settlement founding, spawn one Resource entity per registered Resource template, runOnCreatedlifecycle.Server/Hubs/GameHub.cs— diff serialisation switches from "settlement.Gold = N, settlement.Food = M" to "resources: [{templateId, amount, capacity, flowIn, flowOut}, ...]". React renders generically, one row per resource — adding mithril is zero React work.Client/src/store/gameStore.ts— replace typed resource fields with genericresources: Map<ResourceTemplateId, ResourceState>. Replace per-resource selectors withselectResource(templateId)andselectAllResources().
Existing 12 Province templates (eldwood, frostpeak, …)
Survive with minimal rewrites. They become metadata-only template<Province> carrying field int ClimateBand, field int TopographyType, field int Habitability, plus optional OnLoaded hooks. Biome-specific stat declarations (e.g. stat int Fertility = 75) move to per-Location templates (new files per location archetype).
6. Implementation order (the 12-step build plan)
Each step lands independently and leaves the engine in a coherent state.
- Resource scope + contract + 9 templates + Settlement.Resources collection. Resources flow but everything still reads through legacy fields temporarily. (Remove legacy fields in step 11.)
scope Buildingdeclaration + Building contractroot_scope Buildingchange. Migrate all 32 building templates' lowering. Old Settlement.Buildings collection now holds Building entities, not Location-rooted activations.scope Sitetwo-contract split —contract DevelopmentSite+contract Threat. Migrate Dungeon → Threats/, AncientShrine → DevelopmentSites/.- Recipe registry +
Building.ActiveRecipeId+ Recipe modifier bundle pattern. Forge, Workshop, Mine get 2-3 Recipes each. stat int Level { kind = Base; ... }on Building. Master Craftsman buff demonstrates effective-Level decoration.- Capacity origin via dynamic-effect modifiers. OreVein.Yield → Location.MineralCapacity via
RichOre; Forest.Yield → Location.TimberCapacity viaRichForest; etc. - Rank-based modifier lifecycle via
whiletriggers on Location.OnLoaded. - Stage gate: pop-prereq + ritual event on Settlement. StageGateSystem fires events on pop crossing.
- Population growth Location-scoped with Hybrid model. PopulationGrowthSystem moves from Settlement to Location iteration.
- Province completion: Boom + Tail + tier-building unlocks. ProvinceCompletionSystem detects, fires OnCompleted.
- Delete legacy hardcoded resource fields from Settlement, legacy Location output fields, legacy
Slotsfield. Update host bridge. - Outpost scope + FoundOutpost action. Stage 3 unlock. Naming pass to disambiguate Stage-2-label vs Outpost-entity.
Optional after step 12: per-Outpost construction lanes if specialisation becomes a major late-game feature; Threat respawn system; per-Resource trade routes.
7. Open questions for user sign-off
- Outpost player-facing name — pick one of: Holdfast / Frontier Post / Bastion. Affects UI strings only; SECS scope stays
scope Outpost. - Recipe vocabulary — agent recommends "Recipe". Fallback "Technique" if Recipe's kitchen-coding bothers you.
- Province-tier buildings — synthesis assumes Cathedral, Provincial Capital, Marketplace+ unlock on Province completion. Pick which buildings should actually be in the unlock set.
- Stage threshold numbers — synthesis used Pop 25/100/500/2000 from existing memory plus building/province prereqs. These are tunable.
- Designation refund ratio default — synthesis used 50%. Could be 25% (harsher) or 75% (more forgiving).
- Province completion Boom/Tail values — synthesis used +20% for 30 days decaying to +5% permanent. Tunable.
- The Production stat — confirm it stays Settlement-scoped Contributed throughput, NOT a Resource. (Three agents independently agreed; flagging to verify.)
- Resources Settlement pre-spawns — synthesis recommends pre-spawning all registered Resource entities at founding (one per template, even if never used). Alternative: lazy-spawn on first deposit. Pre-spawn is simpler; lazy is leaner.
8. Where this synthesis lives
This document is the research-grade scratch consolidation. It is non-authoritative (per memory feedback_research_citation_separation.md — docs/research/ is exploration, docs/design/ is committed spec).
After user sign-off on the open questions in §7, the next step is to:
- Write actual
scopes.secs,contracts.secs,stats.secs,modifiers.secsreplacements. - Generate matching
Generated/lowerings file by file. - Update host bridge code.
- Update React client.
- Land the changes in the implementation order from §6.
Each step is independently reviewable.