You are viewing a potentially older version of this package. View all versions.
CeruleanCutlass-StoryKit-0.1.12 icon

StoryKit

Add an NPC to Outward as plain data: StoryKit builds the character, spawns it at a fixed spot, wires its dialogue, and compiles a trainer skill tree that sells from the vanilla Trainer UI. Requires SideLoader at runtime. A library dependency.

Date uploaded 6 days ago
Version 0.1.12
Download link CeruleanCutlass-StoryKit-0.1.12.zip
Downloads 89
Dependency string CeruleanCutlass-StoryKit-0.1.12

This mod requires the following mods to function

BepInEx-BepInExPack_Outward-5.4.19 icon
BepInEx-BepInExPack_Outward

BepInEx pack for Outward.

Preferred version: 5.4.19
sinai-dev-SideLoader-3.8.4 icon
sinai-dev-SideLoader

API and Mod Development Toolkit for Outward.

Preferred version: 3.8.4
CeruleanCutlass-ForgeKit-0.4.13 icon
CeruleanCutlass-ForgeKit

Dependency-free dev-tooling for Outward BepInEx mods: file-driven dev command loop, self-test harness, on-screen toasts, player-ready lifecycle wait, embedded/override table loaders, and a shared dev-verb pack (movement/combat/skill/status probes).

Preferred version: 0.4.13
CeruleanCutlass-NetKit-0.2.10 icon
CeruleanCutlass-NetKit

Shared Photon co-op transport layer for Outward BepInEx mods: channels over one relay, a hello/peer ledger, per-channel counters/heartbeat, PUN diagnostics, and a replicated-record store for consumer state mirroring.

Preferred version: 0.2.10

README

StoryKit β€” add an NPC, trainer, and skill tree

πŸ“– Full documentation: StoryKit wiki page

A BepInEx library plugin for Outward (Definitive Edition, Mono branch) that lets a mod add an NPC: a standing character in the world with a dialogue graph and, if it's a trainer, a full in-game skill tree the vanilla Trainer window sells from. A consuming mod describes the NPC as plain data; StoryKit builds the character, spawns it at a fixed spot, wires its conversation, and compiles its skill tree β€” no custom UI, no NodeCanvas hand-editing.

StoryKit covers NPCs, trainers, and dialogue. A quest/story-event engine is not part of the kit β€” there is no quest-authoring API. (It does ship read-only recon tooling over the vanilla QuestEventManager β€” the qevent* dev verbs below β€” but that is diagnostics, not an authoring surface, and it's off unless [Recon] EnableStoryRecon is turned on.)

Requires: BepInEx 5 (Outward's Mono branch β€” see Compatibility), ForgeKit, and SideLoader present at runtime.

Installing (for players)

You don't install or interact with StoryKit directly. It arrives as a dependency of a mod that adds an NPC β€” for example Beastwhispering, whose animal-taming trainer is a StoryKit NPC that sells the mod's pet-skill tree. The NPC and its dialogue are what you actually meet in-game; StoryKit is the plumbing behind it. A mod manager installs it automatically alongside whatever mod declares it as a dependency.

Consuming the kit

A consumer calls NpcRegistry.Register(NpcSpec) in Awake, describing the NPC's placement, appearance, dialogue tree, and (if it's a trainer) the skills it sells. NpcDirector spawns the registered NPC at player-ready on every scene load (built to be master-only in co-op and duplicate-safe β€” that guard has not been live-verified on a two-machine session), and DialogueBuilder compiles the described conversation into a real NodeCanvas graph reusing SideLoader's own trainer dialogue nodes. See the wiki page for the full NpcSpec/SkillTreeDef shape and worked examples.

Mobile, combat-capable and merchant NPCs

Since 2026-08-22 a spec can also describe a walking NPC with an AI, combat stats, a faction, a backpack and a vanilla shop (the road-merchant wave; first consumer: DangerousRoads). All of it is opt-in on the same NpcSpec; a spec that sets none of these fields builds the classic pinned talker exactly as before.

NpcRegistry.Register(new NpcSpec
{
    Id = "mymod.pedlar", Name = "Orrin the Pedlar",
    Mobile = true,                            // SideLoader melee AI + NavMeshAgent; no pin/NoFall/snap
    LookFollowEnabled = false,                // the agent owns yaw on a mobile body
    Faction = "Merchants",                    // Character.Factions by NAME
    BackpackName = "Mefino's Trade Backpack", // display name; ' and ’ both match
    Ai = new AiSpec { WanderSpeed = 1.1f, CanWanderFar = false, ChanceToAttack = 40f },
    Combat = new CombatSpec { Health = 900f, Protection = 20f, DamageBonusMult = 0.25f,
                              TargetableFactions = new List<string> { "Bandits" } },
    // MerchantSpec requires Mobile = true β€” the Merchant graft rides the mobile rig, and a
    // static merchant is refused by SpecValidation rather than spawning without a shop.
    Merchant = new MerchantSpec { StockTableNameContains = "MerchantCaravanTrader",
                                  FallbackItemNames = new List<string> { "Bandages", "Makeshift Torch" },
                                  RefreshRateGameHours = 72f },
    Dialogue = new DialogueSpec
    {
        Greetings = { "Care to lighten my pack?" },
        Choices = { Choice.Shop("shop", "Let's see what you're carrying."),
                    Choice.Reply("road", "Where to?", "Wherever pays.") },
    },
});
Character body = NpcRegistry.SpawnAtAndGet("mymod.pedlar", pos, yaw);   // drive its AISWander yourself
NpcRegistry.ReplaceGreetings("mymod.pedlar", new[] { "You saved my hide back there." });  // live swap
NpcRegistry.Unregister("mymod.pedlar");   // done with him: despawn if live, drop the SL template + entry

A consumer that mints a fresh spec id per event (SideLoader refuses a second template per UID, so a respawn needs a new id) must Unregister the old one when it is finished, or the registry and SideLoader's template table grow by one dead entry per event.

Random looks (2026-08-22): RandomVisuals = true rolls gender / skin / head / hair style / hair colour instead of SideLoader's "same bald man" default, seeded from the spec id plus a per-session salt (a respawn looks the same, the next session differs; bounds are read off the game's CharacterVisualsPresets). OutfitPool rolls one OutfitSpec per spawn on the same seed, each piece a display name (ChestName / HelmetName / BootsName, any may be null) resolved like BackpackName; an unresolvable piece warns and is skipped, never the spawn, and a resolved piece overrides the matching explicit id.

