CampaignVault.PluginSdk
0.12.0
See the version list below for details.
dotnet add package CampaignVault.PluginSdk --version 0.12.0
NuGet\Install-Package CampaignVault.PluginSdk -Version 0.12.0
<PackageReference Include="CampaignVault.PluginSdk" Version="0.12.0" />
<PackageVersion Include="CampaignVault.PluginSdk" Version="0.12.0" />
<PackageReference Include="CampaignVault.PluginSdk" />
paket add CampaignVault.PluginSdk --version 0.12.0
#r "nuget: CampaignVault.PluginSdk, 0.12.0"
#:package CampaignVault.PluginSdk@0.12.0
#addin nuget:?package=CampaignVault.PluginSdk&version=0.12.0
#tool nuget:?package=CampaignVault.PluginSdk&version=0.12.0
CampaignVault.PluginSdk
Contracts and models for writing out-of-tree CampaignVault plugins: IWorldChangeHandler,
IChangeContext, IInteractionMode, PluginManifest, and the shared domain models
(Character, Location, Item, WorldChange, ...) plugins mutate.
This package has no dependency on the CampaignVault host — it's the assembly boundary plugin DLLs compile against so they never need (or get) access to host internals.
Usage
dotnet add package CampaignVault.PluginSdk
Implement IRulesetModule (data/mechanics plugin) or IInteractionMode (turn-based scene
activity), drop the built DLL + a plugin.json manifest under Plugins/<YourPlugin>/ in a
CampaignVault install, and restart the host.
See PLUGINS.md in the main repository for the full plugin architecture, trust model, and quick-start guide.
0.12.0
- Piercings (
piercing,PiercingChange,Character.Piercings): SFW body adornment (earrings, septum, navel, …). Opensite/kindstrings (PiercingSites/PiercingKindssuggest; plugins may namespace kinds and use intimate sites). Identity(site, kind);loadnone|light|heavy; tags (locked,bell,leash_ring,fresh, …). Locked marks refuseremovewithoutforce:true. Cap 16 per character. Publishescore.pierced.v1. Wire cards show a compactpiercingssummary line. Mutate only via the verb (or returnPiercingChangefrom an event handler).
0.11.1
- Dirt phrase leaf. Namespaced kinds narrate the leaf after the last dot:
myplugin.ichor→ "ichor-stained hem" (SoilHelpers.DisplayKind/Phrase). StoredKindstays fully qualified. DirtMark.AppliedBy/SoilChange.AppliedBy. Optional provenance (character id, verb id,pluginId:cause). Audit only; does not split stacks. On worsen, a new non-null value replaces the previous. Included oncore.soiled.v1asappliedBy.DirtSpots. Suggested spot constants (face,hair,hands,chest,back,clothes,boots,cloak,hem,blade,hilt) — freeform spots still work.- Docs. Plugins mutate dirt by returning
SoilChangefrom anIDomainEventHandlerfollow-up. Observers cannot return WorldChanges and must not callSoilHelpers.Applyon tracked hosts.
0.11.0
- Roll modifiers. Implement
IRollModifierProvider.Modifiers(RollQuery)to change what a roll is: a numeric bonus, advantage or disadvantage, and a reason the player reads. Core folds every provider (its own status-effect layer, willpower, and yours) into each attack, damage, AC, check, save, initiative and speed, cancels advantage against disadvantage, and adds each reason to the roll's narrative. Providers are registered by convention, gated by yoursystems, must be pure and synchronous, and are skipped if they throw.RollQueryhasKind(RollKinds.*), the normalizedSubject(skill or save),Tags(what the roll is against:charm,fear,compulsion,mental),Actor,Other,Systemand the campaign'sOptions. Rule: numbers that expire live on aStatusEffect(core folds those); providers add situational advantage and tag-specific bonuses. Never stamp an effect and also return the same number. - Plugin rolls.
IChangeContext.ResolveRollModifiers(query, baseBonus, explicitAdvantage)runs your own rolls through the same pipeline. It is a default interface member (returns the bonus unchanged), so existing contexts and test doubles keep compiling. - Willpower matters. Saves tagged
charm,fear,compulsionormental(a Wisdom, Intelligence or Charisma save counts as mental) move withWillpower: 90+ +1, 60-89 none, 30-59 -1, 10-29 -2, under 10 -3 and disadvantage. The default (75) changes nothing.SystemExtension.WillpowerDrainedrecords what was worn down by something recoverable (a negativeattribute willpowerdelta, or your own drain); each 4-hour rest step gives back up to 5. A willpower value set outright is a new baseline. - Spell tags.
SpellDefinition.tags(charm, fear, compulsion, mental...) and an action'ssaveTagsparameter feedRollQuery.Tags. - Speed.
Speedstatus modifiers are now real: they slow travel (the group moves at its slowest member's pace, at most 3x, gear excluded), show asspeedon character and NPC cards, and a context line compares everyone's speed when someone is off their normal pace, so a chase can be adjudicated. - Dirt (
soil,SoilChange,IHasDirt.Dirt): dust, blood, mud... on a character, an item or a location (scenery viafixture:north wall,floor; props are items whoseHolderIdis the location). Each host keeps at most 8DirtMarks (Kind,Severity1-3,Spot,Fixture,AppliedDay,Note,AppliedBy); identity is(kind, spot, fixture), case-insensitive.amount+1 adds or worsens, -1 washes (spot and fixture then act as filters),clear: trueremoves every match. Past the cap the least severe, oldest mark fades. The engine never soils anything on its own: the DM commitssoilin the same batch as the fight or the road. Kinds are open strings (DirtKindsonly suggestsblood,mud,dust,soot...): inventectoplasm, ormyplugin.ichorif you want a namespace; summaries phrase the leaf (ichor-stained). To read dirt, usectx.Characters/Items/Locations[id].Dirt(orSoilHelpers.HasDirt/SeverityOf/Summarize) from any handler, observer or event handler. To change it, return aSoilChangeas anIDomainEventHandlerfollow-up; there is no need for a new$type. Every changed mark publishescore.soiled.v1(targetId,kind,severity(0 once gone),spot,fixture,action,appliedBy), which is where scent tracking, infection or cleaning rituals belong. On the wire, lists carry one shortsoilline ("muddy boots, heavily bloodied") or nothing; the fulldirtarray appears only inget_entityfor characters and items, take_turn'sfullDetailCharacterId, and a location'sfullDescriptionview.
0.10.0
- Time hook. Implement
IWorldTimeObserver.OnTimeAdvancedAsync(TimeAdvance, IChangeContext, ct). It runs after the clock moved, once per bucket (travel 6h, rest 4h, other activity andadvance_world6h; a long skip is split into at most 16 coarse steps), oldest first.TimeAdvancecarriesSource(travel,rest,activity,advance_world),Hours,TotalHoursSoFar,CharacterIds,LocationIdandTerrain(travel: the exit's terrain). LikeIWorldChangeObserverit cannot fail the commit (exceptions are logged), is gated by your plugin'ssystems, and can only cause effects by dispatching newWorldChanges; whatever it dispatches does not re-trigger the hook. apply_effect(ApplyEffectChange): a clamped layer over statuses.tierlight (±1, ≤1h), moderate (±2, Speed ±10, ≤8h), serious (±3, Speed ±20, ≤24h, needsrecoveryHint) orpersistent(curses, auras: needsimposedByandremoval, never expires). Modifiers are a whitelist (AllChecks,AllSaves,AttackRoll,AllRolls,Initiative,Speed, skills), at most two per effect; a buff must be positive and a debuff negative.keyis unique per character: reapplying refreshes to the stronger value. Each character keeps at most two non-persistent buffs and two debuffs; a third is reported as not applied. Non-persistent effects needdurationHoursand are swept when their hour passes.StatusEffectgainedEffectKeyandEffectTier.EffectTiers(core) holds the numbers.- Consequence beats (core, uses the time hook): each step on the road or in camp may signal a
good,badormixedbeat oflight,moderateorserioussize as a hint in the commit result; the DM invents it and resolves it, usually withapply_effect. Campaign options:consequences(off,light(default),full),consequenceCooldownHours(default 8; bad beats 24),consequenceMaxPerDay(default 2). Cooldown and count are kept per character inSystemStats.Traits(consequences.*). It never spawns creatures. tether(TetherChange,Character.SystemStats.Tethers):attacha subject to an anchor (character, item, orfixture:<name>) withbreakDc, optionalslackFeet,holderId,label;detach;strain(a d20 check vs the DC). A tethered subject cannot travel unless the anchor or its holder travels in the same batch. A tether ends when its anchor item is archived, its anchor or holder character is gone, or its holder is incapacitated.- Ammunition. A weapon item with the property
ammoTypefires real rounds: rangedruleset_actionattacks find a held item whoseammoFor(orammoType) names the weapon's key, name or ammo type (or the explicitammoItemId), spendattackCount × ammoPerShotfrom its charges (or quantity), clamp to what is left, and fail with[NoAmmo]when there is none.fireModes: "single:1, burst:3, auto:10"plusmode=burstsets the count (an explicitattackCountwins). A loaded item withdamage/damageTypeadds that damage as a rider on each hit (dnd5e). Weapons withoutammoTypebehave as before. - Weapon attacks with an
attackCountabove the number of targets now fan out round-robin (a burst at one target, a machine gun across a horde) instead of being capped at the target count.
0.9.0
[ActorAction]on aWorldChangemarks it as an action its actor takes (ActorId, elseCharacterId). The host refuses it at top level whileActionBlock.IsBlocked(actor): the core conditionsincapacitated,paralyzed,petrified,stunned,unconscious, or any status whoseStatModifierscarryActionBlock.Tag(BlocksAllActions). Verbs without the attribute (saves, recovery, effects aimed at someone) are never blocked, and neither are engine follow-ups. Give every blocking status an exit:ExpiresAtDay(compared withTotalDaysElapsed + Hour / 24.0; the host stops honouring an expired block even if nothing removed it), or an owner that removes it in and out of encounters. Coreattack/spell/use itemruleset actions are gated the same way.
0.8.0
EngineOnlyAttributeis public: put it on aWorldChangeyour plugin only emits (for example from anIDomainEventHandlerreacting tocore.rested.v1) so it stays out of the model's schema.plugin.jsonsystems(string list): theActiveSystemvalues the plugin applies to; empty means all.IModeStateMachine.TryAddParticipant/TryRemoveParticipant(default implementations) and thecore.mode_joined.v1/core.mode_left.v1events backmode_transitionjoin/leave.
Guidance and mode-scoped verbs (0.3.0)
Implement
IPluginGuidanceContributorto append a short hint to take_turn responses. It receives a Raven-freeIGuidanceContext(campaign, time, config, surfaced character IDs, and the changes this turn committed). The host namespaces your hint keys, admits at most one plugin hint per response, and delivers each key once per session (again afterRepeatAfterDays, or in a new session); still, fire on an edge (aModeTransitionChangeentering your mode just landed), not on a level.(0.4.0) Implement
IPluginContextContributorto push one-line facts the next prose needs on the beat that uses them (recipe state on your crafting verb, a meter on your mode's action). It receives a Raven-freeIContextTurn(campaign, committed changes, involved entity IDs, party IDs, the party's location) after every committed take_turn. EachPluginContextItemkey is namespaced and delivered once per session, so put the value in the key when a changed fact should go out again ("meter:3"). Core and plugin lines share one ~800-char budget per response, ranked byPriority.Set
[PluginWorldChange("my_verb", ModeId = "my_mode")]on verbs that only make sense inside your interaction mode. They drop out of thelookup kind=commit_schemaindex but still resolve withtype=; pair that with a guidance hint on mode entry so the model gets the schema when it needs it.Several modes can be active at once. Look up your own encounter with
context.ActiveModes.TryGetValue("my_mode", out var enc)rather thancontext.ActiveMode, which is only the most recently entered one.Declare
ParticipantClaimon yourIInteractionMode:Independent(default),Shared(mode actions cost the character's combat action), orExclusive(the character acts only in your mode, e.g. astral projection). The host rejects entering a mode when a participant is already held by an exclusive one. Combat skips anExclusiveparticipant's turn; chargingSharedmode actions against the combat action budget is not enforced yet.Domain events (
CampaignVault.Events): string-topic pub/sub, so plugins integrate without ever referencing each other. Publish withcontext.Publish("myplugin.thing_happened.v1", new { ... })from a handler; subscribe withIDomainEventHandler(Topics+HandleAsync), read fields withe.TryGet<T>(key, out var v). Rules: you may only publish under your manifest id +.(the host stampsSourceand rejects anything else), payloads are JSON objects of plain values, delivery is synchronous inside the same commit, and you react by returning follow-upWorldChanges, not by mutating entities. Follow-ups can reference any entity (the host loads what the batch didn't), are seen by observers, and count as this turn's applied changes for guidance. Chains stop at depth 3.Faults don't block play. If your handler throws or a follow-up is rejected, the rest of that reaction is skipped, the turn is saved, and the turn summary gets a
PLUGIN FAULTline: what broke, which follow-ups had already landed (there is no rollback), and a fix hint. ThrowPluginFaultException(message, fixHint)to write that hint yourself. If your steps only make sense together, return one follow-up or setFailurePolicy => ReactionFailurePolicy.FailCommit, and a fault then rejects the whole turn. Every fault is also published ascore.plugin_faulted.v1after all other reactions finish, so a diagnostics plugin can subscribe to it.Core topics (
CoreEvents.*, use the constants;CoreEvents.Alllists them):ModeEntered,ModeExited,CharacterDamaged,CharacterDowned,Traveled,Rested,EncounterInterrupted(travel, rest, ambient and crowd ambushes),EventLogged(every event beat; conversations are categoryConversation),CombatStarted,CombatTurnStarted,CombatEnded, andPluginFaulted. The doc comment on each constant lists its fields. List what you publish under"publishes"inplugin.jsonso the host can warn about subscriptions to topics nobody publishes.A combatant held by an
Exclusivemode no longer gets a combat turn; it can still be targeted, andCharacterDamagedtells your mode about it.
All of this is additive; plugins built against 0.2.0 keep working.
Hidden content and hazards (0.4.0)
LocationExit,ItemandItemDetailgainHiddenandDiscoverDc; exits also gainIntent. Hidden entries stay off the wire until a Perception/Investigation check (or passive Perception on arrival) meets the DC, or the party uses or takes them.- New
Hazard(name, trigger enter/take, detect/disarm DC, effect, save DC/ability, intent) on exits, items andLocation.Hazards; the host sets detected/disarmed/spent. New world-change fields:addHazardon a location update,hazardandhidden/discoverDcon item and upsert changes. IPluginContextContributor(see above) is new in this version.
Breaking Changes
0.4.0 — CampaignConfig.PointOfInterestDetailCharCap is removed: points of interest are now ordinary
fixture items (the host migrates existing data on startup). Drop any reference to it.
0.2.0 — ItemCategory, EquipZone, and EquipLayer are no longer enums. They're now open
string-constants classes (ItemCategories, EquipZones, EquipLayers) so item packs can define
their own categories/zones via YAML alone. Item.CoreCategory/EquipLayer are now string/
string?, and Item.EquipZones is List<string>. Update any code referencing the old enum
members (e.g. ItemCategory.Weapon → ItemCategories.Weapon).
Traits migration (0.5.0)
- New
IPluginTraitsUpgrader: migrate your ownSystemStats.Traitskeys (rename, reshape, retire) when your trait schema changes. The host runs it once per character on load; keepTryUpgradeidempotent and returntrueonly when you changed something.
License
PolyForm Noncommercial 1.0.0 — see the bundled LICENSE file.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.