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 | 2 weeks ago |
| Version | 0.1.5 |
| Download link | CeruleanCutlass-StoryKit-0.1.5.zip |
| Downloads | 108 |
| Dependency string | CeruleanCutlass-StoryKit-0.1.5 |
This mod requires the following mods to function
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.13CeruleanCutlass-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.10README
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"),
},
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 onAISWander.Update(the same source-suppression pattern asTorsoLookGuard) 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 additiveNpcSpec.HoldStillInDialogue(defaulttrue); echoes/merchants/trainers/mobile NPCs inherit it with zero consumer code. Decidable bits (ShouldHold,TryFaceYaw,ShortestTurn) are pureStoryKit.Core.DialogueHold, unit-tested. Live retest:echoes-testplan.mdEC-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.OnDisableNRE (SK-D1-NRE). Root cause found in the decompile:m_aiActiveOnQuestEventis 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.NpcRegistrynow 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.mdSK-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.Buyeris 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.mdSK-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.mdopen question 1).[BepInDependency(NetKit)]+KitContract.Declare+ theDependencyDirectionTestsallowance, in one change. This release carries the EDGE only β no wire code yet: thestorychannel, thedlgrevision store and thestory.actrequest/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.Despawnno longer reports a success it did not achieve (SK-D1). SideLoader's destroy always failed β a SetActive-first NRE inCharacterAI.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 logsdespawnedonly once CharacterManager has actually forgotten the UID; when SideLoader's coroutine died it force-cleans (manager removal plus a direct destroy, mirroringDelayedCharacterDestroy). The bool keeps its signature, and its XML doc now says what it has always meant: "destroy issued", never "the world changed". Rules inStoryKit.Core.DespawnRules.- Every mutating registry op is master-gated (SK-D3).
Despawn,Unregister,Respawn,RebuildandReplaceGreetingsrefuse 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/ReplaceGreetingsare gated rather than documented as local-cosmetic, because a guest-local rebuild forks guest spec state;Rebuildalso no longer mutatese.Specbefore its liveness read. TheSpawnAtAndGetguest gate is hoisted to the top, so a guestSpawnAtrefuses synchronously instead of returning true and deferring. Authority rules inStoryKit.Core.RegistryAuthority. - The despawn-vs-dialogue guard moved INSIDE the registry (SK-D13).
Despawn/Unregister/Respawnrefuse with[STORYKIT] <Op>('<id>') REFUSED β in dialogue/trading β¦whileIsInDialogue(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'ShopDeferbecause every retry takes a fresh read, so a double-defer cannot wedge.Unregisterrefuses the whole op while busy rather than dropping the entry around a live body. Verdicts inStoryKit.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)andUnregister(id, evenIfBusy)for teardown that must win. They exist because SK-D13's refusal broke SK-D8's composition: RoadWalker's Overdue ceiling firedEnd()while busy andMerchantCard.ClearLivetore its handles down around a REFUSEDUnregister, 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_SINCEunchanged at 0.1.5. - A Photon master migration is no longer a no-op (SK-D6).
NpcDirectorwatchesisMasterClientalongside 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, andNpcRegistry.AbandonInFlight()β an epoch counter that makes in-flightSpawnWhenLive/RespawnWhenClearcoroutines 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, plusOrphanSweepRules.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 logsNOT force-cleaningrather than force-cleaning locally. Static specs still self-heal viaFirstMissingInafterForgetLive. 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, wherePhotonNetwork.roomis 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 inConverge, so guests stop producing naked / T-posed bodies with an inactiveAIStatesRoot. And the "already rigged" marker is now honest:DialogueBuilderwritesNpcLookFollowat 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.BuildRETHROWS 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
DialogueActorLocalizecarrying its spec id asActorLocKey, which is whatSceneInteractionManagerrequires 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 carriesactorKey=. Rules inStoryKit.Core.DialogueLockRules. Two known limits, documented as rows and neither fixed: one benign Unity-swallowedDialogueActorLocalize.AwakeNRE per rig (Awake runs insideAddComponent, beforeLocKeycan 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 throughGenerateItemNetworkβ an unbounded item fountain, in solo as much as in co-op. New pure ruleStoryKit.Core.MerchantRefreshRules; one entry pointMerchantWiring.RefreshAndMaybeFillFallbackreplaces the two ad-hoc refresh-then-fill call sites. RowRM26. - 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 indocs/storykit-dialogue-wire-plan.md(SK-D14 v2, withDW1βDW10draft 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_SINCEunchanged). Puts registered spec ids on a ring around a centre, slot 0 atcentreAngleDegclockwise from a world heading (180 + the player's forward = behind the player), fanned acrossspreadDeg. Every slot is navmesh-snapped (Walkable area, 2.5 m), rejected on a non-trigger collider at body height (Physics.CheckCapsuler=0.4, 0.3β1.8 m) or when withinminSpacingof a placed body, thenRingPlan.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 viaSpawnAtAndGet(β¦, SpawnPolicy.Refuse)so the gameplay-live gate applies. Pure plannerStoryKit.Core.RingPlan(RingPlanTests). Log tag[RING].
0.1.7 β 2026-08-29 (built, not live-verified)
- The spawn gate, in the registry (additive;
COMPAT_SINCEunchanged at 0.1.5).SpawnAt/SpawnAtAndGet/TrySpawnnever spawn whileForgeKit.Lifecycle.IsGameplayLive()is false β a body born in the loader's paused window comes up T-posed / naked / with an inactiveAIStatesRoot(education/who-poses-the-body.md#8). NewNpcRegistry.SpawnPolicy { Refuse, Defer }+ overloads:SpawnAt/TrySpawndefault to Defer (spawns when the gate opens, β€30 s then anyway, abandoned on a scene change; returns true = live now or accepted);SpawnAtAndGetdefaults 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.IsGameplayResumednow 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;AIStatesRootinactive β 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.WanderSpeeddefault 1.1 βNpcSpec.WalkSpeed(0.3), withNpcSpec.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 saysRunSpeedexplicitly; the stranger card saysWalkSpeed. 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_SINCEunchanged at0.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, vanillaCharacterSave.LoadFromFile; null-vs-empty contract kept).SavedCharacterSpec.FromRecord(rec, id, opts)β a look-alikeNpcSpec: visual indices verbatim, worn gear resolved to SL slots via the prefab'sEquipSlot.SaveHandoff.Mint/OfferPick/RemoveFromSaveβ saved entry β liveItem; the one-take container-panel pick (Harmony postfix onItemContainer.RemoveItem, filtered to the offer container); permanent write-back as a NEW snapshot folder. Log tag[SAVEHANDOFF].NpcSpec.Visuals(VisualIndices, wins overRandomVisuals),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.EchoRuleskeeps its surface as forwarders overStoryKit.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_SINCEunchanged at0.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.Unregisternow 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'sShopNoMerchanttreatment. 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 theavailableChoicesindex-match holds.SpecValidationrefusesKind=Actionwith an emptyActionId.RunActionTaskresolves the talking player at EXECUTION time (theOpenTrainerTaskprecedent, 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 (overridesNpcSpec.WeaponId; an unresolvable name warns and is skipped, never the spawn).- Every body
NpcRegistry.RigCharactertouches is now stampedForgeKit.ScriptedBody(owner=StoryKit,reason=story NPC '<id>') β the "do not adopt this body" marker β plus theSpawnAtAndGetpath that deliberately skips theOnSpawncallback, which stamps it itself. That covers every master-side spawn. On a guest,NpcDirector.EnsureGuestRigswalksNpcRegistry.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.csand the ForgeKit 0.4.10 changelog entry. TorsoLookGuard(+ pureStoryKit.Core.TorsoLook.ShouldMute, unit-tested): mutes vanilla's torso look-at pitch on StoryKit bodies in the ONE case vanilla left undamped.Character.UpdateLateAnim2rotates two spine bones from a pitch clamped to+50Β°(up to ~60Β° of backward arch, legs untouched) and only damps itif (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 aGetComponentin a LateUpdate that runs for every Character in the scene. Ships behindTorsoLookGuard.Enabled(default off) soroadsmerchant torsofix on|offcan A/B it live β road-merchant-testplan RM18b, not yet graded in game.DialogueSelectionMemory:DialoguePanelno longer snaps the menu highlight back to the top row when a leafChoice.Replyloops 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'sCharacterVisualsPresets) andNpcSpec.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). PureOutfitRoll.Pick/VisualRoll.Seed|Roll|Roll01in 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),Factionby name,BackpackNameby display name,MerchantSpec(vanillaMerchantgraft, caravan stock table lookup, fallback items, refresh rate),Choice.Shopβ a realShopDialogueActionnode. 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(anSL_CharacterTrainersubclass) keepsCharacterStatsenabled 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.
NpcDirectorspawns the registered NPC at player-ready on every scene load β master-only in co-op, duplicate-safe.DialogueBuildercompiles 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.