fix: show dice in spell cast card, dedupe Alchemy folders, assign signature spells

- Spell cast card now embeds the TS dice (base + overcast) and damage dice
  instead of posting separate roll messages; Dice So Nice still animates.
- Spell pack Alchemy sub-folders renamed to Transmutacja / Warzenie Eliksirów
  so the label no longer repeats under the Alchemia folder.
- Discipline schema gains signatureSpells (the sheet already bound to it);
  build-packs fills it with each discipline's own spells.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 09:53:05 +02:00
co-authored by Claude Sonnet 5.5
parent e5716e276c
commit 9ca94268c5
8 changed files with 85 additions and 19 deletions
+2 -2
View File
@@ -162,8 +162,8 @@
"Magia Ognia": { "Magia Ognia": {
"name": "Fire Magic" "name": "Fire Magic"
}, },
"Alchemia - Transmutacja": { "Transmutacja": {
"name": "Alchemy - Transmutation" "name": "Transmutation"
}, },
"Magia Żywiołów": { "Magia Żywiołów": {
"name": "Elemental Magic" "name": "Elemental Magic"
+16 -2
View File
@@ -54,6 +54,20 @@ allDocs.push(...(await parseGear(ctx)));
console.log(`[build-packs] parsed ${allDocs.length} documents total`); console.log(`[build-packs] parsed ${allDocs.length} documents total`);
// 2b. Assign signature spells: every discipline lists the spells of its own school.
// Sacred Magic lists only the common prayers (the rest are deity-specific).
for (const disc of allDocs) {
if (disc.subType !== 'discipline') continue;
const sys = disc.system as Record<string, any>;
const spells = allDocs.filter((d) => {
if (d.subType !== 'spell') return false;
const sp = d.system as Record<string, any>;
if (sp.school !== disc.id || sp.isSuperspell) return false;
return disc.id !== 'sacred' || sp.deity === 'common';
});
sys.signatureSpells = spells.map((d) => d.name);
}
// 3. Group by pack and emit one JSON per doc into packs-src/<pack>/. // 3. Group by pack and emit one JSON per doc into packs-src/<pack>/.
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true }); if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
const byPack = new Map<string, ParsedDoc[]>(); const byPack = new Map<string, ParsedDoc[]>();
@@ -87,10 +101,10 @@ function getFolderHierarchy(doc: ParsedDoc): { name: string; parentName?: string
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ziemi', parentName: 'Magia Żywiołów' }]; return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ziemi', parentName: 'Magia Żywiołów' }];
} }
if (school === 'alchemyTransmutation') { if (school === 'alchemyTransmutation') {
return [{ name: 'Alchemia' }, { name: 'Alchemia - Transmutacja', parentName: 'Alchemia' }]; return [{ name: 'Alchemia' }, { name: 'Transmutacja', parentName: 'Alchemia' }];
} }
if (school === 'alchemyBrewing') { if (school === 'alchemyBrewing') {
return [{ name: 'Alchemia' }, { name: 'Alchemia - Warzenie Eliksirów', parentName: 'Alchemia' }]; return [{ name: 'Alchemia' }, { name: 'Warzenie Eliksirów', parentName: 'Alchemia' }];
} }
if (school === 'artifice') { if (school === 'artifice') {
return [{ name: 'Rzemiosło Artefaktów' }]; return [{ name: 'Rzemiosło Artefaktów' }];
+2
View File
@@ -146,6 +146,8 @@ const folderNameTranslations: Record<string, string> = {
"Magia Ziemi": "Earth Magic", "Magia Ziemi": "Earth Magic",
"Alchemia": "Alchemy", "Alchemia": "Alchemy",
"Alchemia - Transmutacja": "Alchemy - Transmutation", "Alchemia - Transmutacja": "Alchemy - Transmutation",
"Transmutacja": "Transmutation",
"Warzenie Eliksirów": "Brewing of Elixirs",
"Alchemia - Warzenie Eliksirów": "Alchemy - Brewing of Elixirs", "Alchemia - Warzenie Eliksirów": "Alchemy - Brewing of Elixirs",
"Rzemiosło Artefaktów": "Crafting of Artifacts", "Rzemiosło Artefaktów": "Crafting of Artifacts",
"Golemancja": "Golemancy", "Golemancja": "Golemancy",
+1
View File
@@ -148,6 +148,7 @@ export const parseDisciplines: ParserFn = async (ctx: ParserContext): Promise<Pa
suggestedSkills: SUGGESTED_SKILLS[seed.id] ?? ['magicalAbilities'], suggestedSkills: SUGGESTED_SKILLS[seed.id] ?? ['magicalAbilities'],
passiveAbility: '', passiveAbility: '',
availableSpells: [], availableSpells: [],
signatureSpells: [],
description, description,
}, },
description, description,
+1
View File
@@ -12,6 +12,7 @@ export class DisciplineData extends foundry.abstract.TypeDataModel {
}), }),
suggestedSkills: makeArrayField(new f.StringField({ blank: false })), suggestedSkills: makeArrayField(new f.StringField({ blank: false })),
passiveAbility: makeHtmlField(''), passiveAbility: makeHtmlField(''),
signatureSpells: makeArrayField(new f.StringField({ blank: false })),
availableSpells: makeArrayField(new f.StringField({ blank: false })), availableSpells: makeArrayField(new f.StringField({ blank: false })),
description: makeHtmlField(''), description: makeHtmlField(''),
}; };
+44 -15
View File
@@ -80,6 +80,12 @@ export interface CastOptions {
speaker?: ChatMessage.SpeakerData; speaker?: ChatMessage.SpeakerData;
} }
/** A roll made during a cast; rendered inline in the cast card. */
interface CastRollEntry {
label: string;
roll: HbmTSRoll;
}
export interface CastResult { export interface CastResult {
validation: ValidationResult; validation: ValidationResult;
baseRoll: HbmTSRoll | null; baseRoll: HbmTSRoll | null;
@@ -92,6 +98,16 @@ function notify(text: string, severity: 'warn' | 'error' | 'info' = 'info'): voi
u.notifications?.[severity](text); u.notifications?.[severity](text);
} }
/** Queue a roll for the cast card and play the 3D dice animation if available. */
async function recordRoll(rolls: CastRollEntry[], roll: HbmTSRoll, label: string): Promise<void> {
rolls.push({ label, roll });
try {
await (game as any).dice3d?.showForRoll?.(roll, game.user, true);
} catch {
// Dice So Nice is optional; the card still shows the faces.
}
}
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> { export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> {
// 1. Validate // 1. Validate
const validation = validateCast(actor, spell, opts); const validation = validateCast(actor, spell, opts);
@@ -108,16 +124,17 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
// 2-3. Mode dispatch // 2-3. Mode dispatch
const mode = spell.system.castingMode ?? 'standard'; const mode = spell.system.castingMode ?? 'standard';
let baseRoll: HbmTSRoll | null = null; let baseRoll: HbmTSRoll | null = null;
const rolls: CastRollEntry[] = [];
if (mode === 'sacred') { if (mode === 'sacred') {
baseRoll = opts.hekateMode === 'witch' baseRoll = opts.hekateMode === 'witch'
? await castWitch(actor, spell, opts) ? await castWitch(actor, spell, opts, rolls)
: await castSacred(actor, spell, opts); : await castSacred(actor, spell, opts, rolls);
} else if (mode === 'witch') { } else if (mode === 'witch') {
baseRoll = await castWitch(actor, spell, opts); baseRoll = await castWitch(actor, spell, opts, rolls);
} else if (mode === 'blood') { } else if (mode === 'blood') {
baseRoll = await castBlood(actor, spell, opts); baseRoll = await castBlood(actor, spell, opts, rolls);
} else { } else {
baseRoll = await castStandard(actor, spell, opts); baseRoll = await castStandard(actor, spell, opts, rolls);
} }
let damageRoll: Roll | null = null; let damageRoll: Roll | null = null;
@@ -134,7 +151,7 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
} }
// Render unified rich chat card (Phase 1.6) // Render unified rich chat card (Phase 1.6)
await renderCastCard(actor, spell, opts, baseRoll, damageRoll, triggersRegistered); await renderCastCard(actor, spell, opts, baseRoll, damageRoll, triggersRegistered, rolls);
return { validation, baseRoll, damageRoll, triggersRegistered }; return { validation, baseRoll, damageRoll, triggersRegistered };
} }
@@ -146,6 +163,7 @@ async function renderCastCard(
baseRoll: HbmTSRoll | null, baseRoll: HbmTSRoll | null,
damageRoll: Roll | null, damageRoll: Roll | null,
triggersRegistered: number, triggersRegistered: number,
rolls: CastRollEntry[],
): Promise<void> { ): Promise<void> {
const success = baseRoll?.ts?.isSuccess ?? false; const success = baseRoll?.ts?.isSuccess ?? false;
const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard'); const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard');
@@ -173,6 +191,10 @@ async function renderCastCard(
damage = { damage = {
total: damageRoll.total, total: damageRoll.total,
formula: damageRoll.formula, formula: damageRoll.formula,
dice: ((damageRoll as any).dice ?? []).map((d: any) => ({
expression: d.expression,
results: (d.results ?? []).filter((r: any) => !r.discarded).map((r: any) => r.result),
})),
type: spell.system.damageType ?? 'magical', type: spell.system.damageType ?? 'magical',
ignoresArmor: !!spell.system.ignoresArmor, ignoresArmor: !!spell.system.ignoresArmor,
targets: targets.length > 0 ? targets : null, targets: targets.length > 0 ? targets : null,
@@ -198,8 +220,14 @@ async function renderCastCard(
? spell.system.triggers ? spell.system.triggers
: []; : [];
const rollsHtml = await Promise.all(rolls.map(async (r) => ({
label: r.label,
html: await r.roll.render(),
})));
const data = { const data = {
spell: { name: spell.name }, spell: { name: spell.name },
rolls: rollsHtml,
mode, mode,
school: spell.system.school ?? '', school: spell.system.school ?? '',
circle: (spell.system as any).circle ?? 0, circle: (spell.system as any).circle ?? 0,
@@ -216,11 +244,12 @@ async function renderCastCard(
await ChatMessage.create({ await ChatMessage.create({
content: html, content: html,
speaker: opts.speaker ?? ChatMessage.getSpeaker({ actor: actor as any }), speaker: opts.speaker ?? ChatMessage.getSpeaker({ actor: actor as any }),
rolls: [...rolls.map((r) => r.roll), ...(damageRoll ? [damageRoll] : [])].map((r) => r.toJSON()),
flags: { 'hbm-rpg-v3': { spellCast: { spellName: spell.name, success } } }, flags: { 'hbm-rpg-v3': { spellCast: { spellName: spell.name, success } } },
}); });
} }
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const baseCost = Math.max(0, spell.system.manaCost ?? 0); const baseCost = Math.max(0, spell.system.manaCost ?? 0);
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost)); const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
const a = actor.system.attributes; const a = actor.system.attributes;
@@ -248,7 +277,7 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
flavor, flavor,
}); });
await baseRoll.evaluate(); await baseRoll.evaluate();
await baseRoll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, baseRoll, flavor);
// Overcast beyond Maksymalny Koszt Zaklęcia - extra TS test. // Overcast beyond Maksymalny Koszt Zaklęcia - extra TS test.
// T depends on the spell's BASE cost; S grows triangularly with the excess // T depends on the spell's BASE cost; S grows triangularly with the excess
@@ -265,13 +294,13 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
flavor: overcastFlavor, flavor: overcastFlavor,
}); });
await overcastRoll.evaluate(); await overcastRoll.evaluate();
await overcastRoll.toMessage({ flavor: overcastFlavor, speaker: opts.speaker }); await recordRoll(rolls, overcastRoll, overcastFlavor);
} }
return baseRoll; return baseRoll;
} }
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; const a = actor.system.attributes;
const zealCost = Math.max(1, opts.zealSpent ?? 1); const zealCost = Math.max(1, opts.zealSpent ?? 1);
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost }); await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
@@ -286,11 +315,11 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
flavor, flavor,
}); });
await roll.evaluate(); await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, roll, flavor);
return roll; return roll;
} }
async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; const a = actor.system.attributes;
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0); const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
const maxSymbols = Math.ceil(a.magic.actual / 2); const maxSymbols = Math.ceil(a.magic.actual / 2);
@@ -320,11 +349,11 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
flavor, flavor,
}); });
await roll.evaluate(); await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, roll, flavor);
return roll; return roll;
} }
async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; const a = actor.system.attributes;
const baseCost = Math.max(0, spell.system.bloodCost ?? 0); const baseCost = Math.max(0, spell.system.bloodCost ?? 0);
const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost)); const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost));
@@ -362,6 +391,6 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
flavor, flavor,
}); });
await roll.evaluate(); await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, roll, flavor);
return roll; return roll;
} }
+4
View File
@@ -600,6 +600,10 @@
.hbm.spell-cast-card section { margin: .35rem 0; } .hbm.spell-cast-card section { margin: .35rem 0; }
.hbm.spell-cast-card .damage-line { display: flex; gap: .35rem; align-items: baseline; font-size: 1.2em; } .hbm.spell-cast-card .damage-line { display: flex; gap: .35rem; align-items: baseline; font-size: 1.2em; }
.hbm.spell-cast-card .damage-formula code { font-size: .8em; color: #555; } .hbm.spell-cast-card .damage-formula code { font-size: .8em; color: #555; }
.hbm.spell-cast-card .hbm-card-roll { margin: .35rem 0; padding: .25rem; border: 1px solid rgba(107,63,160,.25); border-radius: 4px; }
.hbm.spell-cast-card .damage-dice { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .15rem; }
.hbm.spell-cast-card .damage-die-group { display: inline-flex; gap: .2rem; align-items: center; }
.hbm.spell-cast-card .damage-die-group .die { display: inline-flex; align-items: center; justify-content: center; min-width: 1.6rem; height: 1.6rem; border-radius: 4px; font-weight: bold; background: #ddd; color: #333; }
.hbm.spell-cast-card .hbm-card-targets, .hbm.spell-cast-card .status-buttons { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .25rem; } .hbm.spell-cast-card .hbm-card-targets, .hbm.spell-cast-card .status-buttons { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .25rem; }
.hbm.spell-cast-card button.hbm-action { font-size: .85em; padding: 2px 8px; border-radius: 4px; cursor: pointer; } .hbm.spell-cast-card button.hbm-action { font-size: .85em; padding: 2px 8px; border-radius: 4px; cursor: pointer; }
.hbm.spell-cast-card .hbm-card-hint { font-size: .8em; color: #777; margin: .15rem 0; font-style: italic; } .hbm.spell-cast-card .hbm-card-hint { font-size: .8em; color: #777; margin: .15rem 0; font-style: italic; }
+15
View File
@@ -24,6 +24,12 @@
</ul> </ul>
{{/if}} {{/if}}
{{#each rolls as |r|}}
<section class="hbm-card-roll">
{{{r.html}}}
</section>
{{/each}}
{{#if outcome.success}} {{#if outcome.success}}
{{#if damage}} {{#if damage}}
<section class="hbm-card-damage"> <section class="hbm-card-damage">
@@ -34,6 +40,15 @@
{{#if damage.ignoresArmor}}<span class="badge ignores-armor">{{localize 'HBM.spell.ignoresArmor'}}</span>{{/if}} {{#if damage.ignoresArmor}}<span class="badge ignores-armor">{{localize 'HBM.spell.ignoresArmor'}}</span>{{/if}}
</div> </div>
<div class="damage-formula"><code>{{damage.formula}}</code></div> <div class="damage-formula"><code>{{damage.formula}}</code></div>
{{#if damage.dice.length}}
<div class="damage-dice">
{{#each damage.dice as |d|}}
<span class="damage-die-group"><small>{{d.expression}}:</small>
{{#each d.results as |face|}}<span class="die">{{face}}</span>{{/each}}
</span>
{{/each}}
</div>
{{/if}}
{{#if damage.targets}} {{#if damage.targets}}
<div class="hbm-card-targets"> <div class="hbm-card-targets">
{{#each damage.targets as |t|}} {{#each damage.targets as |t|}}