CampaignVault.PluginSdk
0.16.0
dotnet add package CampaignVault.PluginSdk --version 0.16.0
NuGet\Install-Package CampaignVault.PluginSdk -Version 0.16.0
<PackageReference Include="CampaignVault.PluginSdk" Version="0.16.0" />
<PackageVersion Include="CampaignVault.PluginSdk" Version="0.16.0" />
<PackageReference Include="CampaignVault.PluginSdk" />
paket add CampaignVault.PluginSdk --version 0.16.0
#r "nuget: CampaignVault.PluginSdk, 0.16.0"
#:package CampaignVault.PluginSdk@0.16.0
#addin nuget:?package=CampaignVault.PluginSdk&version=0.16.0
#tool nuget:?package=CampaignVault.PluginSdk&version=0.16.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.16.0
- Option prerequisites (
ChoiceOption.Prerequisite,ClassOptionDefinition.Prerequisite,OptionPrerequisite): a class level and/or an option picked earlier in the class before an option is offered.OptionsFor(GainedChoice, picked)returns only the options the character qualifies for at the choice's level.
0.15.0
- Effect kinds (
FeatEffectKinds):initiativeBonus,speedBonus(feet),passiveBonus(subjectPerception or Investigation) anddamageReduction(flat, before resistance, optionally for some damage types).resistanceanddamageReductiontake several comma-separated damage types.
0.14.0
- Proficiencies (
ProficiencyGrants:armor,weapons,tools): what a class, class feature, race, background or feat gives.FeatureDefinition.Proficienciesjoins a feature's to the sheet;Dnd5eExtensiongainsarmorProficiencies,weaponProficienciesandtoolProficiencies. - Pools (
ResourcePoolTemplate):grantedOnly,maxFrom(PoolMaxFormula: ability modifier, proficiency bonus, level multiple, flat part, minimum),die/dieByLevelandrecoveryByLevel;ResourcePool.Die.FeatureDefinition.Poolsgrants pools by name from a class feature (a subclass's). - A subclass that casts (
ChoiceOption.Spellcasting,ClassOptionDefinition.Spellcasting,OptionSpellcasting): caster type, ability, the class list it learns from, school limits and known counts by level. OptionsFor(choice, from): a later option-less choice inside a subclass borrows that subclass's options.
0.13.0
- Character creation (
CampaignVault.Rulesets.Creation): the contracts behind the recipe-driven character builder.ICharacterCreation(steps, options, preview, commit for aCharacterDraft),IRecipeValidator(a named check a recipe step points at; plugins add their own),CreationStep,CreationOption(withValuesfor templates that fill several fields at once),CreationIssue,CreationContext, and the ability score rules (AbilityScoreMethods,PointBuyRules,AbilityScoreChoice).CharacterDraft.PartyLevellets companion checks compare against the party.CreationStep.CountPlusadds a path's number to a step's count, andCreationContext.Resolvereadsmodifier.<ability>(PF2e: trained skills = the class's count + the Intelligence modifier). Anallocatestep's picks are attribute boosts, +1 each to<ability>Mod. - Feat effects (
FeatEffect,FeatRequirement,ActiveFeatEffect): declarative feat mechanics whose numbers the engine owns; a model only supplies a toggle or an asserted condition.RulesetTemplate.Requireshides any template unless its plugin (and, if named, mode) is active. - Spells on the sheet (
SpellRepertoire,Character.Spells): cantrips, spells known or spellbook, and prepared spells, set by the builder.RulesetSystem.Canonicalizemaps system spellings to the canonical ids. - Plugin manifest gains
authoranddescription. - Full rulesets out of tree.
IRulesetModule,IActionResolution,ICombatRuleset(CampaignVault.Rulesets) and the character-bootstrap contracts (IBootstrapStep,ILevelGainStep,ICharacterBootstrapPipeline+ in-box implementations,BootstrapContext,BootstrapStepResult,BootstrapReport,IBootstrapEquipmentAccessinCampaignVault.Rulesets.Bootstrap) moved here from the host (same namespaces, so host code is untouched). A plugin referencing only this package can now author a complete ruleset: dice resolution, action economy, and HP/defense/proficiency derivation. - Deliberately not moved: session-bound pressures.
IPressureContributorandIRulesetPressureContributorstay host-side (first-party resolvers implement the host'sIHostRulesetModulefor those); out-of-tree rulesets contribute pressure viaIPluginGuidanceContributor/IPluginContextContributor, which the host surfaces on the same read paths. - Bootstrap stays Raven-free. Steps that derived stats from worn gear previously
took a live session; they now read
BootstrapContext.EquipmentAccess(GetEquippedItemsAsync), which the host backs with its session. Null means no equipment data — degrade to unarmored defaults. - Death (
death,DeathChange,Character.Death/IsDead,DeathRecord): explicit character death, since 0 HP is only downed. Records day, cause, killer and body location; clears an NPC's location, schedule and companion flag (a dead PC keeps its location), lapses its minions, and rejects positivehpdeltas on the dead.revive:trueundoes it. Publishescore.character_died.v1(characterId,cause,killerId,bodyLocationId,day). 5e player characters also get automatic dying rules:Character.DeathSaves(DeathSaveTally), thedeath_saveverb (DeathSaveChange), a death-save failure per hit at 0 HP, and instant death on massive damage. NPCs never auto-die.
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). Several marks may share one site (and the same kind) — e.g. three rings onlabia.leftplus aclitorisring. Each mark has a stableid(auto1,2,…); passidto update/remove one of a stack. Defaultaddstacks;replace:trueupserts a single site+kind.loadnone|light|heavy; tags (locked,bell,leash_ring,fresh, …). Locked marks refuseremovewithoutforce:true. Cap 32 per character. Publishescore.pierced.v1(includespiercingId). Cards show a compact summary (stacks as3× …). 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.