09 — AI, Policies, and Activities
This doc covers the current SECS AI subsystem built around policies, selectors, needs, rule decisions, and activity candidates:
policy— a first-class declaration of compile-time AI decision logic. A policy ties an actor scope to a behavior domain (Survival, Economy, Combat, Travel, Forage…) and carriesneed,selector, andrule Decisionsub-declarations.need— a declarative goal carried by a policy. Names a channel, a target value, an urgency curve, a threshold, and a weight. Read by the utility-AI selector when scoring candidates.selector— a candidate-source declaration that buildsActivityCandidatelists forcall_bestto score and pick from.rule Decision— a method-bodied dispatch that returns one of sevenRuleDecisioncases (continue,complete,fail,wait,call(...),call_best(...),cancel_child).PolicyExecutor— the engine runtime that builds candidates, scores them against the policy's needs, and dispatches.
Executable behavior is activity + policy. Valenar docs should use
activity as the canonical behavior / planning noun, with more specific
terms such as Mission or Assignment where they clarify the player-facing
surface. Read 04-behavior.md § Policies for the lowering contract; this
doc covers the AI scoring math and the philosophy of needs-vs-effects.
The design is motivated by three concrete pressure tests:
- The AI must score crafted/recipe-based items correctly without per-template AI weights — there are too many channels and too many runtime modifier combinations for hand-authored scoring formulas to scale.
- The player must be able to configure AI behavior in-game (toggle rules, tune thresholds, add custom rules) without recompiling the game.
- The target AI design must persist behavior across save/load, including mid-execution policy state, with a migration story when definitions change between versions. The current runtime has partial store-level primitives only; policy child linkage remains a save/load gap.
Prerequisites
00-overview.md— the SECS skeleton and doc map.01-world-shape.md—scope,contract, top-levelchanneldeclarations.02-templates.md— template declarations, fields, channels.03-channels-and-modifiers.md— channel resolution pipeline, modifier stacks.04-behavior.md—system,event,on_action,activity, and the## Policiessection that defines the lowering contract.08-collections-and-propagation.md—ScopedList<T>/ScopedDictionary<TKey, T>, used for the player-authored extension surfaces below.
Rules
rule Decision Name() { ... } — method-bodied dispatch
What: A method that the host evaluates each time the policy runs. It
inspects the actor's current state and returns a RuleDecision. Multiple
rules per policy; walked in declaration order (or by explicit Order slot
when the lowering keeps a numeric ordering).
SECS:
rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}
rule Decision EatWhenHungry()
{
if (actor.resolve(Hunger) > 50)
return call_best(FoodSelector);
return continue;
}
rule Decision DoneWhenHealthy()
{
if (actor.resolve(HP_Current) >= actor.resolve(HP_Max))
return complete;
return continue;
}
Compiles to:
public override RuleDecision EvaluateRule(RuleId ruleId, PolicyRunContext context)
{
if (ruleId.Value == H.Rule_EmergencyRest) return EvaluateEmergencyRest(context);
if (ruleId.Value == H.Rule_EatWhenHungry) return EvaluateEatWhenHungry(context);
if (ruleId.Value == H.Rule_DoneWhenHealthy) return EvaluateDoneWhenHealthy(context);
return RuleDecision.Continue.Instance;
}
private static RuleDecision EvaluateEmergencyRest(PolicyRunContext context)
{
var hp = context.Tick.ChannelResolver.ResolveIntFast(context.Actor, H.HP_Current);
var hpMax = context.Tick.ChannelResolver.ResolveIntFast(context.Actor, H.HP_Max);
if (hpMax > 0 && hp * 4 < hpMax)
return new RuleDecision.CallBest(new SelectorId(H.Selector_RestSelector));
return RuleDecision.Continue.Instance;
}
Why this shape: The user pressure was that the previous rule X when Y call Z; micro-syntax was hard to read because it didn't share shape with
established query / method declarations. Rules-as-method-bodies match
exactly: function name, parameter list, C# body. Imperative inside the
body, declarative as a registered rule. The keyword rule Decision (vs
reusing method) is justified because rules have a specific runtime
contract — the executor walks them in order, reads the returned
RuleDecision, and stops on first dispatch.
RuleDecision — the discriminated return type
public abstract record RuleDecision
{
public sealed record Continue : RuleDecision { ... }
public sealed record Complete : RuleDecision { ... }
public sealed record Fail : RuleDecision { ... }
public sealed record Wait : RuleDecision { ... }
public sealed record Call(ActivityId ActivityId, EntityHandle Target, ActivityArgsBlob Args) : RuleDecision;
public sealed record CallBest(SelectorId SelectorId) : RuleDecision;
public sealed record CancelChild : RuleDecision { ... }
}
| Case | Meaning |
|---|---|
Continue | Rule had nothing to say; move to the next rule. |
Complete | Mark the policy run successful; no further rules this tick. |
Fail | Mark the policy run failed; no further rules this tick. |
Wait | Park this tick; keep any active child intact. |
Call(activity, target, args) | Dispatch a specific activity directly. |
CallBest(selector) | Dispatch the highest-scoring candidate from the named selector. |
CancelChild | Cancel the actor's current child run before re-evaluating. |
The no-payload cases are singleton Instance constants so trivial
branches ("nothing to say this tick") do not allocate. Call and
CallBest carry payload as records.
Needs
need Name { channel = ...; target = ...; curve = ...; threshold = N; weight = N; }
What: A declarative goal carried by a policy. Names a channel the actor
cares about, a target value, an urgency curve, a threshold (where
urgency = 1.0), and a weight (the contribution to the final score). Read
by PolicyExecutor.ScoreCandidates when evaluating call_best.
SECS:
policy CharacterSurvival
{
actor Character;
domain Survival;
need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}
need KeepFed
{
channel = Stamina_Current;
target = Stamina_Max;
curve = inverse_quad;
threshold = 30;
weight = 60;
}
}
Compiles to:
public override IReadOnlyList<NeedDeclaration> Needs { get; } = new[]
{
new NeedDeclaration(
new NeedId(H.Need_StayAlive), "Stay Alive",
H.HP_Current, H.HP_Max,
NeedCurveKind.InverseQuad,
Threshold: 50, Weight: 100),
new NeedDeclaration(
new NeedId(H.Need_KeepFed), "Keep Fed",
H.Stamina_Current, H.Stamina_Max,
NeedCurveKind.InverseQuad,
Threshold: 30, Weight: 60),
};
Why this shape: Needs describe what the actor wants. They are
declarative data (a channel reference, a target, a curve, a threshold, a
weight). The keyword form gives the compiler a structural slot to emit;
the underlying NeedDeclaration is a flat positional record, mirroring
OnActionDeclaration and RuleDeclaration.
Needs live on the policy, never directly on the entity template. Settlements and characters are data; policies are behavior; mixing them confuses both. A single character may have multiple policies attached (Survival, Combat, Travel, Forage), each with its own needs relevant to its domain.
Curve kinds
SECS provides four built-in curves. Each maps (current, target, threshold)
to [0, 1] urgency:
| Curve | Shape | Use case |
|---|---|---|
linear | Straight line from 0 (at target) to 1 (at threshold). | Even urgency rise (gold reserves, generic resources). |
quad | linear². Sharper at the threshold. | Scarcity-sensitive needs (food in famine). |
inverse_quad | 1 - (1 - linear)². Smoother far from threshold, sharper at it. | "Low urgency for high HP, very high urgency near death" — the canonical survival curve. |
sigmoid | Logistic, centered at the midpoint between threshold and target. | Step-like response (combat re-engagement gates). |
All four boundary values are clamped: current ≥ target → 0, current ≤ threshold → 1, in between the curve interpolates. Math lives in src/SECS.Engine/Policies/NeedCurves.cs.
Selectors and the utility-AI scorer
selector Name { consider activity X from <source>; }
What: A candidate-source declaration. Selectors enumerate the
activity pool for call_best to score and pick from. This contract uses
the following source kinds:
from all_registered— match the selector's boundActivityIdagainst the registry. Used when the selector targets one specific activity (rest, food, etc.).from tags = X, Y, Z— match every registered activity whoseTagscontains at least one of the listed tag ids. Used for tagged candidate pools (food-providing items, healing items, defense-building activities).
How the scorer picks
For each candidate the executor calls activity.Preview(context) to read
the candidate's EffectPlan, the unified preview surface.
Then for each need on the policy:
current = channel_resolve(actor, need.channel)
target = ResolveNeedTarget(need)
urgency = need.curve(current, target, need.threshold)
if candidate has no preview or preview is empty:
relevance = 0 // unrelated: no info
else:
scan preview for channel-targeting effects matching need.ChannelId
if no match:
relevance = 0 // unrelated: touches different channels
else:
project current through matching effects via ApplyOpToProjection
projectedUrgency = need.curve(projected, target, need.threshold)
gain = max(0, urgency - projectedUrgency)
if gain > 0:
relevance = gain // improving
else:
relevance = 0.25 * urgency // matched-but-no-gain
contribution = need.weight * relevance
totalScore += contribution
The candidate with the highest score wins. The relevance heuristic has three cases:
- Unrelated candidates score 0. An activity that does not touch a
need's channel contributes nothing to that need's score. That includes
both "no preview" and "preview but no matching effect" cases. Authors
who want an idle-fallback option to rank above unrelated candidates
must express that intent through an explicit preview row that touches
the relevant channel with
Add 0(matched-but-no-gain ranks above unrelated). - Matched-but-no-gain candidates score
0.25 × urgency × weight. These are activities whose preview touches the need's channel but does not move the urgency curve (e.g. a+5 HPheal where the actor is already above the threshold, or aModifyrow whoseModifierIdis zero). Useful for tie-breaking against unrelated, but ranks below any gain. - Improving candidates score
gain × weight. Bothgainand0.25 × urgencylive in the same numeric space ([0, 1]), so they compose cleanly under the weight without further normalisation.
The compiler/registry static-analysis pass emits SECS0415 when a
policy's selector can route to an activity that does not override
Preview() — under this scoring contract that activity ranks unrelated
against every need and is almost certainly an authoring oversight.
Hosts can attach a PreviewDriftRecorder to the
ActivityExecutor.PreviewRecorder property to capture per-run
preview-vs-actual channel deltas. The recorder is opt-in (null = telemetry
off, no overhead) and emits one PreviewDriftRecord per successful
run.
Why the inversion matters
Templates declare what they DO (effects extracted from Preview).
Policies declare what they WANT (needs). The executor multiplies them.
There are no per-template AI weights; the AI weight of any option is
computed from (effects × needs) at scoring time. This:
- Scales — adding a new template, activity, or recipe-spawned item requires zero AI-side changes; effects + needs evaluate them automatically.
- Composes with modifiers — a crafted potion with
+HealAmountmodifiers scores higher than a vanilla potion because thePreviewpulls the modifier-stacked value throughEffectPlan. - Allows central player tuning — the player adjusts a need's weight/threshold and every decision the policy makes shifts coherently.
Player-authored extensions
This section describes the slot contract for runtime rule, need, selector, and definition-reference edits.
Engine surface — ScopeSlotStore
The runtime stores per-actor slot lists in ScopeSlotStore keyed by
(EntityHandle scope, SlotKindId kind). The store is a required field
on TickContext.SlotStore so every tick path (system, event, activity
preview) can read or mutate slots without an extra dependency injection
hop. Each slot list is strongly typed at the API boundary:
store.Initialize<RuleDeclaration>(actor, PolicySlotKinds.Rules, defaultRules);
var rules = store.Read<RuleDeclaration>(actor, PolicySlotKinds.Rules);
store.Append(actor, PolicySlotKinds.Rules, newRule);
store.Replace(actor, PolicySlotKinds.Rules, index, replacement);
store.RemoveAt<RuleDeclaration>(actor, PolicySlotKinds.Rules, index);
Hard invariants — the store throws on uninitialized reads, double
initialization, and type mismatch. There is no _or_create fallback;
slots must be explicitly seeded at activation time. For Valenar's
character actor, Generated/Slots/CharacterSlots.cs is the lowering
target, with SeedDefaults invoked from MapGenerationSystem right
after the MainCharacter entity is created.
Runtime player rules — template<RuleSlot>
A policy defines compile-time RuleDeclaration defaults. The compiler
seeds them into the actor's PolicySlotKinds.Rules slot at activation
time; runtime evaluation reads from the slot.
Slots MUST be seeded at activation time. There is no fall-through to
the policy's compile-time defaults — a runtime read of an unseeded slot
throws InvalidOperationException ("Policy slot ... is not seeded for
actor ..."). Hosts call CharacterSlots.SeedDefaults (or the
equivalent host bootstrap for non-character actors) right after the
actor entity is activated. Tests bypass the host bootstrap by calling
TestSlotSeeder.SeedFromPolicy(tick.SlotStore, actor, policy) before
any rule walk or CallBest invocation; the helper lives under
tests/SECS.Engine.Tests/TestUtilities/.
// examples/valenar/Content/characters/slots.secs
template<RuleSlot> on Character
{
seed_from_policy CharacterSurvival.Rules;
}
Lowering target (examples/valenar/Generated/Slots/CharacterSlots.cs):
public static readonly SlotKindId Rules = new(H.Slot_PolicyRules);
public static void SeedDefaults(ScopeSlotStore store, EntityHandle actor, SecsPolicy policy)
{
store.Initialize<RuleDeclaration>(actor, Rules, policy.Rules);
store.Initialize<NeedDeclaration>(actor, Needs, policy.Needs);
store.Initialize<SelectorDeclaration>(actor, Selectors, policy.Selectors);
store.Initialize<TemplateId>(actor, KnownSpells, Array.Empty<TemplateId>());
}
The final KnownSpells line is a Valenar-specific example of a
game-owned definition-reference list; the generic slot model is simply
"typed per-actor lists."
PolicyExecutor.EvaluateAllRules calls PolicyExecutor.ResolveRules per
context. Edits to the slot (insert, replace, remove) take effect on the
next rule walk — no cache, no dirty bit, no synchronization step.
Runtime player needs — template<NeedSlot>
NeedDeclaration itself uses the slot model. The bare ulong TargetChannelId field is replaced with a NeedTargetExpr discriminated
union so designers and runtime authoring tools can pick between
literal, channel-resolved, and formula-driven satiation targets:
public sealed record NeedDeclaration(
NeedId Id,
string Name,
ulong ChannelId,
NeedTargetExpr Target,
NeedCurveKind Curve,
int Threshold,
int Weight);
public abstract record NeedTargetExpr
{
public sealed record Literal(int Value) : NeedTargetExpr;
public sealed record ChannelRef(ulong ChannelId) : NeedTargetExpr;
public sealed record Formula(ulong FormulaId) : NeedTargetExpr; // formula-backed target
}
Same slot pattern: PolicyExecutor.ResolveNeeds reads
PolicySlotKinds.Needs from the actor's slot store; an unseeded read
throws InvalidOperationException. Player-added needs participate
in scoring the same way authored needs do.
Definition-reference slots and collection-backed selectors
A policy may also read a typed per-actor list of definition references
for a content family. The generic rule is the same one used throughout
the behavior docs: content definition data stores row-specific knobs,
one generic activity implements the mechanic, and typed args carry the
chosen definition reference into each ActivityRequest.
The runtime bridge that turns those stored references into candidates is
part of the C# execution boundary, not standalone .secs vocabulary.
This doc stays at the design level; 10-host-secs-execution-boundary.md § 17.3-17.4 documents the current runtime types and registration path.
Valenar example: CharacterSlots.KnownSpells stores TemplateId
values pointing at SpellDefinition templates. The runtime bridge
packages each stored spell definition id into CastSpellArgs and emits
a generic Activity_CastSpell request. Adding a new spell definition is
therefore a data change, not a new activity declaration.
Predicted post-values — Set, Multiply, Modify
PolicyExecutor.CandidateRelevance now resolves the actor's current
value via ChannelResolver.ResolveIntFast and projects each effect:
PredictionOp | Projection |
|---|---|
Add | projected + Amount |
Set | Amount |
Multiply | projected * (Amount / 100f) (matches EffectPlanner's percent-scaled long encoding) |
Modify | projected + Amount * (stacks + 1) where stacks = resolver.BindingStore.CountBindings(effect.Target, effect.ModifierId); a row with ModifierId == 0 contributes nothing (the candidate still scores as "touches the channel") |
Effects compose left-to-right within the same EffectPlan, so an
Add +20 followed by Multiply 200% yields a projected value of 40 on
top of a current value of 0.
Save / load and migration
Save/load currently covers only partial policy and activity state. The future unified payload must preserve the distinction between host-owned world data and SECS-owned runtime records:
| State | Current status | Future target / gap |
|---|---|---|
| Settlement / character entity data | Host-owned entity store; not a SECS runtime payload. | Host world snapshot/container persists scope fields, collections, and entities before SECS runtime records are restored. |
| Policy entity (for policy-attached state) | Host-owned entity data if the game models policies as entities. | Same host world snapshot path as any other entity; not part of SecsSavePayload. |
Need tunable params (Need_*_Threshold, Need_*_Weight) | Host-owned channel backing / scope-field data when a game exposes tunables. | Persist through the host world snapshot; SECS validates definitions after load. |
| Player rule slots | Current ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyRules). | Included in future unified payload as slot snapshots. |
| Player need slots | Current ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyNeeds). | Included in future unified payload as slot snapshots. |
| Player selector slots | Current ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicySelectors). | Included in future unified payload as slot snapshots. |
| Valenar known-spell list | Current ScopeSlotStore row keyed by (EntityHandle actor, SlotKindId Slot_Character_KnownSpells), with TemplateId[] entries for SpellDefinition templates. | Included in future unified payload as slot snapshots. |
| Active activity runs | Current ActivityRun.ToState() produces ActivityRunState, including Origin; restore seeds ActivityExecutor before calling ActivityRunStore.Restore(activity, state, durationTicks). Mismatches throw SECS0810/SECS0811/SECS0812. | Included in future unified payload as activity-run records. |
| Slot contents (rules, needs, selectors, known-spells) | Current ScopeSlotStore.Snapshot() returns IReadOnlyList<SlotSnapshot>; Restore(snapshots) runs on a fresh store. Hard invalidation on type mismatch (SECS0821) or unknown slot kind / non-empty store (SECS0820). | Same data should be carried by the future unified payload without changing slot ownership. |
PolicyDispatcher.activeChildren | Current in-memory dictionary; not persisted. Child run tracking is lost on save/load. | Future PolicyChildEntry-style runtime record must restore (actor, policyId) -> ActivityRunId after activity runs are restored. |
Slot persistence is per-actor, per-slot-kind. The conceptual on-disk
shape of a single slot row is { EntityHandle actor, SlotKindId kind, T[] entries } where T is the slot's payload type (e.g.
RuleDeclaration, NeedDeclaration, SelectorDeclaration,
TemplateId).
Restore rehydrates each row by invoking
store.Initialize<T>(actor, kind, entries) on a fresh
ScopeSlotStore. There is no ScopedList<RuleSlot> collection on the
policy entity — slot data hangs off the actor, never off the policy.
When the game updates and policy/activity/need definitions change between the saved game's version and the current build:
| Change | Migration policy |
|---|---|
| Compile-time rule added | Enabled by default; no save-side action |
| Compile-time rule removed | Save's enabled flag for that rule dropped (no-op) |
| Compile-time need added | Tunable params get default values |
| Compile-time need removed | Tunable params dropped from save |
RuleSlot's TargetActivityId references a removed activity | Slot disabled, UI surfaces warning, slot retained for player to fix |
NeedSlot's ChannelId references a removed channel | Same — disabled with warning |
| Policy itself removed | Active policy run cancelled, retained slots discarded |
| Activity signature changed (e.g. now requires a target it didn't before) | In-flight ActivityRun invalidated, parent policy resumes from the rule that dispatched it |
End-to-end example
A character survival policy demonstrating the complete flow.
1. Authored definitions (compile time)
// examples/valenar/Content/policies/character_survival.secs
policy CharacterSurvival
{
actor Character;
domain Survival;
need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}
need KeepFed
{
channel = Stamina_Current;
target = Stamina_Max;
curve = inverse_quad;
threshold = 30;
weight = 60;
}
selector RestSelector
{
consider activity RestAtCamp from all_registered;
}
rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}
}
2. Game runtime — host evaluates the policy
GameRuntime.Create
registers SecsModule.Policies, including CharacterSurvivalPolicy.Instance.
Each tick (or on-demand from a system / event):
policy = registry.GetPolicy(new PolicyId(H.Policy_CharacterSurvival))
foreach (ruleId, decision) in executor.EvaluateAllRules(policy, character, tick):
switch (decision):
case CallBest(selectorId):
best = executor.CallBest(policy, selectorId, ctx)
if (best != null)
request = best.Request with { Origin = ActivityRequestOrigin.Policy }
activityExecutor.Start(request, tick)
break
case Call(activityId, target, args):
activityExecutor.Start(
new ActivityRequest(activityId, character, target, args, ActivityRequestOrigin.Policy),
tick)
break
case Continue: continue
case Complete: stop walking, mark domain done
case Fail: stop walking, mark domain failed
case Wait: stop walking, leave child alive
3. Player edits the policy at runtime
The runtime path looks like:
At MainCharacter activation (MapGenerationSystem):
CharacterSlots.SeedDefaults(ctx.SlotStore, character, CharacterSurvivalPolicy.Instance);
→ store seeded with policy.Rules, policy.Needs, policy.Selectors, empty Valenar KnownSpells list
At runtime (player edits):
ctx.SlotStore.Replace<NeedDeclaration>(character, PolicySlotKinds.Needs, index, edited);
→ next PolicyExecutor.ScoreCandidates pass reads the edited need
At runtime (Valenar spell example):
ctx.SlotStore.Append<TemplateId>(character, CharacterSlots.KnownSpells, new TemplateId(H.Spell_TestSpell));
→ Valenar's collection-backed selector now sees one additional spell
definition reference and can produce a CastSpell candidate carrying
that template id in typed args.
The client-facing wire surface is a read/edit API on top of
ScopeSlotStore. policy.catalog describes the authored policy
defaults; per-actor edits are represented as slot read/write operations
and surfaced by the policy-edit panel.
Execution notes
- Multi-policy-per-actor concurrency. One active policy run is allowed per domain at a time for a given actor. Re-entrancy is defined per policy kind.
- Sub-call failure semantics.
PolicyDispatcher(src/SECS.Engine/Policies/PolicyDispatcher.cs) is the reference loop. WhenRuleDecision.Call(...)'s underlyingActivityExecutor.Startreturns null (CanStart=false, lane occupied, costs unaffordable, etc.) the dispatcher returnsPolicyDispatchResult.Fail. WhenRuleDecision.CallBest(...)produces no candidate, the dispatcher returnsPolicyDispatchResult.Fail.Callcreates a policy-originActivityRequest, andCallBestnormalizes the selected candidate toActivityRequestOrigin.Policybefore start so the saved run records the policy dispatch boundary as provenance. See § Policy dispatch loop and04-behavior.md § Policy dispatch loopfor the full decision table. - Rule ordering. Rules are walked in declaration order; the first
non-
Continuedecision wins. Utility scoring ranks selector candidates, not rules. - Need curves. The built-in curve set is
Linear,Quad,InverseQuad, andSigmoid.NeedCurveKindremains extensible. - Predicted-change evaluation. Candidate scoring resolves the
actor's current value via
ChannelResolver.ResolveIntFastat scoring time and projects each effect left-to-right.Add,Set,Multiply, andModifyuse the projection rules defined in § Predicted post-values. - Preview source of truth.
EffectPlanis the unified preview surface consumed by the AI. There is no separate analyzer-owned preview path. - Save-versioning and player commands. Engine-wide migration
callbacks and player-facing commands such as
Command("policy.set_channel", ...),Command("policy.add_rule_slot", ...), andCommand("scope.set_field", ...)sit above this policy contract and must preserve the slot semantics defined here.
Static analysis (warn-only)
SecsRegistry.Validate() walks every registered policy and reports patterns that almost never reflect designer intent. The pass never throws — every diagnostic carries Severity.Warning, surfaced through RegistryDiagnostics.Diagnostics.
| Code | What fires it | Why warn-only |
|---|---|---|
SECS0910 | policy need references an unknown channel id (SecsRegistry.ChannelExists returns false) | a need targeting an unregistered channel will silently score zero forever; usually a typo or a forgotten RegisterChannels call |
SECS0911 | policy selector references an activity id with no registered activity | the selector returns no candidates at scoring time; load order may legitimately register activities after policies so this is warn-only |
SECS0912 | two needs over the same channel have weights of opposite sign | the contributions cancel at scoring time; same-sign duplicates are allowed (multiple urgency curves on one channel is a legitimate design) |
SECS0913 | SelectorSource.FromTags references an unknown tag | the selector enumerates an empty tag set forever |
SECS0914 | policy actor scope id has no registered scope declaration | mod patches can rewrite ActorScopeId post-finalize and bypass the per-policy hard-check |
Implementation lives at src/SECS.Engine/Diagnostics/PolicyStaticAnalysis.cs. The pass cross-references activity ids registered through RegisterActivity (so the order of registration matters: register activities before policies that point at them, or accept the warning as a known-late-binding signal).
Relationship to existing SECS docs
04-behavior.md § Policiescovers the lowering contract for thepolicykeyword — declaration shell, generatedSecsPolicyshape,PolicyExecutorruntime API, registration throughSecsModule. This doc covers the AI scoring math and philosophy; the lowering doc covers the C# emission contract.03-channels-and-modifiers.mddefines the modifier stack. Need tunable parameters are channels and participate in the modifier pipeline — a status effect can stack a modifier onto a need's weight to shift the policy's priorities globally.06-overrides-and-modding.md § 8.10–8.11defines the activity / policy slot schema. Activity architectural slots (activity_actor_scope,activity_target_scope,activity_lane,activity_args_schema) and policy architectural slots (policy_actor_scope,policy_domain) are replace-only. Inject may patch ordinary activity fields such as duration, cooldown, metadata, tags/cost lists, lifecycle bodies, and the preview body; policy inject may patch or append needs, selectors, rules, and rule evaluate bodies by child id. Slot identity follows the structuredSlotKey(DeclarationKind, DeclarationId, SlotKind, ChildId?)shape from § 7.3 of that document.08-collections-and-propagation.mddefinesScopedList<T>, used for player-authoredPlayerRulesandPlayerNeeds.
Scope boundary
- Specific game-side AI content stays game-owned. Combat policy, settlement governance, forage/rest behavior, and dungeon orchestration are Valenar or per-game authoring decisions, not engine surface.
- Multi-actor coordination is out of scope for this contract. The current policy model is single-actor; squad plans or cross-actor goal sharing would require a separate primitive.
- Long-horizon planning is also out of scope. The selector scores immediate options; GOAP-style multi-step planning would sit above this layer.