RandomVisuals = true,
OutfitPool = new List<OutfitSpec>
{
    new OutfitSpec("Adventurer Armor", "Adventurer Hat", "Adventurer Boots"),
    new OutfitSpec("Padded Armor", null, "Padded Boots"),
},

Saved characters (StoryKit.Saves, 0.1.6)

SaveScan.Scan reads the player's OTHER saved characters off disk (null = save path not ready yet, empty = none); SavedCharacterSpec.FromRecord(rec, id, opts) turns one into an NpcSpec that looks like them (NpcSpec.Visuals + worn gear by EquipSlot); SaveHandoff moves items across β€” Mint (saved entry β†’ live Item), OfferPick (vanilla container panel, one take, local player only), RemoveFromSave (a NEW snapshot folder with the entry removed/reduced; refuses when the character is in use or a save is in progress). Pure parts (SavedItemParser, SaveSnapshotRules) are in StoryKit.Core and unit-tested. Log tag [SAVEHANDOFF]. Consumers: Echoes, DangerousRoads. No config keys of its own. Full table + example: wiki Saved characters.

NpcRegistry.IsInDialogue(id) is true while the NPC's dialogue tree is running, the game's conversation roster lists it, or its shop is open (Merchant.Buyer set) β€” poll it to stop a walker mid-conversation.

Rules the validator enforces offline: Combat requires Mobile; a Choice.Shop requires a Merchant (error) and sits at the root menu only; DamageResists is 6 entries. Specs carry no config of their own β€” StoryKit's only settings are in BepInEx/config/cobalt.storykit.cfg (below); per-NPC numbers belong to the consuming mod's config.

Compatibility

Outward must be on its Mono Steam branch, not the default IL2CPP build. If your game runs but nothing looks modded, check that first.

Config

BepInEx/config/cobalt.storykit.cfg, created on first launch. Three keys, shown at their shipped defaults:

[Story]
## Master kill-switch. false = no NPC is built or spawned by this kit.
EnableStory = true

[Recon]
## Read-only taps over the vanilla QuestEventManager, for the qevent* verbs below.
EnableStoryRecon = false

[Diag]
## The Harmony patches StoryRecon installs. Only meaningful with recon enabled.
StoryReconPatches = false

Each generated entry carries a # Default value: comment; BepInEx never migrates a changed default into an existing cfg, so compare against those before assuming a bug.

Dev verbs

BepInEx/config/StoryKit_cmd.txt β€” write a line, it runs on the next poll (works while paused).

Verb What it does
storynpclist Every registered NPC and its placement
storynpcstatus Whether each NPC is spawned, and where
storynpcspawn <id> [here] Spawn one NPC β€” here uses your current position
storynpcdespawn Despawn the spawned NPCs
npcmerchanttest [despawn] Spawn (or remove) the built-in test merchant 3 m ahead β€” mobile, tanky, Mefino's backpack, caravan stock, a Shop row. Proves the shop path without any consumer. Master-only.
storyreload Re-read registrations and rebuild
(no verb) TorsoLookGuard.Enabled β€” the RM18b spine-arch guard, default off. It is a public static rather than a config key because it exists to be A/B'd live; DangerousRoads drives it from its own channel with roadsmerchant torsofix on|off.
selftest [SELFTEST] PASS/FAIL … DONE
storyrecon Recon summary (needs [Recon] EnableStoryRecon)
qeventdump / qeventlisten Dump quest-event state / watch events as they fire
qeventadd / qeventset / qeventdel / qeventage Read-write pokes at a quest event, for diagnosis only

