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).PolicyDispatcherV2(src/SECS.Runtime/Policies/PolicyDispatcherV2.cs) — the engine runtime that walks each policy's rule rows in ascendingOrderper actor and dispatches. The dispatcher processes all seven decision kinds (Continue,Complete,Fail,Call,Wait,CallBest,CancelChild); only an enum value outside the committed list throws (InvalidOperationException).CallBestscoring is opt-in wiring: the dispatcher is constructed with aNeedCurveRegistryand aSelectorResolver(both or neither), and aCallBestreached without that wiring fails loud. The utility-AI scorer (selectors, needs, response curves, deterministic candidate arbitration) ships undersrc/SECS.Runtime/Policies/Scoring/.
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. v1 had partial store-level
primitives only. v2 implements entities, relations, modifier
bindings, saved scopes, PRNG, activity runs, and pending choices via
SecsSavePayload(docs/runtime/rn-save-load-determinism.md). Per-actor mutable policy slot contents (rule / need / selector / known-spell lists) persist throughSecsRuntimeSaveSection.PolicySlots(PolicySlotSaveRow), and policy-child linkage (PolicyDispatcherV2's active-children map) persists throughSecsRuntimeSaveSection.PolicyActiveChildren(PolicyActiveChildSaveRow).
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:
// Registered via SecsRuntimeBuilder.WithRuleHandler(ruleId, handler).
// Handler signature: RuleDecision Handler(in RuleEvaluationContext context).
private static RuleDecision EvaluateEmergencyRest(in RuleEvaluationContext context)
{
var hp = context.ChannelResolver.Resolve(context.Actor, new ChannelId(H.HP_Current));
var hpMax = context.ChannelResolver.Resolve(context.Actor, new ChannelId(H.HP_Max));
if (hpMax > 0 && hp * 4 < hpMax)
// call_best lowers to RuleDecisionKind.CallBest via the
// RuleDecision.CallBest(selectorId, args) factory. The dispatcher
// resolves the selector's candidates, scores them against the
// policy's needs, and starts the argmax winner through the same
// path as a literal call.
return RuleDecision.CallBest(new SelectorId(H.Selector_RestSelector));
return RuleDecision.Continue;
}
ChannelResolverV2.Resolve(EntityHandle, ChannelId) returns double; there
is no ResolveIntFast in v2. The dispatcher walks each registered rule
handler through RuleHandlerRegistry.Invoke(ruleId, in context) in
ascending rule Order; Complete, Fail, and Wait terminate the walk,
while Call, CallBest, and CancelChild take effect and advance to the
next rule. The per-rule EvaluateRule(RuleId, …) switch is the v1 dispatch
shape.
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
In v2 RuleDecision is a single value-typed readonly record struct with a
RuleDecisionKind discriminator and payload fields, not an abstract record
with sealed sub-records (the v1 shape). RuleDecisionKind
(src/SECS.Runtime/Policies/RuleDecisionKind.cs) is Continue=0,
Complete=1, Fail=2, Call=3, Wait=4, CallBest=5, CancelChild=6:
public readonly record struct RuleDecision(
RuleDecisionKind Kind,
ActivityId ActivityId,
EntityHandle Target,
SelectorId SelectorId,
ActivityArgsBlob Args)
{
public static readonly RuleDecision Continue = new(RuleDecisionKind.Continue, ...);
public static readonly RuleDecision Complete = new(RuleDecisionKind.Complete, ...);
public static readonly RuleDecision Fail = new(RuleDecisionKind.Fail, ...);
public static readonly RuleDecision Wait = new(RuleDecisionKind.Wait, ...);
public static readonly RuleDecision CancelChild = new(RuleDecisionKind.CancelChild, ...);
public static RuleDecision Call(ActivityId activityId, EntityHandle target, ActivityArgsBlob args = default);
public static RuleDecision CallBest(SelectorId selectorId = default, ActivityArgsBlob args = default);
}
| Case | Meaning | v2 status |
|---|---|---|
Continue | Rule had nothing to say; move to the next rule. | Built. |
Complete | Mark the policy run successful; no further rules this tick. | Built. |
Fail | Mark the policy run failed; no further rules this tick. | Built. |
Wait | Park this tick; keep any active child intact. | Built — the walk stops and returns PolicyDispatchResult.Wait; the next tick re-evaluates fresh. |
Call(activity, target, args) | Dispatch a specific activity directly. | Built. |
CallBest(selector) | Dispatch the highest-scoring candidate from the named selector (SelectorId.None scores the union of the policy's selectors). | Built — requires the dispatcher's opt-in scorer wiring (NeedCurveRegistry + SelectorResolver); a CallBest without that wiring fails loud. |
CancelChild | Cancel the actor's current child run before re-evaluating. | Built — cancels the tracked child through the executor (firing OnCancel), then advances. |
The no-payload cases are static readonly singleton values
(RuleDecision.Continue, .Complete, .Fail, .Wait, .CancelChild)
so trivial branches ("nothing to say this tick") do not allocate. Call
carries its activity / target / args payload through the struct's fields;
CallBest carries a selector id plus args, and the scorer chooses the
activity.
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). The
need is read by the utility-AI scorer (NeedScorer,
src/SECS.Runtime/Policies/Scoring/NeedScorer.cs) when evaluating
call_best: each need is scored as one consideration — the current and
target channel values are read for the actor, the current value is
normalized into the bookended [Threshold, target] range, and the
normalized input runs through the need's registered response curve. The
v2 NeedRow carries only declaration metadata; the curve binds at
runtime per NeedId through NeedCurveRegistry. The shipped IAUS
aggregation is multiplicative with a compensation factor and does not yet
apply Weight.
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:
// Needs.Build() returns NeedRow[] assembled into WorldSchema by
// SecsModule.BuildSchema(). NeedRow is a flat positional record struct;
// the curve is NOT a row field — it maps from NeedId through a runtime
// registry (mirroring the formula / event-option resolver pattern).
public static NeedRow[] Build() => new[]
{
new NeedRow(
Id: new NeedId(H.NeedCharacterSurvivalStayAlive),
OwnerPolicyId: new PolicyId(H.PolicyCharacterSurvival),
ChannelId: new ChannelId(H.HP_Current),
TargetChannelId:new ChannelId(H.HP_Max),
Threshold: 50,
Weight: 100),
new NeedRow(
Id: new NeedId(H.NeedCharacterSurvivalKeepFed),
OwnerPolicyId: new PolicyId(H.PolicyCharacterSurvival),
ChannelId: new ChannelId(H.Stamina_Current),
TargetChannelId:new ChannelId(H.Stamina_Max),
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 NeedRow (src/SECS.Schema/Rows/NeedRow.cs) is a flat
positional record struct carrying only declaration metadata. The curve
kind lives outside the row in a runtime registry, mirroring the
formula-resolver pattern.
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). |
The shipped scorer clamps both the normalized input and the curve output to [0, 1]. Curve evaluation is runtime-backed: INeedCurve (src/SECS.Runtime/Policies/Scoring/INeedCurve.cs) is the curve contract, six implementations live under src/SECS.Runtime/Policies/Scoring/Curves/ — LinearCurve, PolynomialCurve (integer exponent by repeated multiply; expresses the quad / inverse_quad shapes), StepCurve, and PiecewiseLinearCurve are exact-float, while LogisticCurve (the sigmoid shape) and LogitCurve route through transcendental functions and report IsDeterministicExact = false — and NeedCurveRegistry binds each NeedId to its curve, mirroring the formula-resolver pattern. NeedRow (src/SECS.Schema/Rows/NeedRow.cs) deliberately omits the curve: it is a runtime responsibility, not a schema column. The registry's default Register admits exact-float curves only; a determinism-hazard curve must be opted in explicitly through RegisterHazard, and ValidateCoverage cross-checks every declared need at boot.
Selectors and the utility-AI scorer
The utility-AI scorer ships under src/SECS.Runtime/Policies/Scoring/.
PolicyDispatcherV2 resolves and scores CallBest when constructed with
its opt-in scorer wiring (NeedCurveRegistry + SelectorResolver, both
or neither); SecsRuntimeBuilder passes both once the host registers at
least one INeedCurve through WithNeedCurve. SelectorSourceKind
(src/SECS.Schema/Enums/SelectorSourceKind.cs) declares FromActor,
AllRegistered, FromTags, and FromCollection. SelectorResolver
(src/SECS.Runtime/Policies/Scoring/SelectorResolver.cs) resolves
FromActor and AllRegistered directly and routes FromTags /
FromCollection through the CandidateBuilderRegistry (host-registered
via SecsRuntimeBuilder.WithCandidateBuilder), where an unregistered kind
fails loud rather than yielding a silent empty set; the dispatcher's
per-policy authorization currently rejects FromTags / FromCollection
selectors loud until a candidate-builder path is wired into it. The
shipped scoring core is IAUS (NeedScorer + CandidateArbiter): per-need
normalize-and-curve considerations aggregated multiplicatively with the
compensation factor, then a deterministic argmax with content-key
tie-break and a zero score floor. The preview-projection scoring described
in "How the scorer picks" below (preview reads, relevance, weighted
contributions) is a design target with no runtime backing — read that
pseudocode as specification, not as a description of shipped behavior.
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 is intended to emit
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.
Not built in v2: there is no policy/selector static-analysis pass in
src/.
As a design target, hosts attach a PreviewDriftRecorder to
ActivityExecutorV2 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. Not built in v2:
ActivityExecutorV2 (src/SECS.Runtime/Activities/ActivityExecutorV2.cs)
exposes no preview-recorder surface yet, and there is no EffectPlan
preview path in src/.
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
(src/SECS.Runtime/Policies/ScopeSlotStore.cs) keyed by
(EntityHandle actor, SlotKindId kind). The store is owned by
PolicyDispatcherV2 and exposed through its Slots property; in v2 the
slot list holds typed-id wrappers (RuleId, NeedId, SelectorId,
TemplateId) — there is no managed RuleDeclaration payload in the slot.
Each slot list is strongly typed at the API boundary, with the
SlotKindId passed directly (there is no PolicySlotKinds accessor class
in v2):
store.Initialize<RuleId>(actor, rulesKind, defaultRuleIds);
var ruleIds = store.Read<RuleId>(actor, rulesKind);
store.Append(actor, rulesKind, newRuleId);
store.Replace(actor, rulesKind, index, replacementRuleId);
store.RemoveAt<RuleId>(actor, rulesKind, index);
Hard invariants — the store throws on uninitialized reads (mutations
require a prior Initialize), double initialization, type mismatch, and
SlotKindId.None. There is no _or_create fallback; slots must be
explicitly seeded at activation time. Not built in v2: no Valenar
character-slot lowering exists yet under
examples/valenar-v2/Generated/obj/**/SecsGenerated; a SeedDefaults host hook
is the design target.
Runtime player rules — template<RuleSlot>
A policy defines compile-time rule defaults (RuleRow ids in declaration
order). The host seeds the matching RuleIds into the actor's rules slot
at activation time; runtime evaluation reads the RuleId list 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 (mutations require a prior Initialize). Hosts call a
SeedDefaults host hook (or the equivalent host bootstrap for
non-character actors) right after the actor entity is activated. Not
built in v2: the test seeding helper and the seed_from_policy slot
lowering are design targets; no such helper exists in tests/.
// examples/valenar-v2/Content/characters/slots.secs
template<RuleSlot> on Character
{
seed_from_policy CharacterSurvival.Rules;
}
Design-target lowering (future compiler-owned
examples/valenar-v2/Generated/obj/**/SecsGenerated/CharacterSlots.g.cs, not
yet emitted). The slot holds typed-id wrappers, not managed
declaration objects — seed the policy's RuleId / NeedId / SelectorId
lists:
public static readonly SlotKindId Rules = new(H.Slot_PolicyRules);
public static void SeedDefaults(ScopeSlotStore store, EntityHandle actor, PolicyRow policy)
{
store.Initialize<RuleId>(actor, Rules, policy.RuleIds);
store.Initialize<NeedId>(actor, Needs, policy.NeedIds);
store.Initialize<SelectorId>(actor, Selectors, policy.SelectorIds);
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."
The live PolicyDispatcherV2.EvaluateTick walks the policy's declared
rule rows in ascending Order; routing the walk through each actor's
seeded rule slots is the design target (the store is owned by the
dispatcher and exposed through Slots, and rule handlers can read it
through RuleEvaluationContext, but the rule walk itself does not read
it yet). Under the slot-driven design target, 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>
The need slot stores NeedIds; the design target enriches the underlying
need declaration so the bare target-channel field is replaced with a
NeedTargetExpr discriminated union, letting designers and runtime
authoring tools pick between literal, channel-resolved, and formula-driven
satiation targets. Not built in v2: the committed v2 row is
NeedRow (src/SECS.Schema/Rows/NeedRow.cs) with fields
(NeedId Id, PolicyId OwnerPolicyId, ChannelId ChannelId, ChannelId TargetChannelId, double Threshold, double Weight) — a single
channel-ref target, no NeedTargetExpr, no curve field, no name. The
enriched form below is the design target:
// DESIGN TARGET — v2 ships the flat NeedRow above; this enriched
// declaration is not yet backed.
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: the design-target scorer reads the need slot from the
actor's slot store; an unseeded read throws. Not built in v2: the
shipped CallBest scorer reads the policy's declared NeedRow range
from the schema, not the actor's need slot, so player-added needs do not
yet participate in scoring.
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
The design-target candidate-relevance pass resolves the actor's current
value via ChannelResolverV2.Resolve(EntityHandle, ChannelId) (which
returns double; there is no ResolveIntFast in v2) and projects each
effect. Not built in v2: no effect-projection pass exists in src/;
the shipped NeedScorer reads channel values but does not project
candidate effects.
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
v2 ships a unified SecsSavePayload
(docs/runtime/rn-save-load-determinism.md)
composed of a KernelSaveSection (entities, relations, modifier
bindings, saved scopes, pending journal entries) and a
SecsRuntimeSaveSection (PRNG, current tick, cumulative counters,
pending choices, active activity runs, policy active children, and
per-actor mutable policy slot contents). Per-actor mutable policy slot
contents persist through SecsRuntimeSaveSection.PolicySlots
(PolicySlotSaveRow rows produced by ScopeSlotStore.SnapshotAll and
rehydrated by ScopeSlotStore.RestoreFrom), and
PolicyDispatcherV2's active-children map persists through
SecsRuntimeSaveSection.PolicyActiveChildren (PolicyActiveChildSaveRow
rows produced by SnapshotActiveChildren and rehydrated by
RestoreActiveChildren). The distinction between host-owned world data
and SECS-owned runtime records is preserved:
| State | Current status | Future target / gap |
|---|---|---|
| Settlement / character entity data | v1: Host-owned entity store; not a SECS runtime payload. v2: Same — entity identity flows through KernelSaveSection.Entities (EntitySaveRow), but scope-field host data lives in the host's world container. | Host world snapshot/container persists scope fields, collections, and entities before SECS runtime records are restored. |
| Policy entity (for policy-attached state) | v1/v2: 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) | v1/v2: 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 | v1: ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyRules). v2 implemented: persisted via ScopeSlotStore.SnapshotAll → PolicySlotSaveRow rows in SecsRuntimeSaveSection.PolicySlots; RestoreFrom rehydrates on a fresh store. RuleId entries widen through the SlotElementTypeTag.UInt64 codec branch. Distinct from KernelSaveSection.SavedScopes (blittable saved-scope frames), which encode a different data class. | None — covered by SecsRuntimeSaveSection.PolicySlots in v2. |
| Player need slots | v1: ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyNeeds). v2 implemented: persisted via the same PolicySlotSaveRow path; NeedId entries widen through the SlotElementTypeTag.UInt64 codec branch. | None — covered by SecsRuntimeSaveSection.PolicySlots in v2. |
| Player selector slots | v1: ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicySelectors). v2 implemented: persisted via the same PolicySlotSaveRow path; SelectorId entries widen through the SlotElementTypeTag.UInt64 codec branch. | None — covered by SecsRuntimeSaveSection.PolicySlots in v2. |
| Valenar known-spell list | v1: ScopeSlotStore row keyed by (EntityHandle actor, SlotKindId Slot_Character_KnownSpells), with TemplateId[] entries for SpellDefinition templates. v2 implemented: persisted via the same PolicySlotSaveRow path; TemplateId entries widen through the SlotElementTypeTag.UInt64 codec branch. | None — covered by SecsRuntimeSaveSection.PolicySlots in v2. |
| Active activity runs | v1: ActivityRun.ToState() produces ActivityRunState; restore seeds ActivityExecutor before calling ActivityRunStore.Restore(activity, state, durationTicks). Mismatches throw SECS0810/SECS0811/SECS0812. v2 implemented: SecsRuntimeSaveSection.ActiveRuns carries ActivityRunRecord (Id, ActivityId, Actor, Target, StartedAtTick, CompletedAtTick, Args) at src/SECS.Runtime/Activities/ActivityRunRecord.cs:9; Args is an ActivityArgsBlob with SecsTypeRef plus canonical structured payload bytes. | None — covered by SecsRuntimeSaveSection.ActiveRuns in v2. |
| Slot contents (rules, needs, selectors, known-spells) | v1: ScopeSlotStore.Snapshot() returns IReadOnlyList<SlotSnapshot> with Type ElementType and managed IReadOnlyList<object> payload; Restore(snapshots) runs on a fresh store. Hard invalidation on type mismatch (SECS0821) or unknown slot kind / non-empty store (SECS0820). v2 implemented: ScopeSlotStore.SnapshotAll emits deterministic PolicySlotSaveRow rows (ascending EntityHandle, then SlotKindId) into SecsRuntimeSaveSection.PolicySlots; RestoreFrom rehydrates them on a fresh store with hard invariants (non-empty store, null actor, SlotKindId.None, unknown SlotElementTypeTag, and duplicate (actor, kind) pairs all throw). The element codec is a closed blittable set (SlotElementTypeTag: UInt64 / Int64 / Int32); the live rule / need / selector / known-spell slots all hold typed-id wrappers and persist through the UInt64 branch. | Remaining limit (not a gap in the live slots): a slot opened with an element type outside the closed SlotElementTypeTag set throws at SlotList<T>.Snapshot rather than persisting silently. Widening the codec to managed declaration objects is the documented forward-compatible move on SlotElementTypeTag. |
PolicyDispatcherV2.activeChildren | v1: PolicyDispatcher.activeChildren in-memory dictionary; not persisted. Child run tracking is lost on save/load. v2 implemented: PolicyDispatcherV2 holds a live (PolicyId, EntityHandle) → ActivityRunId active-children map; SnapshotActiveChildren emits PolicyActiveChildSaveRow rows into SecsRuntimeSaveSection.PolicyActiveChildren and RestoreActiveChildren rehydrates them, cross-checking each row's ActivityRunId against the restored active-run list and rejecting stale entries loud. Restore runs after ActiveRuns. | None — covered by SecsRuntimeSaveSection.PolicyActiveChildren in v2. |
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 migration |
| 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-v2/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
v2: policies are declared via WorldSchema rows consumed by SecsRuntimeBuilder
(per docs/runtime/rn-external-engine-adapters.md § Boot Contract); the
tick pipeline runs a PolicyDispatch step every tick.
PolicyDispatcherV2.EvaluateTick(tick):
for each actor whose contract root scope matches PolicyRow.ActorScopeId:
walk the policy's rule rows in ascending Order:
decision = ruleHandlers.Invoke(ruleId, in RuleEvaluationContext)
switch (decision.Kind):
case Continue: move to the next rule
case Complete: stop walking, return PolicyDispatchResult.Complete
case Fail: stop walking, return PolicyDispatchResult.Fail
case Call: ActivityExecutorV2.Start(request, tick) // suppressed
if the same lane already has an active child
case Wait: stop walking, return PolicyDispatchResult.Wait
(start nothing; leave the active child untouched)
case CallBest: resolve selector candidates (SelectorResolver),
score each against the policy's needs (NeedScorer),
start the argmax winner through the Call path
case CancelChild:cancel the tracked child run (firing OnCancel),
then advance to the next rule
The call_best(RestSelector) rule in the authored example above lowers to
a CallBest decision that PolicyDispatcherV2 resolves through
SelectorResolver, scores through NeedScorer, and dispatches through
the same start path as a literal call.
3. Player edits the policy at runtime
The runtime path looks like:
The slot store is owned by PolicyDispatcherV2 (its Slots property); the
slot holds typed-id wrappers, not managed declaration objects:
At MainCharacter activation (design-target host hook):
CharacterSlots.SeedDefaults(dispatcher.Slots, character, policyRow);
→ store seeded with policy.RuleIds, policy.NeedIds, policy.SelectorIds,
empty Valenar KnownSpells list
At runtime (player edits):
dispatcher.Slots.Replace<NeedId>(character, needsKind, index, editedNeedId);
→ the design-target scorer would read the edited need on its next pass
(the shipped CallBest scorer reads the policy's declared NeedRow range
from the schema; per-actor need slots are not yet routed into scoring)
At runtime (Valenar spell example):
dispatcher.Slots.Append<TemplateId>(character, knownSpellsKind, new TemplateId(H.Spell_TestSpell));
→ the design-target collection-backed selector would then see one
additional spell definition reference and produce a CastSpell candidate
carrying that template id in typed args (selector source FromCollection
routes through the CandidateBuilderRegistry in SelectorResolver, but
the dispatcher's per-policy authorization does not yet accept
FromTags / FromCollection selectors, and no shipping policy drives
those kinds).
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.
PolicyDispatcherV2(src/SECS.Runtime/Policies/PolicyDispatcherV2.cs) is the reference loop. WhenRuleDecision.Call(...)'s underlyingActivityExecutorV2.Startcannot start the activity (the same lane already has an active child) the dispatcher returnsPolicyDispatchResult.Fail.Callcreates a policy-originActivityRequest.CallBestresolves candidates, scores them, and dispatches the argmax winner through the same start path as a literalCall, inheriting the same double-start suppression, lane-occupancy, and active-child tracking semantics. See § Policy dispatch loop and04-behavior.md § Policy dispatch loopfor the full decision table. - Rule ordering. Rules are walked in ascending
Order;Complete,Fail, andWaitterminate the walk, whileCall/CallBest/CancelChildtake effect and advance (PolicyDispatcherV2implements this). The utility scoring ranks selector candidates, not rules. - Need curves. The runtime curve surface is
INeedCurvewith six implementations undersrc/SECS.Runtime/Policies/Scoring/Curves/(LinearCurve,PolynomialCurve,StepCurve,PiecewiseLinearCurveexact-float;LogisticCurve,LogitCurvedeterminism-hazard opt-in), bound perNeedIdthroughNeedCurveRegistry.NeedRowomits the curve by design (see § Curve kinds). - Predicted-change evaluation. The design-target candidate scoring
resolves the actor's current value via
ChannelResolverV2.Resolve(EntityHandle, ChannelId)(returnsdouble; noResolveIntFast) and projects each effect left-to-right.Add,Set,Multiply, andModifyuse the projection rules defined in § Predicted post-values. Not built in v2: no effect-projection pass exists insrc/; the shipped scorer evaluates needs without projecting candidate effects. - Preview source of truth.
EffectPlanis the design-target unified preview surface for the AI; there is no separate analyzer-owned preview path. Not built in v2: noEffectPlanpreview path exists insrc/. - 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)
The design target is a warn-only pass that walks every declared policy and
reports patterns that almost never reflect designer intent — never throwing,
every diagnostic carrying Severity.Warning. Not built in v2: there is
no warn-only policy static-analysis pass in src/. The v2 validation entry
is WorldSchema.Validate() (called by SecsRuntimeBuilder at construction,
src/SECS.Schema/WorldSchema.cs), which performs hard FK validation that
throws — it is not the warn-only diagnostic surface below. The diagnostics
codes here are the design target — the reserved SECS0910-SECS0914 band in
SECS-Compiler-Plan.md § Diagnostic Code Catalog, with no shipped owner:
| Code | What fires it | Why warn-only |
|---|---|---|
SECS0910 | policy need references an unknown channel id | a need targeting an unregistered channel will silently score zero forever; usually a typo or a forgotten channel declaration |
SECS0911 | policy selector references an activity id with no declared activity | the selector returns no candidates at scoring time; load order may legitimately declare 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 | SelectorSourceKind.FromTags references an unknown tag | the selector enumerates an empty tag set forever |
SECS0914 | policy actor scope id has no declared scope | mod patches can rewrite ActorScopeId and bypass the per-policy hard-check |
The design-target pass cross-references declared activity ids (so declaration order matters: declare 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, generatedPolicyRowshape,PolicyDispatcherV2runtime API, declaration throughSecsModule.BuildSchema(). 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.8–8.9defines the activity / policy slot schema. Activity architectural slots (activity_actor_scope,activity_target_scope,activity_lane,activity_args_type_ref) 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.