diff --git a/packs/armour/LOG b/packs/armour/LOG index bad08a5..ea0335e 100644 --- a/packs/armour/LOG +++ b/packs/armour/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.251914 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.252258 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.252372 1744c7000 Level-0 table #5: 2618 bytes OK -2026/07/22-11:06:42.252427 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.252467 1744c7000 Manual compaction at level-0 from '!items!GOAakTqMDZSCDOdE' @ 72057594037927935 : 1 .. '!items!vHyUhdri4IKpq9CW' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.342167 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.342695 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.342842 1724f3000 Level-0 table #5: 2618 bytes OK +2026/07/22-22:53:09.342915 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.342966 1724f3000 Manual compaction at level-0 from '!items!GOAakTqMDZSCDOdE' @ 72057594037927935 : 1 .. '!items!vHyUhdri4IKpq9CW' @ 0 : 0; will stop at (end) diff --git a/packs/bestiary/LOG b/packs/bestiary/LOG index 7e8cd12..11d29c3 100644 --- a/packs/bestiary/LOG +++ b/packs/bestiary/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.318704 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.319032 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.319244 1744c7000 Level-0 table #5: 5226 bytes OK -2026/07/22-11:06:42.319295 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.319326 1744c7000 Manual compaction at level-0 from '!actors!351sA95uALULY3Kv' @ 72057594037927935 : 1 .. '!actors!rPfEYHmSoprnQdrY' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.402765 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.403157 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.403344 1724f3000 Level-0 table #5: 5226 bytes OK +2026/07/22-22:53:09.403402 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.403444 1724f3000 Manual compaction at level-0 from '!actors!351sA95uALULY3Kv' @ 72057594037927935 : 1 .. '!actors!rPfEYHmSoprnQdrY' @ 0 : 0; will stop at (end) diff --git a/packs/companions/LOG b/packs/companions/LOG index f4f1f9e..9ce1fef 100644 --- a/packs/companions/LOG +++ b/packs/companions/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.322638 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.323336 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.323628 1744c7000 Level-0 table #5: 4974 bytes OK -2026/07/22-11:06:42.323701 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.323743 1744c7000 Manual compaction at level-0 from '!actors!1FGl7v1mODKvN9fy' @ 72057594037927935 : 1 .. '!actors!yEfKBqSa4GmIgOpx' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.406577 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.407048 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.407182 1724f3000 Level-0 table #5: 4974 bytes OK +2026/07/22-22:53:09.407246 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.407280 1724f3000 Manual compaction at level-0 from '!actors!1FGl7v1mODKvN9fy' @ 72057594037927935 : 1 .. '!actors!yEfKBqSa4GmIgOpx' @ 0 : 0; will stop at (end) diff --git a/packs/edges/LOG b/packs/edges/LOG index 4a7a673..06f266c 100644 --- a/packs/edges/LOG +++ b/packs/edges/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.310645 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.310944 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.311148 1744c7000 Level-0 table #5: 5417 bytes OK -2026/07/22-11:06:42.311209 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.311251 1744c7000 Manual compaction at level-0 from '!items!21hRQJtWGk5fDQlo' @ 72057594037927935 : 1 .. '!items!zMphj752klzDFU3Q' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.395152 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.395469 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.395594 1724f3000 Level-0 table #5: 5417 bytes OK +2026/07/22-22:53:09.395643 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.395676 1724f3000 Manual compaction at level-0 from '!items!21hRQJtWGk5fDQlo' @ 72057594037927935 : 1 .. '!items!zMphj752klzDFU3Q' @ 0 : 0; will stop at (end) diff --git a/packs/gear/LOG b/packs/gear/LOG index d24c560..0b958bc 100644 --- a/packs/gear/LOG +++ b/packs/gear/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.268229 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.268754 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.268950 1744c7000 Level-0 table #5: 9529 bytes OK -2026/07/22-11:06:42.269017 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.269061 1744c7000 Manual compaction at level-0 from '!items!27LcrYsIEEF3KYHA' @ 72057594037927935 : 1 .. '!items!yo48EzCVEwNdpkXF' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.357214 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.357850 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.358066 1724f3000 Level-0 table #5: 9529 bytes OK +2026/07/22-22:53:09.358124 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.358162 1724f3000 Manual compaction at level-0 from '!items!27LcrYsIEEF3KYHA' @ 72057594037927935 : 1 .. '!items!yo48EzCVEwNdpkXF' @ 0 : 0; will stop at (end) diff --git a/packs/gm/LOG b/packs/gm/LOG index 1940d0c..d4f62b9 100644 --- a/packs/gm/LOG +++ b/packs/gm/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.344443 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.344750 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.345011 1744c7000 Level-0 table #5: 10149 bytes OK -2026/07/22-11:06:42.345068 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.345104 1744c7000 Manual compaction at level-0 from '!journal!JJEgDJvtWBnFbyZA' @ 72057594037927935 : 1 .. '!journal.pages!kBclT2QiwEQmOsX1.HSVD8c7BenQjb45v' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.428790 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.429078 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.429289 1724f3000 Level-0 table #5: 10149 bytes OK +2026/07/22-22:53:09.429337 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.429371 1724f3000 Manual compaction at level-0 from '!journal!JJEgDJvtWBnFbyZA' @ 72057594037927935 : 1 .. '!journal.pages!kBclT2QiwEQmOsX1.HSVD8c7BenQjb45v' @ 0 : 0; will stop at (end) diff --git a/packs/pregens/000004.log b/packs/pregens/000004.log new file mode 100644 index 0000000..e69de29 diff --git a/packs/pregens/CURRENT b/packs/pregens/CURRENT index f7753e2..1a84852 100644 --- a/packs/pregens/CURRENT +++ b/packs/pregens/CURRENT @@ -1 +1 @@ -MANIFEST-000006 +MANIFEST-000002 diff --git a/packs/pregens/LOG b/packs/pregens/LOG index f02eb1f..5c2f1be 100644 --- a/packs/pregens/LOG +++ b/packs/pregens/LOG @@ -1,3 +1,5 @@ -2026/07/22-11:06:52.981350 16fe8f000 Recovering log #4 -2026/07/22-11:06:52.982189 16fe8f000 Delete type=0 #4 -2026/07/22-11:06:52.982310 16fe8f000 Delete type=3 #2 +2026/07/22-22:53:09.418973 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.419962 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.420751 1724f3000 Level-0 table #5: 120609 bytes OK +2026/07/22-22:53:09.420822 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.420867 1724f3000 Manual compaction at level-0 from '!actors!3Kjqu9ibyf8TjSrk' @ 72057594037927935 : 1 .. '!actors.items!zR0OZzFsVQLTSlBM.uc6AKpWyZeREAX2M' @ 0 : 0; will stop at (end) diff --git a/packs/pregens/MANIFEST-000002 b/packs/pregens/MANIFEST-000002 new file mode 100644 index 0000000..91c8077 Binary files /dev/null and b/packs/pregens/MANIFEST-000002 differ diff --git a/packs/rules/000005.ldb b/packs/rules/000005.ldb index 7b793b3..769576b 100644 Binary files a/packs/rules/000005.ldb and b/packs/rules/000005.ldb differ diff --git a/packs/rules/LOG b/packs/rules/LOG index fe8343e..1fdc4b9 100644 --- a/packs/rules/LOG +++ b/packs/rules/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.341352 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.341857 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.342338 1744c7000 Level-0 table #5: 63868 bytes OK -2026/07/22-11:06:42.342395 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.342429 1744c7000 Manual compaction at level-0 from '!journal!9PRuE5Q81BnO22fH' @ 72057594037927935 : 1 .. '!journal.pages!9PRuE5Q81BnO22fH.qCt9jGDZePPN5S6G' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.424854 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.425505 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.426080 1724f3000 Level-0 table #5: 77503 bytes OK +2026/07/22-22:53:09.426135 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.426181 1724f3000 Manual compaction at level-0 from '!journal!CNrYR2rXwHMBm0lH' @ 72057594037927935 : 1 .. '!journal.pages!CNrYR2rXwHMBm0lH.yPi5N6WdEe1jn19i' @ 0 : 0; will stop at (end) diff --git a/packs/rules/MANIFEST-000002 b/packs/rules/MANIFEST-000002 index 74d9f7e..ce18d77 100644 Binary files a/packs/rules/MANIFEST-000002 and b/packs/rules/MANIFEST-000002 differ diff --git a/packs/spells/LOG b/packs/spells/LOG index 20c262a..59616b6 100644 --- a/packs/spells/LOG +++ b/packs/spells/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.303697 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.304162 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.304433 1744c7000 Level-0 table #5: 11569 bytes OK -2026/07/22-11:06:42.304491 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.304526 1744c7000 Manual compaction at level-0 from '!items!13KOIsQOsgYqdVEX' @ 72057594037927935 : 1 .. '!items!zywiqamhRlcCgLJY' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.388862 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.389229 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.389426 1724f3000 Level-0 table #5: 11569 bytes OK +2026/07/22-22:53:09.389478 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.389511 1724f3000 Manual compaction at level-0 from '!items!13KOIsQOsgYqdVEX' @ 72057594037927935 : 1 .. '!items!zywiqamhRlcCgLJY' @ 0 : 0; will stop at (end) diff --git a/packs/tables/LOG b/packs/tables/LOG index 582a57d..44f390b 100644 --- a/packs/tables/LOG +++ b/packs/tables/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.348478 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.348956 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.349173 1744c7000 Level-0 table #5: 15588 bytes OK -2026/07/22-11:06:42.349228 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.349266 1744c7000 Manual compaction at level-0 from '!tables!JEOcxCxjK6S6pVEZ' @ 72057594037927935 : 1 .. '!tables.results!rurx5ZPcPObdbNDD.bWBU56ZArkeat9DO' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.432699 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.433167 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.433520 1724f3000 Level-0 table #5: 15588 bytes OK +2026/07/22-22:53:09.433594 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.433642 1724f3000 Manual compaction at level-0 from '!tables!JEOcxCxjK6S6pVEZ' @ 72057594037927935 : 1 .. '!tables.results!rurx5ZPcPObdbNDD.bWBU56ZArkeat9DO' @ 0 : 0; will stop at (end) diff --git a/packs/treasure/LOG b/packs/treasure/LOG index 0c356cd..efdaf47 100644 --- a/packs/treasure/LOG +++ b/packs/treasure/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.294803 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.295458 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.296003 1744c7000 Level-0 table #5: 27619 bytes OK -2026/07/22-11:06:42.296069 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.296113 1744c7000 Manual compaction at level-0 from '!items!0NDwQbhQQNeCCEco' @ 72057594037927935 : 1 .. '!items!yxds1AZFNXBpERdk' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.382119 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.382653 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.383100 1724f3000 Level-0 table #5: 27619 bytes OK +2026/07/22-22:53:09.383168 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.383221 1724f3000 Manual compaction at level-0 from '!items!0NDwQbhQQNeCCEco' @ 72057594037927935 : 1 .. '!items!yxds1AZFNXBpERdk' @ 0 : 0; will stop at (end) diff --git a/packs/vices/LOG b/packs/vices/LOG index d85c1e5..5a91a77 100644 --- a/packs/vices/LOG +++ b/packs/vices/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.313391 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.313714 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.313812 1744c7000 Level-0 table #5: 1461 bytes OK -2026/07/22-11:06:42.313868 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.313900 1744c7000 Manual compaction at level-0 from '!items!7kO3VzfFPt8QRVXT' @ 72057594037927935 : 1 .. '!items!s50CZgFUaS5puSOp' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.397830 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.398050 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.398158 1724f3000 Level-0 table #5: 1461 bytes OK +2026/07/22-22:53:09.398227 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.398280 1724f3000 Manual compaction at level-0 from '!items!7kO3VzfFPt8QRVXT' @ 72057594037927935 : 1 .. '!items!s50CZgFUaS5puSOp' @ 0 : 0; will stop at (end) diff --git a/packs/weapons/LOG b/packs/weapons/LOG index 5a61026..47694df 100644 --- a/packs/weapons/LOG +++ b/packs/weapons/LOG @@ -1,5 +1,5 @@ -2026/07/22-11:06:42.244940 173c2f000 Delete type=3 #1 -2026/07/22-11:06:42.246581 1744c7000 Level-0 table #5: started -2026/07/22-11:06:42.246870 1744c7000 Level-0 table #5: 7415 bytes OK -2026/07/22-11:06:42.246943 1744c7000 Delete type=0 #3 -2026/07/22-11:06:42.246992 1744c7000 Manual compaction at level-0 from '!items!0d6FZCtl78jEPkof' @ 72057594037927935 : 1 .. '!items!v3IghflF7cI2FzbU' @ 0 : 0; will stop at (end) +2026/07/22-22:53:09.334806 171c5b000 Delete type=3 #1 +2026/07/22-22:53:09.336531 1724f3000 Level-0 table #5: started +2026/07/22-22:53:09.336701 1724f3000 Level-0 table #5: 7415 bytes OK +2026/07/22-22:53:09.336755 1724f3000 Delete type=0 #3 +2026/07/22-22:53:09.336799 1724f3000 Manual compaction at level-0 from '!items!0d6FZCtl78jEPkof' @ 72057594037927935 : 1 .. '!items!v3IghflF7cI2FzbU' @ 0 : 0; will stop at (end) diff --git a/styles/vanity.css b/styles/vanity.css index 2262bc1..51b5e13 100644 --- a/styles/vanity.css +++ b/styles/vanity.css @@ -376,6 +376,25 @@ font-style: normal; margin-left: 4px; } +/* Targeting — the defender's answer, on the attack card */ +.vanity-target { + margin-top: 8px; + padding: 7px 9px; + border: 1px solid var(--v-plum); + border-left-width: 3px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.05); +} +.vanity-target .tgt-head { font-size: 13px; } +.vanity-target .tgt-sub { color: var(--v-ink2); font-size: 11.5px; margin-left: 5px; } +.vanity-target .pierced { color: var(--v-gold-d); font-style: italic; } +.vanity-target .tgt-dice { margin: 3px 0; font-size: 17px; line-height: 1.2; } +.vanity-target .tgt-dice .d { margin-right: 2px; opacity: 0.55; } +.vanity-target .tgt-dice .d.hit { opacity: 1; color: var(--v-teal-d); } +.vanity-target .tgt-dice .d.one { opacity: 1; color: var(--v-plum); } +.vanity-target .tgt-res { font-size: 11.5px; color: var(--v-ink2); margin-left: 6px; } +.vanity-target .tgt-net { font-size: 12.5px; } + /* Straining the Weave (§17) — repeats in a scene raise the Threshold */ .vanity-sheet .strain-badge { font-size: 10px; diff --git a/system.json b/system.json index 6e5a23c..840783c 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.8", + "version": "0.10.0", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/templates/character-sheet.hbs b/templates/character-sheet.hbs index 4e0a033..17f7177 100644 --- a/templates/character-sheet.hbs +++ b/templates/character-sheet.hbs @@ -274,6 +274,9 @@ {{!-- ============ TAB: STORY ============ --}}
+ {{!-- The Bond is a Ranger's Locked Knack (§7). Anyone else who has somehow + acquired a companion keeps their card; nobody else is offered one. --}} + {{#if hasCompanion}}
Companion — the Bond @@ -314,6 +317,7 @@
+ {{/if}}
Notes
{{editor notesHTML target="system.notes" button=true editable=editable engine="prosemirror" collaborate=false}}
diff --git a/vanity.mjs b/vanity.mjs index 96bf4d8..0b4d2e1 100644 --- a/vanity.mjs +++ b/vanity.mjs @@ -96,11 +96,43 @@ const MANEUVERS = [ desc: "Make one extra attack immediately. Remember the cap: at most one extra attack per round, however it's paid for (Press or Rapid Shot, not both — §11a)." } ]; +/** + * Spell Effects (§17) — the caster's maneuver menu. Successes past the + * Threshold buy these as readily as they buy the spell's own scaling line. + * The `fits` line is guidance for the table, not a hard gate: common sense + * (§6a) decides whether this spell could plausibly do this thing. + */ +const SPELL_EFFECTS = [ + { key: "ignite", label: "Ignite", cost: 1, hint: "1 Grit at the start of each of their turns until put out.", + desc: "The target burns: 1 Grit at the start of each of their turns until they spend an Action smothering it. Fire, acid, lightning." }, + { key: "blind", label: "Blind", cost: 1, hint: "−2 dice on anything needing sight, one round.", + desc: "Light, dust or a flood of dark: −2 dice on anything that needs eyes, until the end of their next turn. Light, illusion, weather." }, + { key: "snare", label: "Snare", cost: 1, hint: "Held until they win a Brawn contest.", + desc: "Roots, ice, webbing or grasping shadow — the target is Held until they win a Brawn contest to tear free. Nature, ice, shadow." }, + { key: "push", label: "Push", cost: 1, hint: "Thrown a pace and knocked prone.", + desc: "The target is thrown a pace and knocked prone — off the parapet, out of the circle, into the fire you just lit. Force, wind, water." }, + { key: "silence", label: "Silence", cost: 1, hint: "They cannot cast until their next turn.", + desc: "No breath, no words, no incantation: the target cannot cast until their next turn. The cleanest answer to an enemy spellcaster. Divine, mind, sound." }, + { key: "shield", label: "Shield the Ally", cost: 1, hint: "An ally gains +2 defence dice until their next turn.", + desc: "Spend the power outward instead: an ally gains +2 defence dice until their next turn. Any protective list." }, + { key: "spread", label: "Spread", cost: 2, hint: "Also strikes one further target within reach.", + desc: "The magic carries: it also strikes one further target within reach of the first, for your remaining damage. Fire, sound, area." }, + { key: "linger", label: "Linger", cost: 2, hint: "Lasts a second round — and doesn't strain the Weave for it.", + desc: "The effect lasts a second round with no second casting — and crucially does not strain the Weave (§17) for it. Wards, clouds, blessings." }, + { key: "unmake", label: "Unmake", cost: 2, hint: "Strip a ward, blessing, resistance or enchantment.", + desc: "Strip a magical defence: a ward, a blessing, a resistance, or one enchantment on an object. Arcane, divine." }, + { key: "mark", label: "Mark of the Divine", cost: 2, hint: "Faith only: allies get +1 die against them all scene.", + desc: "Faith only. The target is Marked: every ally's attack against them gains +1 die until the scene ends. Divine only." } +]; + +/** Which menu a card's spare Successes may be spent from. */ +function menuFor(context) { return context === "spell" ? SPELL_EFFECTS : MANEUVERS; } + /** * The interactive spend panel for attack cards: click maneuvers to buy them * with Successes, dial in the defender's Successes, read off the damage. */ -function renderSpendPanel({ successes, spend, sublabel }) { +function renderSpendPanel({ successes, spend, sublabel, menu = MANEUVERS, context = "attack", threshold = 0 }) { const s = { defence: 0, damage: 0, maneuvers: [], ...(spend ?? {}) }; const net = successes - s.defence; @@ -117,35 +149,36 @@ function renderSpendPanel({ successes, spend, sublabel }) {
`; - if (net <= 0) { + if (net <= 0 && context !== "spell") { return `
${defRow}
Turned aside — net ${net}, no harm done.
`; } - const budget = net - 1; - const spentOnMans = s.maneuvers.reduce((n, k) => n + (MANEUVERS.find(m => m.key === k)?.cost ?? 0), 0); + const isSpell = context === "spell"; + const budget = isSpell ? Math.max(0, successes - (threshold || 1)) : net - 1; + const spentOnMans = s.maneuvers.reduce((n, k) => n + (menu.find(m => m.key === k)?.cost ?? 0), 0); const remaining = budget - s.damage - spentOnMans; - const totalDamage = 1 + s.damage; + const totalDamage = (isSpell ? 0 : 1) + s.damage; - const manBtns = MANEUVERS.map(m => { + const manBtns = menu.map(m => { const sel = s.maneuvers.includes(m.key); const disabled = !sel && remaining < m.cost; return ``; }).join(""); - const chosen = s.maneuvers.map(k => MANEUVERS.find(m => m.key === k)).filter(Boolean); + const chosen = s.maneuvers.map(k => menu.find(m => m.key === k)).filter(Boolean); const descs = chosen.map(m => `
${m.label}${m.cost} Success${m.cost > 1 ? "es" : ""}

${m.desc}

`).join(""); return `
- ${defRow} + ${isSpell ? "" : defRow}
- Damage: ${totalDamage} Grit + ${isSpell && !s.damage ? "Effects" : `Damage: ${totalDamage} Grit`} ${remaining > 0 ? `${remaining} Success${remaining === 1 ? "" : "es"} to spend` : "all spent"}
@@ -282,7 +315,7 @@ function renderDice(results) { /** * Build the chat-card HTML for a pool roll. */ -function renderRollCard({ label, sublabel, results, threshold, pushed, context, extra, spend, banked = 0, twistUsed = false, spell = null }) { +function renderRollCard({ label, sublabel, results, threshold, pushed, context, extra, spend, banked = 0, twistUsed = false, spell = null, target = null }) { const successes = results.filter(r => r >= 5).length; const ones = results.filter(r => r === 1).length; const stumble = successes === 0 && ones >= 2; @@ -307,8 +340,8 @@ function renderRollCard({ label, sublabel, results, threshold, pushed, context, } } - const specials = context === "attack" && successes >= 1 - ? renderSpendPanel({ successes, spend, sublabel }) + const specials = (context === "attack" || context === "spell") && successes >= 1 + ? renderSpendPanel({ successes, spend, sublabel, menu: menuFor(context), context, threshold }) : ""; const spellBanner = spell ? ` @@ -343,12 +376,77 @@ function renderRollCard({ label, sublabel, results, threshold, pushed, context, ? `
` : twistUsed ? `
The knife has been twisted.
` : ""} ${spellScaling} + ${targetBlock(target, results, spend)} ${extra ? `
${extra}
` : ""} ${specials} ${buttons ? `
${buttons}
` : ""}
`; } +/** + * The exchange, resolved (§10): who was struck at, what their guard rolled, + * and what is left to land. Damage is applied on a button so the GM stays in + * charge of the fiction — Subdue, mercy and "he was already down" all live in + * the beat between the roll and the click. + */ +function targetBlock(target, results, spend) { + if (!target) return ""; + const successes = results.filter(r => r >= 5).length; + const net = Math.max(0, successes - (spend?.defence ?? target.successes ?? 0)); + const damage = spend?.damage ?? 0; + const pips = (target.results ?? []).map(r => + `${DIE_FACES[r] ?? r}`).join(""); + const pierceNote = target.piercing + ? (target.pierced + ? ` · armour-piercing — ${target.full}d cut to ${target.pool}d` + : ` · armour-piercing — nothing left to strip`) + : ""; + return ` +
+
${target.name} + ${target.label} ${target.pool}d${pierceNote}
+
${pips}${target.successes} Success${target.successes === 1 ? "" : "es"} stopped
+
${net > 0 + ? `${net} net Success${net === 1 ? "" : "es"} through — spend ${net === 1 ? "it" : "them"} above` + : `Turned aside. The guard held.`}
+ ${damage > 0 + ? `
` + : ""} +
`; +} + +/** + * Take the damage off the target's Grit and say what it did. + * + * Applied once — the button disarms itself afterwards, so a card cannot be + * clicked twice in the noise of a fight. Emptying Grit announces Taken Out + * (§5a) but never decides the state: Down, Broken or Dying is the GM's call, + * and Subdue was declared before the roll. + */ +async function applyDamageToTarget(message, damage) { + const f = message.flags?.vanity; + if (!f?.target?.uuid || damage <= 0) return; + if (f.applied) return ui.notifications.warn("That blow has already been paid for."); + + const actor = await fromUuid(f.target.uuid); + if (!actor) return ui.notifications.warn("That target is no longer on the board."); + + const grit = actor.system?.grit ?? {}; + const before = Number(grit.value) || 0; + const after = Math.max(0, before - damage); + await actor.update({ "system.grit.value": after }); + + const takenOut = after === 0 && before > 0; + await message.update({ "flags.vanity.applied": true }); + await ChatMessage.create({ + speaker: { alias: "The Exchange" }, + content: `
+

${f.target.name} takes ${damage} Grit — ${before} → ${after}.

+ ${takenOut ? `

TAKEN OUT. Down, Broken or Dying is the GM's call (§5a) — and whoever struck the blow chose Subdue or Kill before it landed.

` : ""} +
` + }); +} + /** * Roll a VANITY pool and post the chat card. * options: { threshold, context ("spell"|"attack"|...), spellUuid, sublabel } @@ -387,7 +485,8 @@ async function rollPool(actor, pool, label, options = {}) { extra: "", banked, spell: options.spell ?? null, - spend: { defence: 0, damage: 0, maneuvers: [] } + target: options.target ?? null, + spend: options.spend ?? { defence: 0, damage: 0, maneuvers: [] } } }; @@ -395,12 +494,75 @@ async function rollPool(actor, pool, label, options = {}) { speaker: ChatMessage.getSpeaker({ actor }), rolls: [roll], sound: CONFIG.sounds.dice, - content: renderRollCard({ label, sublabel: options.sublabel, results, threshold: options.threshold, pushed: false, context: options.context, banked, spell: options.spell ?? null }), + content: renderRollCard({ + label, sublabel: options.sublabel, results, threshold: options.threshold, pushed: false, + context: options.context, banked, spell: options.spell ?? null, + target: options.target ?? null, spend: flags.vanity.spend + }), flags }); if (banked) await actor.addBanes(1, `a Stumble on “${label}”`); } +/* -------------------------------------------- */ +/* Targeting — the defender answers for itself */ +/* -------------------------------------------- */ + +/** Does this weapon's properties line claim Armour-Piercing? */ +function isPiercing(text) { + return /armour[-\s]?piercing|armor[-\s]?piercing|\bAP\b/i.test(String(text ?? "")); +} + +/** + * The pool a defender actually rolls. + * + * Characters defend with the better of block or dodge — nobody chooses their + * worse guard. Armour-Piercing strips up to 2 armour dice, but §11a is + * absolute: Attribute dice always defend, so the pool never falls below the + * governing Attribute. A nameless foe has no attribute breakdown to protect, + * so AP simply thins its pool, never below one die. + */ +function defenceProfile(actor, { piercing = false } = {}) { + const sys = actor.system ?? {}; + if (actor.type !== "character") { + const pool = Number(sys.defence?.pool) || 0; + return { + pool: piercing ? Math.max(1, pool - 2) : pool, + label: sys.defence?.note?.trim() || "defence", floor: 1, full: pool + }; + } + const dodge = Number(sys.dodgePool) || 0; + const block = Number(sys.blockPool) || 0; + const useBlock = block >= dodge; + const attr = Number(useBlock ? sys.attributes?.brawn?.value : sys.attributes?.flair?.value) || 0; + const full = useBlock ? block : dodge; + return { + pool: piercing ? Math.max(attr, full - 2) : full, + label: useBlock ? "block" : "dodge", floor: attr, full + }; +} + +/** Roll a defender's guard and report what it stopped. */ +async function rollDefence(actor, { piercing = false } = {}) { + const prof = defenceProfile(actor, { piercing }); + const roll = new Roll(`${Math.max(1, prof.pool)}d6`); + await roll.evaluate(); + const results = roll.dice[0].results.map(r => r.result); + return { + ...prof, results, roll, + successes: results.filter(r => r >= 5).length, + pierced: piercing && prof.pool < prof.full + }; +} + +/** The token the attacker has targeted, if exactly one is marked. */ +function currentTarget() { + const t = [...(game.user?.targets ?? [])]; + if (t.length !== 1) return null; + const token = t[0]; + return token?.actor ? { token, actor: token.actor, name: token.name || token.actor.name } : null; +} + /* -------------------------------------------- */ /* Straining the Weave (§17) — the spam brake */ /* -------------------------------------------- */ @@ -544,7 +706,7 @@ async function handlePush(message) { await message.update({ content: renderRollCard({ label: f.label, sublabel: f.sublabel, results: newResults, - threshold: f.threshold, pushed: true, context: f.context, extra, spend, banked, twistUsed: f.twistUsed ?? false, spell: f.spell ?? null + threshold: f.threshold, pushed: true, context: f.context, extra, spend, banked, twistUsed: f.twistUsed ?? false, spell: f.spell ?? null, target: f.target ?? null }), "flags.vanity.results": newResults, "flags.vanity.pushed": true, @@ -574,8 +736,10 @@ async function handleSpend(message, dataset) { } // Never overspend: if the budget shrank (defence went up), drop purchases. - const budget = Math.max(0, successes - spend.defence - 1); - const cost = spend.damage + spend.maneuvers.reduce((n, k) => n + (MANEUVERS.find(m => m.key === k)?.cost ?? 0), 0); + const budget = f.context === "spell" + ? Math.max(0, successes - (f.threshold || 1)) + : Math.max(0, successes - spend.defence - 1); + const cost = spend.damage + spend.maneuvers.reduce((n, k) => n + (menuFor(f.context).find(m => m.key === k)?.cost ?? 0), 0); if (cost > budget) { if (dataset.def !== undefined) { spend.damage = 0; spend.maneuvers = []; } else return; // an illegal purchase — ignore the click @@ -584,7 +748,7 @@ async function handleSpend(message, dataset) { await message.update({ content: renderRollCard({ label: f.label, sublabel: f.sublabel, results: f.results, - threshold: f.threshold, pushed: f.pushed, context: f.context, extra: f.extra, spend, banked: f.banked ?? 0, twistUsed: f.twistUsed ?? false, spell: f.spell ?? null + threshold: f.threshold, pushed: f.pushed, context: f.context, extra: f.extra, spend, banked: f.banked ?? 0, twistUsed: f.twistUsed ?? false, spell: f.spell ?? null, target: f.target ?? null }), "flags.vanity.spend": spend }); @@ -601,7 +765,7 @@ async function handleFizzle(message) { await message.update({ content: renderRollCard({ label: f.label, sublabel: f.sublabel, results: f.results, - threshold: f.threshold, pushed: true, context: f.context, extra, banked: f.banked ?? 0, twistUsed: f.twistUsed ?? false, spell: f.spell ?? null + threshold: f.threshold, pushed: true, context: f.context, extra, banked: f.banked ?? 0, twistUsed: f.twistUsed ?? false, spell: f.spell ?? null, target: f.target ?? null }), "flags.vanity.pushed": true, "flags.vanity.extra": extra @@ -659,6 +823,12 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { strain: strainCount(s), threshold: effectiveThreshold(s) })); ctx.strained = ctx.spells.some(s => s.strain > 0); + // The Animal Companion is the Ranger's, via Wildcraft (§7). Other classes + // never see the card — but if one already has a bonded beast (an edge, a + // gift, a GM's ruling), it stays on the sheet rather than vanishing. + const bond = this.actor.system.companion ?? {}; + ctx.hasCompanion = String(this.actor.system.details?.class ?? "").trim().toLowerCase() === "ranger" + || Boolean(String(bond.name ?? "").trim() || String(bond.kind ?? "").trim()); ctx.edges = items.filter(i => i.type === "edge"); ctx.vices = items.filter(i => i.type === "vice"); ctx.hasSpells = ctx.spells.length > 0; @@ -786,7 +956,7 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { }); // Weapon attack - html.find(".item-attack").click(ev => { + html.find(".item-attack").click(async ev => { const item = actor.items.get(ev.currentTarget.closest("[data-item-id]").dataset.itemId); if (!item) return; const group = item.system.group === "shoot" ? "shoot" : "fight"; @@ -800,11 +970,26 @@ class VanityActorSheet extends foundry.appv1.sheets.ActorSheet { knackDice = Math.max(knackDice, Number(locked.rank) || 0); knackName = "Martial Prowess"; } - promptAndRoll(actor, { + // If exactly one token is targeted, the defender answers for itself: its + // guard is rolled here and the result is dialled into the card, so the + // exchange (§10) resolves in one step instead of two. + const mark = currentTarget(); + const piercing = isPiercing(item.system.properties); + const defence = mark ? await rollDefence(mark.actor, { piercing }) : null; + + await promptAndRoll(actor, { title: `${item.name} — attack`, base: attr + knackDice + (Number(item.system.dice) || 0), baseLabel: `${ATTRIBUTES[attrKey].label} ${attr} + ${knackName} ${knackDice} + weapon ${item.system.dice}`, - options: { context: "attack", sublabel: item.system.properties } + options: { + context: "attack", sublabel: item.system.properties, + target: mark && defence ? { + uuid: mark.actor.uuid, name: mark.name, piercing, + label: defence.label, pool: defence.pool, full: defence.full, + pierced: defence.pierced, results: defence.results, successes: defence.successes + } : null, + spend: mark && defence ? { defence: defence.successes, damage: 0, maneuvers: [] } : undefined + } }); }); @@ -1227,7 +1412,7 @@ async function twistTheKnife({ actorUuid = "", free = false, sourceMessage = nul content: renderRollCard({ label: f.label, sublabel: f.sublabel, results: f.results, threshold: f.threshold, pushed: f.pushed, context: f.context, extra: f.extra, spend: f.spend, - banked: f.banked ?? 0, twistUsed: true, spell: f.spell ?? null + banked: f.banked ?? 0, twistUsed: true, spell: f.spell ?? null, target: f.target ?? null }) }); } @@ -1942,6 +2127,12 @@ Hooks.on("renderChatMessageHTML", (message, html) => { handleSpend(message, btn.dataset); }); }); + html.querySelectorAll(".vanity-apply").forEach(btn => { + btn.addEventListener("click", () => { + if (!game.user.isGM) return ui.notifications.warn("Only the GM applies damage."); + applyDamageToTarget(message, Number(btn.dataset.damage) || 0); + }); + }); html.querySelectorAll(".vanity-twist").forEach(btn => { btn.addEventListener("click", () => { if (!game.user.isGM) return ui.notifications.warn("Only the GM may twist the knife.");