⚠ The qevent* verbs write to real quest state. They exist for investigation on a throwaway save, not for play.

CHANGELOG

StoryKit changelog

0.1.12 β€” 2026-09-05

  • StoryKit 0.1.12: complete the partial bump (manifest/thunderstore/csproj + consumer pins)
  • Merge branch 'worktree-agent-ace7c8e6b563bfb04'
  • ForgeKit 0.4.13 / DonorKit 0.1.9 / SpawnKit 0.6.1: session-3 fixes
  • Fix release-blocking version skew: NetKit 0.2.10, Beastwhispering 0.2.17
  • StoryKit 0.1.12: NpcDialogueHold β€” stop-and-face during dialogue for every spawned NPC
  • Echoes/StoryKit/GhostPeer: talk-to-wraith set-free SPIKE (Lane 6/7) β€” additive, OFF by default
  • GhostPeer: import saved enchantments β€” a hex-blade stays a hex-blade
  • GhostPeer VP7: full-character import (stats/skills/quickslot policy from the save) + post-rest weapon re-stamp
  • DangerousRoads 0.1.11 / StoryKit 0.1.11: bug-swarm close-out (BUG-TROGVSTROG, FC13, SK-D1-NRE, STALEBUSY)
  • StoryKit: STALEBUSY follow-up β€” clear a proven-stale LOCAL Buyer latch (never a remote one)
  • StoryKit: STALEBUSY review fixes β€” a checkout in flight is hard-busy; correct the host-latch claim
  • Merge branch 'main' into worktree-agent-ae7f08be6931734b3
  • StoryKit/DangerousRoads: STALEBUSY Merchant.Buyer is a latch β€” corroborate it, and name the busy leg
  • StoryKit: SK-D1-NRE heal CharacterAI's null quest-event ref before disabling the AI
  • StoryKit 0.1.10: the StoryKitβ†’NetKit edge is ratified and landed (BepInDependency + KitContract.Declare + DependencyDirectionTests; Echoes gains the direct NetKit refs the R2 dist-dup rule demands)
  • StoryKit 0.1.9: MP-hardening sweep close-out (SK-D1/D2/D3/D4/D5/D6/D12/D13, SK-D14 v2)
  • StoryKit: SK-D13 review follow-up β€” forcing Unregister overload; wire SK-D8's Overdue ceiling and the DR teardowns through it
  • Merge branch 'main' into worktree-agent-a91f6de81384aa9e3
  • StoryKit: SK-D6 master migration detected by role, not room name β€” reset, abandon in-flight, sweep the departed host's minted orphans
  • StoryKit: SK-D13 despawn guard moves inside the registry β€” refuse (never defer) while the NPC is in dialogue
  • … and 23 more (see git history)

0.1.12 β€” 2026-09-04 (built, not live-verified)

  • Every StoryKit dialogue NPC now stops moving and faces the player for the duration of a conversation (NpcDialogueHold). Live bug (Cobalt 2026-09-04): a wandering Echoes echo ("LaughingJim") kept walking away while being talked to. Baked in at the rig level β€” a Harmony guard on AISWander.Update (the same source-suppression pattern as TorsoLookGuard) that, while the body's dialogue/shop is running (NpcRegistry.IsBodyInDialogue, the single busy-read source of truth), halts the wander at its source (CharacterAI.StopMovement + SpeedModif=0, so no per-frame re-write or re-path drift) and turns the whole body (not just the head) to face the nearest local player. The freeze is a per-frame suppression: nothing on the AISWander route is mutated, so the exact borrowed patrol resumes on its own the tick the conversation ends β€” no stored state, no coroutine. Opt-out via the new additive NpcSpec.HoldStillInDialogue (default true); echoes/merchants/trainers/mobile NPCs inherit it with zero consumer code. Decidable bits (ShouldHold, TryFaceYaw, ShortestTurn) are pure StoryKit.Core.DialogueHold, unit-tested. Live retest: echoes-testplan.md EC-HOLD1.

0.1.11 β€” 2026-08-30 (built, not live-verified)

