From 3b7d6e778dd9b7ff14d78a4992b5a8c0a89af895 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:58:50 +0100 Subject: [PATCH] =?UTF-8?q?Straining=20the=20Weave=20(=C2=A717)=20?= =?UTF-8?q?=E2=80=94=20v0.9.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeated castings of the same spell in one scene now raise its Threshold by +1 per prior cast, as the rules have always said. The sheet shows the current Threshold with a +N badge, "Settle the Weave" clears the scene's strain, and both rests settle it automatically. Implemented in the VRPG source; this repo carries the published system. Co-Authored-By: Claude Opus 4.8 --- styles/vanity.css | 14 +++++++ system.json | 2 +- templates/character-sheet.hbs | 6 ++- vanity.mjs | 70 +++++++++++++++++++++++++++++++---- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/styles/vanity.css b/styles/vanity.css index 9801c2e..2262bc1 100644 --- a/styles/vanity.css +++ b/styles/vanity.css @@ -376,6 +376,20 @@ font-style: normal; margin-left: 4px; } +/* Straining the Weave (§17) — repeats in a scene raise the Threshold */ +.vanity-sheet .strain-badge { + font-size: 10px; + font-weight: 700; + color: var(--v-plum); + border: 1px solid var(--v-plum); + border-radius: 3px; + padding: 0 3px; + margin-left: 3px; + vertical-align: top; +} +.vanity-sheet .bar a.strain-reset { float: right; font-size: 11px; letter-spacing: 0.04em; } +.vanity-roll-dialog .strained { color: var(--v-plum); font-style: italic; } + .vanity-sheet a.item-attack i, .vanity-sheet a.item-cast i { color: var(--v-teal-d); font-size: 15px; } .vanity-sheet a.item-attack:hover i, .vanity-sheet a.item-cast:hover i { color: var(--v-gold-d); text-shadow: 0 0 6px rgba(176,135,47,.7); } .vanity-sheet a.item-edit i, .vanity-sheet a.item-delete i, .vanity-sheet a.item-spent i, .vanity-sheet a.item-equip i { diff --git a/system.json b/system.json index c50c3db..6e5a23c 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "vanity", "title": "VANITY — the Vanity Role-Playing Game", "description": "A generic d6 RPG where ego is a resource and the universe keeps score. Pool = Attribute + Knack + situation; 5–6 = a Success; fail with two 1s = a Stumble that banks a Bane of Karma (Draft 13). No hit points — Grit. Vanity, Karma and Glory drive play. Includes the full Draft 13 rulebook, all spells and gear, the starter bestiary, a 100-item Treasures & Relics library, twenty ready-to-play characters (each with cameo art, token and backstory), ten animal companions with the d10 Bond table and the one-shot adventure The Vain Crown.", - "version": "0.9.7", + "version": "0.9.8", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/templates/character-sheet.hbs b/templates/character-sheet.hbs index a187be5..4e0a033 100644 --- a/templates/character-sheet.hbs +++ b/templates/character-sheet.hbs @@ -244,7 +244,9 @@ {{#if hasSpells}}
-
Spells & Nature Tricks · Threshold 1·2·3
+
Spells & Nature Tricks · Threshold 1·2·3 + {{#if strained}} Settle the Weave{{/if}} +
@@ -253,7 +255,7 @@ - +
SpellThrEffect · scaling
{{item.name}} {{listLabel}}{{item.system.threshold}}{{threshold}}{{#if strain}} +{{strain}}{{/if}} {{item.system.effect}}{{#if item.system.scaling}} · {{item.system.scaling}}{{/if}} diff --git a/vanity.mjs b/vanity.mjs index fd41025..96bf4d8 100644 --- a/vanity.mjs +++ b/vanity.mjs @@ -401,6 +401,45 @@ async function rollPool(actor, pool, label, options = {}) { if (banked) await actor.addBanes(1, `a Stumble on “${label}”`); } +/* -------------------------------------------- */ +/* Straining the Weave (§17) — the spam brake */ +/* -------------------------------------------- */ + +/** + * Strain is per scene: cast the same spell again and its Threshold rises by + * +1 per prior casting, resetting when the scene changes or on a rest. + * + * The count lives in a flag stamped with the scene it belongs to, so a change + * of scene resets it on read — no hook, nothing to miss if a GM swaps scenes + * mid-fight or reloads. + */ +function strainScene() { + return game.scenes?.current?.id ?? game.scenes?.viewed?.id ?? "no-scene"; +} + +/** Prior castings of this spell in the current scene. */ +function strainCount(item) { + const s = item.getFlag("vanity", "strain"); + return s?.scene === strainScene() ? Number(s.casts) || 0 : 0; +} + +/** Threshold as it stands now: printed Threshold + 1 per prior casting. */ +function effectiveThreshold(item) { + return (Number(item.system.threshold) || 1) + strainCount(item); +} + +/** Remember a casting. Called only once the dice have actually been rolled. */ +async function addStrain(item) { + return item.setFlag("vanity", "strain", { scene: strainScene(), casts: strainCount(item) + 1 }); +} + +/** Let the Weave settle — clears strain on every spell an actor carries. */ +async function clearStrain(actor) { + const strained = actor.items.filter(i => i.type === "spell" && i.getFlag("vanity", "strain")); + for (const i of strained) await i.unsetFlag("vanity", "strain"); + return strained.length; +} + /** * The pool-builder dialog: situational dice, Vanity spend, Rattled penalty. */ @@ -616,8 +655,10 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { ctx.armour = items.filter(i => i.type === "armour"); ctx.gear = items.filter(i => i.type === "gear"); ctx.spells = items.filter(i => i.type === "spell").map(s => ({ - item: s, listLabel: SPELL_LISTS[s.system.list]?.label ?? s.system.list + item: s, listLabel: SPELL_LISTS[s.system.list]?.label ?? s.system.list, + strain: strainCount(s), threshold: effectiveThreshold(s) })); + ctx.strained = ctx.spells.some(s => s.strain > 0); ctx.edges = items.filter(i => i.type === "edge"); ctx.vices = items.filter(i => i.type === "vice"); ctx.hasSpells = ctx.spells.length > 0; @@ -768,7 +809,7 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { }); // Spell cast - html.find(".item-cast").click(ev => { + html.find(".item-cast").click(async ev => { const item = actor.items.get(ev.currentTarget.closest("[data-item-id]").dataset.itemId); if (!item) return; if (item.system.spent) return ui.notifications.warn(`${item.name} is spent — recover it with a rest or ritual.`); @@ -777,13 +818,16 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { const locked = actor.system.locked; const knackDice = Number(locked.rank) || 0; const flavor = (item.system.description ?? "").match(/(.*?)<\/i>/)?.[1] ?? ""; - promptAndRoll(actor, { + const prior = strainCount(item); + const threshold = effectiveThreshold(item); + const rolled = await promptAndRoll(actor, { title: `Cast ${item.name}`, base: attr + knackDice, - baseLabel: `${ATTRIBUTES[listDef.attr].label} ${attr} + ${locked.name || listDef.knack} ${knackDice} · Threshold ${item.system.threshold}`, + baseLabel: `${ATTRIBUTES[listDef.attr].label} ${attr} + ${locked.name || listDef.knack} ${knackDice} · Threshold ${threshold}` + + (prior ? ` (the Weave strains — +${prior})` : ""), options: { context: "spell", - threshold: Number(item.system.threshold) || 1, + threshold, spellUuid: item.uuid, spell: { img: item.img, @@ -795,6 +839,16 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { } } }); + // Only a casting that actually reached the dice strains the Weave — + // a cancelled dialog costs nothing. + if (rolled) await addStrain(item); + }); + + // Let the Weave settle — clear this scene's strain (§17) + html.find(".strain-reset").click(async () => { + const n = await clearStrain(actor); + if (!n) return ui.notifications.info(`The Weave is already quiet around ${actor.name}.`); + ui.notifications.info(`The Weave settles — strain cleared on ${n} spell${n > 1 ? "s" : ""}.`); }); // Spell recover / spend toggle @@ -828,9 +882,10 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { const g = actor.system.grit; if (g.value >= g.max) return ui.notifications.info(`${actor.name} is already at full Grit.`); await actor.update({ "system.grit.value": Math.min(g.max, g.value + 2) }); + const settled = await clearStrain(actor); await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), - content: `

${actor.name} catches their breath — +2 Grit (${actor.system.grit.value}/${g.max}).

` + content: `

${actor.name} catches their breath — +2 Grit (${actor.system.grit.value}/${g.max})${settled ? `, and the Weave settles` : ""}.

` }); }); @@ -841,9 +896,10 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { await actor.update({ "system.grit.value": g.max, "system.karma": karma }); const spent = actor.items.filter(i => i.type === "spell" && i.system.spent); if (spent.length) await actor.updateEmbeddedDocuments("Item", spent.map(i => ({ _id: i.id, "system.spent": false }))); + const settled = await clearStrain(actor); await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), - content: `

${actor.name} takes a proper rest: Grit restored (${g.max}/${g.max}), 1 Bane cleared (tab ${karma})${spent.length ? `, ${spent.length} spell${spent.length > 1 ? "s" : ""} recovered` : ""}.

` + content: `

${actor.name} takes a proper rest: Grit restored (${g.max}/${g.max}), 1 Bane cleared (tab ${karma})${spent.length ? `, ${spent.length} spell${spent.length > 1 ? "s" : ""} recovered` : ""}${settled ? `, the Weave settled` : ""}.

` }); });