The bug swarm off the 2026-08-30 live session.

  • Despawns no longer leave a swallowed CharacterAI.OnDisable NRE (SK-D1-NRE). Root cause found in the decompile: m_aiActiveOnQuestEvent is a serializable class Unity only allocates during scene deserialization, so every SideLoader-built body carries null and vanilla derefs it unguarded on BOTH enable and disable. NpcRegistry now heals a null field with an empty reference at rig time (and before pre-disables for unrigged orphans) β€” never overwriting a real quest gate. Side effect: vanilla's own distance-culling NREs on our bodies stop too. Retest: storykit-mp-testplan.md SK-D1-NRE.
  • The shop-busy read is self-limiting and transaction-aware (STALEBUSY β€” the live session saw despawn retries read HELD after the shop closed). Vanilla's Merchant.Buyer is a durable latch with exactly one fragile clearing path (the shopper's own ShopMenu.OnHide β†’ QuitShop β†’ exit RPC β€” which silently no-ops on a missing pouch; disconnect/death/scene-change/save-restore all leave it set). The merchant busy leg now corroborates a local shopper with the actual Shop panel (3s open grace), trusts a remote shopper for 300s then expires, and treats an in-flight checkout (IsTransactioPending) as HARD busy ahead of everything β€” money mid-move can never lose its merchant. A PROVEN-stale local latch is cleared (fixing vanilla's merchant-unshoppable-for-the- session wedge and unfreezing the greet-pinned walk); the remote leg is never cleared (clearing under a genuinely-shopping guest trades one latch for a worse one). Every refusal/HELD line now names WHICH leg read busy. Guest caveat: a guest browsing continuously past 300s may have the merchant walk off (T3-mp). Retests: road-merchant-testplan.md SK-D8b/c/d.

0.1.10 β€” 2026-08-30 (built, not live-verified)

  • The StoryKit β†’ NetKit dependency edge is RATIFIED and landed (Cobalt, 2026-08-30; docs/storykit-dialogue-wire-plan.md open question 1). [BepInDependency(NetKit)] + KitContract.Declare + the DependencyDirectionTests allowance, in one change. This release carries the EDGE only β€” no wire code yet: the story channel, the dlg revision store and the story.act request/ack are a later sweep's work, per the plan doc. No shipped profile gains a DLL (every profile already carries NetKit via CompanionKit/SpawnKit); the bundle-closure computation just starts saying so honestly.

The multiplayer half of the MP-hardening sweep. Every finding below is BUILT and unit-green; none of it has been in front of a live two-box session. Rows: docs/storykit-mp-testplan.md SKMP1–SKMP6 and SK-D12a–SK-D12d/SK-D5b, docs/road-merchant-testplan.md RM26.

  • NpcRegistry.Despawn no longer reports a success it did not achieve (SK-D1). SideLoader's destroy always failed β€” a SetActive-first NRE in CharacterAI.OnDisable, swallowed by PUN β€” and the UID leaked forever behind a normal-looking log line. The AI is now disabled before the destroy, the destroy is wrapped, and a ~0.3 s verification pass logs despawned only once CharacterManager has actually forgotten the UID; when SideLoader's coroutine died it force-cleans (manager removal plus a direct destroy, mirroring DelayedCharacterDestroy). The bool keeps its signature, and its XML doc now says what it has always meant: "destroy issued", never "the world changed". Rules in StoryKit.Core.DespawnRules.
  • Every mutating registry op is master-gated (SK-D3). Despawn, Unregister, Respawn, Rebuild and ReplaceGreetings refuse with a named line β€” [STORYKIT] <Op>('<id>') REFUSED β€” non-master client …. One guest call used to delete an NPC permanently on every peer (SideLoader's destroy RPCs to all) and log as if it had worked. Rebuild/ReplaceGreetings are gated rather than documented as local-cosmetic, because a guest-local rebuild forks guest spec state; Rebuild also no longer mutates e.Spec before its liveness read. The SpawnAtAndGet guest gate is hoisted to the top, so a guest SpawnAt refuses synchronously instead of returning true and deferring. Authority rules in StoryKit.Core.RegistryAuthority.
  • The despawn-vs-dialogue guard moved INSIDE the registry (SK-D13). Despawn / Unregister / Respawn refuse with [STORYKIT] <Op>('<id>') REFUSED β€” in dialogue/trading … while IsInDialogue(id) reads busy, instead of destroying a Character under a running DialogueTree (the merchant-greet bug, previously guarded caller-side only and blind to guests). The semantics are REFUSE, not defer: the bool stays honest after SK-D1, and it composes with caller-side deferrals like DangerousRoads' ShopDefer because every retry takes a fresh read, so a double-defer cannot wedge. Unregister refuses the whole op while busy rather than dropping the entry around a live body. Verdicts in StoryKit.Core.DespawnBusyRules. Known limitation, recorded on the row: a guest's conversation with a DangerousRoads-minted (key-less) spec still reads not-busy (AO4-1).
  • ADDITIVE: forcing overloads Despawn(id, evenIfBusy) and Unregister(id, evenIfBusy) for teardown that must win. They exist because SK-D13's refusal broke SK-D8's composition: RoadWalker's Overdue ceiling fired End() while busy and MerchantCard.ClearLive tore its handles down around a REFUSED Unregister, leaving a live merchant, a live entry and a live template with nobody left to retry. The docs are explicit that a non-forcing caller must KEEP its handle on a false. COMPAT_SINCE unchanged at 0.1.5.
  • A Photon master migration is no longer a no-op (SK-D6). NpcDirector watches isMasterClient alongside the room name (StoryKit.Core.RoomRoleWatch; a simultaneous room change subsumes the flip). On a flip: a named log line, ForgetLive(), _specFailures.Reset() (SK-D4's hand-off), an immediate converge, and NpcRegistry.AbandonInFlight() β€” an epoch counter that makes in-flight SpawnWhenLive / RespawnWhenClear coroutines abandon themselves by name, also applied on a room change. A PROMOTED master sweeps the departed host's minted orphans (SL-named, no local template, no local spec, plus OrphanSweepRules.LooksMintedSpecId β€” deliberately conservative against SK-D11's ~95% false-positive recognizer) through SK-D1's verified destroy; a mid-dialogue orphan is left standing with its own line. The inverse guard: a peer demoted inside the SK-D1 verify window logs NOT force-cleaning rather than force-cleaning locally. Static specs still self-heal via FirstMissingIn after ForgetLive. Core: MigrationRules.cs.
  • One throwing spec no longer takes every later spec down with it (SK-D4). Both director walks β€” master placement and guest rig β€” isolate each spec in its own try/catch, so a throw no longer skips the rest, no longer silences the AO4-1 unmatched-template sweep, and no longer retries forever. Failures are counted per id and the director gives up on a spec BY NAME after 5 (new pure StoryKit.Core.RigFailurePolicy). A hard cap rather than a timed backoff, deliberately: the 3 s convergence tick is already the clock, and a second clock is a second thing to reason about in a log. The ladder is failure 1 = warning with the full exception, 2–4 = one line with the count, 5 = an error naming the id and saying attempts stop. Three re-arms: a success clears the id, a room change resets, and a SCENE change resets β€” the last is what makes the guarantee true in single-player, where PhotonNetwork.room is null all session and the room watch never fires again.
  • A guest no longer rigs replicated NPCs inside the loader's paused window (SK-D5). The IsGameplayResumed() gate sits above the master/guest branch in Converge, so guests stop producing naked / T-posed bodies with an inactive AIStatesRoot. And the "already rigged" marker is now honest: DialogueBuilder writes NpcLookFollow at the END of a successful rig rather than at the top, so a throw further down no longer leaves a body marked-but-unrigged and never revisited. DialogueBuilder.Build RETHROWS after installing its fallback graph, which makes a failed dialogue build a counted terminal state that escalates to SK-D4's give-up line instead of a silent re-rig every 3 s. The director also re-attempts a spec whose rig has thrown despite the marker, logging (retry #N after a failed rig) β€” harmless once the marker move is in, and the belt to its braces.
  • StoryKit NPCs enter vanilla's multiplayer dialogue lock (SK-D12). Every NPC now gets a DialogueActorLocalize carrying its spec id as ActorLocKey, which is what SceneInteractionManager requires before it will arbitrate a conversation through the master client. The key was always empty, so dialogue started local-only on every peer: two players could open the same NPC at once and the host could not see that a guest was talking β€” NpcRegistry.IsInDialogue's MP branch was dead code, which is also what made SK-D13's guard toothless for guests. A localization row is registered under the key first, so the NPC is still shown by name rather than by its id, and the rig-census line now carries actorKey=. Rules in StoryKit.Core.DialogueLockRules. Two known limits, documented as rows and neither fixed: one benign Unity-swallowed DialogueActorLocalize.Awake NRE per rig (Awake runs inside AddComponent, before LocKey can be assigned), and an in-session language change renaming every StoryKit NPC to its raw spec id until the next rig (the loc table is cleared on reload and there is no re-registration hook).
  • The merchant's fallback stock stops refilling on demand (SK-D2). The fallback item list now rides the same 72-game-hour refresh clock as a real stock roll (MerchantPouch.m_nextRefreshTime), so buying a shop out and reopening it mints nothing until the clock comes round. Every reopen of an emptied shop used to regenerate the whole fallback list through GenerateItemNetwork β€” an unbounded item fountain, in solo as much as in co-op. New pure rule StoryKit.Core.MerchantRefreshRules; one entry point MerchantWiring.RefreshAndMaybeFillFallback replaces the two ad-hoc refresh-then-fill call sites. Row RM26.
  • ADDITIVE, Core only, nothing wired: StoryKit.Core.DialogueWire β€” the pure codecs and state for the planned guest-dialogue wire (channel/verb constants, epoch+revision stamps with a migration-safe compare ledger, the action-invoke request body, and an ok/refused ack grammar with named refusal reasons), unit-tested. No channel is registered and no dependency edge landed: the StoryKitβ†’NetKit edge is an architectural decision for Cobalt, and the case is written up in docs/storykit-dialogue-wire-plan.md (SK-D14 v2, with DW1–DW10 draft verify rows).
  • Comment fix (SK-D12): the Awake-NRE rationale in the file was wrong; corrected in place, with the two known limits recorded beside it.
  • Dependency pins raised to the sweep floors.

0.1.8 β€” 2026-08-30 (built, not live-verified)

  • RingSpawner.SpawnAround β€” collision-safe ring spawn (additive; COMPAT_SINCE unchanged). Puts registered spec ids on a ring around a centre, slot 0 at centreAngleDeg clockwise from a world heading (180 + the player's forward = behind the player), fanned across spreadDeg. Every slot is navmesh-snapped (Walkable area, 2.5 m), rejected on a non-trigger collider at body height (Physics.CheckCapsule r=0.4, 0.3–1.8 m) or when within minSpacing of a placed body, then RingPlan.Fallbacks (Β±15Β°, Β±30Β°, then radius Γ—0.75 / Γ—1.25) are tried in order; a spec with no surviving candidate is Refused with the reason. Spawns via SpawnAtAndGet(…, SpawnPolicy.Refuse) so the gameplay-live gate applies. Pure planner StoryKit.Core.RingPlan (RingPlanTests). Log tag [RING].

0.1.7 β€” 2026-08-29 (built, not live-verified)

  • The spawn gate, in the registry (additive; COMPAT_SINCE unchanged at 0.1.5). SpawnAt / SpawnAtAndGet / TrySpawn never spawn while ForgeKit.Lifecycle.IsGameplayLive() is false β€” a body born in the loader's paused window comes up T-posed / naked / with an inactive AIStatesRoot (education/who-poses-the-body.md #8). New NpcRegistry.SpawnPolicy { Refuse, Defer } + overloads: SpawnAt/TrySpawn default to Defer (spawns when the gate opens, ≀30 s then anyway, abandoned on a scene change; returns true = live now or accepted); SpawnAtAndGet defaults to Refuse (null + one [STORYKIT] '<id>': spawn REFUSED β€” gameplay is not live (<which half>) line) because its callers (the DangerousRoads cards, Echoes) need a synchronous body. NpcDirector.IsGameplayResumed now IS the kit predicate.
  • Posture census replaces the Bug-46 RepairVisualsWatch: one [STORYKIT] posture '<id>' @3s: renderers= visualsInit= animator= init= ctrl= aiRoot= closeToPlayer= spawnAnimDone= live= line per spawned body. Repairs in place: bare β†’ InitDefaultVisuals+Rebind (as before); animator unbound / disabled / no controller β†’ enable+Rebind; AIStatesRoot inactive β†’ named, and re-activated only when vanilla's own rule (CharacterAI.cs:523-531: live, spawn anim done, alive, not quest-gated) would have. Echoes' private copy of both the gate and the census is gone.
  • AiSpec.WanderSpeed default 1.1 β†’ NpcSpec.WalkSpeed (0.3), with NpcSpec.RunSpeed (1.1) as the other word. SideLoader's 1.1 is a full run; every "sprinting NPC" sighting was this default. The test pedlar says RunSpeed explicitly; the stranger card says WalkSpeed. Consumers that set a config value (DR merchant/patrol) are unchanged.

0.1.6 β€” 2026-08-29

  • Saved characters (0.1.5 β†’ 0.1.6, additive; COMPAT_SINCE unchanged at 0.1.5). Lifted from Echoes for its second consumer (DangerousRoads' familiar-stranger card):
    • StoryKit.Saves.SaveScan / SavedCharacterRecord β€” read every other saved character off disk (newest readable snapshot, vanilla CharacterSave.LoadFromFile; null-vs-empty contract kept).
    • SavedCharacterSpec.FromRecord(rec, id, opts) β€” a look-alike NpcSpec: visual indices verbatim, worn gear resolved to SL slots via the prefab's EquipSlot.
    • SaveHandoff.Mint / OfferPick / RemoveFromSave β€” saved entry β†’ live Item; the one-take container-panel pick (Harmony postfix on ItemContainer.RemoveItem, filtered to the offer container); permanent write-back as a NEW snapshot folder. Log tag [SAVEHANDOFF].
    • NpcSpec.Visuals (VisualIndices, wins over RandomVisuals), NpcSpec.BackpackId, NpcSpec.ShieldId.
    • Core: SavedItemParser (parser + hierarchy classifiers + EnchantmentIds), SaveSnapshotRules.
  • Echoes 0.1.1 now depends on StoryKit and registers its echoes through NpcRegistry (Echoes.Core.EchoRules keeps its surface as forwarders over StoryKit.Core.SavedItemParser).

0.1.5 β€” 2026-08-28

  • Fix stale dependency pins across the fleet; DonorKit 0.1.7
  • StoryKit: Action-kind dialogue choices + weapon outfit slot
  • ForgeKit/Hireling: mark script-owned NPCs so they can't be clone-adopted
  • SplitScene: refuse a solo unflipped-guest crossing instead of wedging
  • RM18b: root cause CONFIRMED at the clamp rail β€” restore the guard, add a pose sampler
  • RM18b: root-cause the merchant's backward spine arch + guard behind a live toggle
  • StoryKit: keep dialogue-menu highlight in place across leaf replies
  • AO4-2/AO4-7+8: document the Mobile=true merchant rule; explain the stash.Graph write
  • AO4-13: state the look-range change as tidy-up, not as two bugs (review CHANGES)
  • AO4-13: one look range in NpcLookFollow, and no first-frame turn to world origin
  • AO4-12: escape control characters in the qeventdb JSON dump
  • AO4-10: qeventadd's auto-registered signatures are not savable
  • AO4-7+8: cut orphaned dialogue nodes loose on rebuild, deferred under a live talk
  • AO4-6: keep the stock-table sweep off the merchant's spawn frame
  • AO4-5: drop a Train row that has no train node instead of showing a dead one
  • AO4-4: quest-event presence is stack > 0, and stack 0 prints as DORMANT
  • AO4-3: verify SideLoader actually took our template after ApplyTemplate
  • AO4-2: a static merchant is refused, not silently shopless
  • AO4-1: warn on a replicated SideLoader body with no local template (interim)
  • Callback dialogue leaves (0.1.3 β†’ 0.1.4, additive; COMPAT_SINCE unchanged at 0.1.3). ChoiceKind.Action + Choice.Action(id, text, replyText, actionId): a Reply leaf that first runs a host-registered callback, and whose reply text is REPLACED at execution time when that callback returns a non-empty string β€” so a row can answer with live state the spec never knew. Legal at any menu depth (unlike Train/Shop there is no singleton node to point at).
    • StoryKit.ChoiceActions.Register(specId, actionId, Func<Character,string>) / Unregister(specId) / TryGet / Has. Keyed by SPEC id so a consumer minting per-spawn-unique specs (DangerousRoads' dr_merchant_<n>) sheds the whole table in one call β€” NpcRegistry.Unregister now does exactly that.
    • MenuPlanner.Plan(…, Func<string,bool> handlerAvailable): an Action row with no registered handler is dropped at plan time (MenuDropReason.ActionNoHandler), the Shop row's ShopNoMerchant treatment. An Action row emits an ActionNode + its Reply statement (menu[i] β†’ action β†’ reply β†’ greeting/parent menu) while still consuming exactly ONE out-connection of the menu, so the availableChoices index-match holds.
    • SpecValidation refuses Kind=Action with an empty ActionId.
    • RunActionTask resolves the talking player at EXECUTION time (the OpenTrainerTask precedent, never a serialized binding) and calls the handler inside a try/catch β€” a consumer's throw logs and the conversation still reaches the statement.
  • OutfitSpec.WeaponName: the rolled outfit can hold a weapon, resolved by display name through the same loose item-name ladder as chest/helmet/boots (overrides NpcSpec.WeaponId; an unresolvable name warns and is skipped, never the spawn).
  • Every body NpcRegistry.RigCharacter touches is now stamped ForgeKit.ScriptedBody (owner=StoryKit, reason=story NPC '<id>') β€” the "do not adopt this body" marker β€” plus the SpawnAtAndGet path that deliberately skips the OnSpawn callback, which stamps it itself. That covers every master-side spawn. On a guest, NpcDirector.EnsureGuestRigs walks NpcRegistry.Specs(), so STATIC specs (Maren, the trainers) are marked there too, but a per-spawn spec minted at card time (dr_merchant_<n>) is registered master-side only and its guest replica is not marked β€” harmless today, since the only consumer refuses on a non-master. Rationale and the live incident it closes: src/ForgeKit/ScriptedBody.cs and the ForgeKit 0.4.10 changelog entry.
  • TorsoLookGuard (+ pure StoryKit.Core.TorsoLook.ShouldMute, unit-tested): mutes vanilla's torso look-at pitch on StoryKit bodies in the ONE case vanilla left undamped. Character.UpdateLateAnim2 rotates two spine bones from a pitch clamped to +50Β° (up to ~60Β° of backward arch, legs untouched) and only damps it if (m_currentWeapon && !flag) β€” our NPCs are unarmed, so a locked target pegs the clamp and it unwinds at 2Β°/s. An armed, aiming, sprinting or unlocked body is left entirely to vanilla. Membership is an instance-id set maintained at spawn/despawn, not a GetComponent in a LateUpdate that runs for every Character in the scene. Ships behind TorsoLookGuard.Enabled (default off) so roadsmerchant torsofix on|off can A/B it live β€” road-merchant-testplan RM18b, not yet graded in game.
  • DialogueSelectionMemory: DialoguePanel no longer snaps the menu highlight back to the top row when a leaf Choice.Reply loops back to the same parent menu β€” it stays on the row you picked. Navigating to a genuinely different menu (Back, a submenu, a new NPC) still resets to the top.
  • Random looks: NpcSpec.RandomVisuals (gender/skin/head/hair/hair colour rolled per spec id + session salt, bounds from the game's CharacterVisualsPresets) and NpcSpec.OutfitPool / OutfitSpec { ChestName, HelmetName, BootsName } (one outfit rolled per spawn, names resolved through the apostrophe-tolerant item-name ladder; an unresolvable piece warns and is skipped). Pure OutfitRoll.Pick / VisualRoll.Seed|Roll|Roll01 in Core, unit-tested.
  • NpcRegistry.IsInDialogue(id): true while the NPC's dialogue tree is running, the game lists it as in conversation, or its shop has a buyer β€” for consumers that walk the NPC.
  • Mobile / combat / merchant NPCs: NpcSpec.Mobile (SideLoader melee AI, unpinned body), AiSpec, CombatSpec (health/protection/resists/damage multiplier/targetable factions by NAME), Faction by name, BackpackName by display name, MerchantSpec (vanilla Merchant graft, caravan stock table lookup, fallback items, refresh rate), Choice.Shop β†’ a real ShopDialogueAction node.
  • NpcRegistry.SpawnAtAndGet / TryGetLive / TryGetMerchant / Rebuild / ReplaceGreetings.
  • NpcRegistry.Unregister(id): despawn if live, SL_Character.Unregister() the template, remove the entry β€” for consumers minting per-spawn spec ids (the road merchant).
  • Stats re-enable: StoryNpcTemplate (an SL_CharacterTrainer subclass) keeps CharacterStats enabled for Mobile/Combat specs instead of SideLoader's unconditional disable.
  • Dev verb npcmerchanttest [despawn] β€” the built-in proving merchant (live-verify owed).
  • Existing static NPCs are unchanged (no Mobile β†’ same pin/NoFall/snap/stats-strip path).

0.1.3 β€” 2026-08-19

  • StoryKit: quest-event catalog tools β€” enumerate, dump and toggle by name
  • Review follow-ups on the F3c leaf-kit wave (M1, M2, L3, L5, L6)
  • Static-analysis wave F3c: leaf-kit fixes (A7-1/2/4/8/9/13/14/15/16/20/23/24/25/27)
  • Coercion wave: every ForgeKit integration completes cleanly through the TUI seam
  • Forge shell fixes: SSH commands via bash -c (fish login shell); set/cfgdump on every channel mod
  • Forge shell: catalog dump + response protocol + set/cfgdump in ForgeKit; forge CLI/REPL + completion packs; wiki-enriched name db

0.1.2 β€” 2026-08-11

  • Merge branch 'main' into fix/sa-0808-kits
  • Static-analysis 2026-08-08 wave D-kits: P2-13/P2-15 + the kit P3 group
  • Static-analysis 2026-08-08 wave E: close the test-debt list (Β§4)
  • Maren teaches: nested tutorial dialogue + StoryKit submenus
  • W4: fixes for everything D1 found, plus the join-race P1
  • StoryKit: fake-null cleanup across the NPC rig and the recon taps

0.1.1 β€” 2026-08-02

  • Cleanup: collapse three repeated verb preambles into helpers that already had a home
  • ForgeKit/SkillKit/StoryKit: fix five guards that could never fire, and stop the recovery verb lying
  • Docs sweep: archive, condense, and validate the whole documentation tree

0.1.0 β€” 2026-07-30

  • Thunderstore release prep: changelogs + real category tags for BW
  • Thunderstore release prep: BW/SkillKit/StoryKit manifests + descriptions
  • Icon pipeline: condition generation on real Outward art, restyle every icon
  • Review fixes for b5118e3 MP wave: real corpse-view mute (group block), QESYNC poison purge, CHAR-GUARD player exemption + ghost identity check
  • 2026-07-27 MP session closeout: NK-F3 guest-join fix (QuestEventSyncGuard + unconditional srecon signatures + save purge), V-PARKLEAK corpse-view mute, AddCharacter dup-UID guard, [DIAG] loading line; session handoff + testplan/STATUS updates (428->352 queue burn)
  • Review must-fixes: plain-NPC fallback safety, empty choice menu, Dialogue guard, owner side-table leak
  • StoryKit T2/T4: kill-switch is recoverable; recon verbs route through VerbHost
  • StoryKit T1/T3/T5: plain-dialogue NPCs, on-demand late registration, internal OpenTrainerTask
  • Maren MP review fixes: scene-scope the guest warning, adopt the replica
  • Maren MP: converge placements against CharacterManager + watch the room
  • StoryKit: per-NPC toggle for NpcLookFollow; Maren stands static
  • Evening fix batch: Bugs 44-47 + 39 + unequip-hint nit (built, NOT live-verified)
  • Integrate verb re-homing wave + 2026-07-20 SP session results + fix batch
  • fix: high-severity wave from the 2026-07-19 static-analysis review
  • Maren: relocate default spawn to Cobalt's spot + StoryKit ground-snap
  • Guest pets Phase A (M0-M5): guests can tame, fight, and buy the skill tree
  • Small-mods bundle (review 2026-07-14): TreeLayout breakthrough violations now hard-refuse (Cobalt's ruling), Hireling recruit/dismiss master-gated, AggroKit cause-stack maintenance skipped when observation is off, StoryKit verbs through VerbHost
  • StoryKit: NPCs/trainers/dialogue as a kit β€” Maren goes live in Cierzo

0.1.0 β€” initial release

  • Add an NPC to Outward as plain data: a consumer describes placement, appearance, dialogue, and (if the NPC is a trainer) the skills it sells, and StoryKit builds the character, spawns it at a fixed spot, and wires the conversation β€” no custom UI, no NodeCanvas hand-editing.
  • NpcDirector spawns the registered NPC at player-ready on every scene load β€” master-only in co-op, duplicate-safe.
  • DialogueBuilder compiles a described conversation into a real NodeCanvas graph, reusing SideLoader's own trainer dialogue nodes.
  • A described skill tree compiles and sells from the vanilla Trainer window β€” no custom shop UI.
  • NPCs, trainers, and dialogue only. No quest/story-event authoring API in this release.