feat: sync rules with vault 2026-10-20 (borrowed mana, Odetchnięcie limit, Oszołomiony, Akcje Natychmiastowe)

Adds src/logic/rules.ts, counters, migration 1.4.0, III/IV mana swap, thaumaturgy school, tests, recompiled translations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 13:22:31 +02:00
co-authored by Claude Sonnet 5.5
parent 6b8d93f772
commit c533ad8040
40 changed files with 640 additions and 85 deletions
+27
View File
@@ -14,6 +14,7 @@
*/
import type { CastableActor } from './spell-cast';
import { bloodAbsorbHp, bloodToZeal } from './rules';
const SELF_HARM_RATIO = 2; // HP per 1 Blood
const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present
@@ -76,6 +77,32 @@ export async function selfHarm(actor: CastableActor, hpSpent: number): Promise<{
return { ok: true, bloodGained: newBlood - blood.value };
}
/**
* Wchłanianie Krwi: spend Krew to heal Żywotność – 2 HP per Krew (Akcja, once per round);
* Większe Wchłanianie Krwi: 3 HP per Krew (Akcja Natychmiastowa), or 1 Zapał per 5 Krew.
*/
export async function absorbBlood(
actor: CastableActor,
bloodSpent: number,
opts: { greater?: boolean; asZeal?: boolean } = {},
): Promise<{ ok: boolean; hpGained: number; zealGained: number; reason?: string }> {
const a = actor.system.attributes as any;
if (bloodSpent <= 0) return { ok: true, hpGained: 0, zealGained: 0 };
if (!a.blood || a.blood.value < bloodSpent) return { ok: false, hpGained: 0, zealGained: 0, reason: 'insufficient-blood' };
const update: Record<string, number> = { 'system.attributes.blood.value': a.blood.value - bloodSpent };
let hpGained = 0;
let zealGained = 0;
if (opts.greater && opts.asZeal) {
zealGained = Math.min(bloodToZeal(bloodSpent), Math.max(0, (a.zeal?.max ?? 0) - (a.zeal?.value ?? 0)));
update['system.attributes.zeal.value'] = (a.zeal?.value ?? 0) + zealGained;
} else {
hpGained = Math.min(bloodAbsorbHp(bloodSpent, !!opts.greater), Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
update['system.attributes.health.value'] = (a.health?.value ?? 0) + hpGained;
}
await actor.update(update);
return { ok: true, hpGained, zealGained };
}
/** Life Steal: invoked from the damage-application hook when the source is a blood spell. */
export async function lifeStealOnDamage(actor: CastableActor, damageDealt: number): Promise<number> {
if (damageDealt <= 0) return 0;
+19
View File
@@ -14,6 +14,7 @@
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import type { CastableActor } from './spell-cast';
import { MANA_ELIXIR_NORMAL, MANA_ELIXIR_QUICK_DIE, manaElixirGain } from './rules';
export interface ElixirRecipe {
name: string;
@@ -61,3 +62,21 @@ export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Pr
});
return { roll, success: !!roll.ts?.isSuccess };
}
/**
* Eliksir Odnowienia Many: normal use restores 3 mana (capped at the daily pool) and
* raises Maksymalny Koszt Zaklęcia by 1 until the end of the drinker's next turn;
* quick use (Akcja Natychmiastowa) restores 1d3 mana only.
*/
export async function drinkManaElixir(actor: CastableActor, quick = false): Promise<{ gained: number }> {
const a = actor.system.attributes as any;
let amount = MANA_ELIXIR_NORMAL;
if (quick) {
const roll = new Roll(`1d${MANA_ELIXIR_QUICK_DIE}`);
await roll.evaluate();
amount = roll.total ?? 1;
}
const gained = manaElixirGain(a.mana.value, a.mana.max, amount);
await actor.update({ 'system.attributes.mana.value': a.mana.value + gained });
return { gained };
}
+1
View File
@@ -77,6 +77,7 @@ async function onApplyDamage(btn: HTMLElement): Promise<void> {
ignoreMagicalArmor: ignoresArmor,
ignoreMagicalShield: ignoresArmor,
ignorePhysicalArmor: ignoresArmor,
minDamageAfterArmor: btn.dataset.minAfterArmor === 'true',
} as any);
}
+41 -1
View File
@@ -20,8 +20,42 @@ function hasStatus(actor: any, id: string): boolean {
return false;
}
/** Statuses lasting "until the end of this character's next turn" (see conditions.ts `expires`). */
export const END_OF_NEXT_TURN_STATUSES = ['oszolomiony', 'przerazony'] as const;
const FLAG_SCOPE = 'hbm-rpg-v3';
function expiringEffects(actor: any): any[] {
const out: any[] = [];
for (const ef of actor?.effects ?? []) {
const ids: string[] = ef?.statuses ? [...ef.statuses] : [];
if (ids.some((s) => (END_OF_NEXT_TURN_STATUSES as readonly string[]).includes(s))) out.push(ef);
}
return out;
}
/** Start of the actor's turn: arm effects so they end with this turn. */
async function armExpiring(actor: any): Promise<void> {
for (const ef of expiringEffects(actor)) {
if (!ef.getFlag?.(FLAG_SCOPE, 'turnStarted')) await ef.setFlag?.(FLAG_SCOPE, 'turnStarted', true);
}
}
/** End of the actor's turn: drop armed effects. */
async function expireArmed(actor: any): Promise<void> {
const ids = expiringEffects(actor).filter((ef) => ef.getFlag?.(FLAG_SCOPE, 'turnStarted')).map((ef) => ef.id);
if (ids.length) await actor.deleteEmbeddedDocuments?.('ActiveEffect', ids);
}
export function registerCombatHooks(): void {
Hooks.on('combatRound', async (_combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
// One free Akcja Natychmiastowa per round – reset the per-round counter.
for (const c of (combat as any)?.combatants ?? []) {
const actor = c.actor;
if (actor?.system?.attributes?.immediateActionsUsed) {
await actor.update({ 'system.attributes.immediateActionsUsed': 0 });
}
}
ChatMessage.create({
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
whisper: ChatMessage.getWhisperRecipients('GM'),
@@ -29,9 +63,15 @@ export function registerCombatHooks(): void {
});
Hooks.on('combatTurn', async (combat: Combat) => {
// The previous combatant's turn just ended: expire their timed Stany.
const prevId = (combat as any).previous?.combatantId;
const prevActor = prevId ? (combat as any).combatants?.get?.(prevId)?.actor : null;
if (prevActor) await expireArmed(prevActor);
const combatant = combat.combatant;
const actor = combatant?.actor;
if (!actor) return;
await armExpiring(actor);
// Skip turn for unconscious / restrained-to-incapacitation
if (hasStatus(actor, 'nieprzytomny') || hasStatus(actor, 'obezwladniony')) {
+4 -4
View File
@@ -4,7 +4,7 @@
* consumed by the roll pipeline and combat hooks.
*/
import { SYSTEM_ID } from '../hbm';
import { SYSTEM_ID } from '../constants';
export interface ConditionDef {
id: string;
@@ -17,14 +17,14 @@ export interface ConditionDef {
const CHANGE_MODE_ADD = 2;
export const CONDITIONS: ConditionDef[] = [
{ id: 'przewrocony', i18nKey: 'HBM.conditions.przewrocony', icon: 'icons/svg/falling.svg', flags: { hbm: { prone: true } } },
{ id: 'przewrocony', i18nKey: 'HBM.conditions.przewrocony', icon: 'icons/svg/falling.svg', flags: { hbm: { prone: true, attacksWithinMeters: 3, thresholdStepsAgainst: -2, onlyDefense: 'dodge', standUpCost: 'halfMoveCeil' } } },
{ id: 'unieruchomiony', i18nKey: 'HBM.conditions.unieruchomiony', icon: 'icons/svg/net.svg', flags: { hbm: { thresholdSteps: { attack: 1 }, defenseSteps: -1 } } },
{ id: 'obezwladniony', i18nKey: 'HBM.conditions.obezwladniony', icon: 'icons/svg/blood.svg', flags: { hbm: { blocksTags: ['attack'], defenseT: 2 } } },
{ id: 'nieprzytomny', i18nKey: 'HBM.conditions.nieprzytomny', icon: 'icons/svg/unconscious.svg', flags: { hbm: { skipTurn: true, blocksAllActions: true } } },
{ id: 'umierajacy', i18nKey: 'HBM.conditions.umierajacy', icon: 'icons/svg/skull.svg', flags: { hbm: { dyingState: { failures: 0, hits: 0, mortalDamage: 0 } } } },
{ id: 'ogluszony', i18nKey: 'HBM.conditions.ogluszony', icon: 'icons/svg/deaf.svg', flags: { hbm: { blocksTags: ['hearing'] } } },
{ id: 'oslepiony', i18nKey: 'HBM.conditions.oslepiony', icon: 'icons/svg/blind.svg', flags: { hbm: { blocksTags: ['sight'] } } },
{ id: 'oszolomiony', i18nKey: 'HBM.conditions.oszolomiony', icon: 'icons/svg/daze.svg', flags: { hbm: { maxActions: 1, blockZeal: true, halveSpeed: true } } },
{ id: 'oszolomiony', i18nKey: 'HBM.conditions.oszolomiony', icon: 'icons/svg/daze.svg', flags: { hbm: { maxActions: 1, blockZeal: true, halveSpeed: true, expires: 'endOfNextTurn' } } },
{
id: 'zatruty',
i18nKey: 'HBM.conditions.zatruty',
@@ -53,7 +53,7 @@ export const CONDITIONS: ConditionDef[] = [
{ id: 'zauroczony', i18nKey: 'HBM.conditions.zauroczony', icon: 'icons/svg/heal.svg', flags: { hbm: { thresholdSteps: { social: 1 }, charmedBy: '' } } },
{ id: 'koncentracja', i18nKey: 'HBM.conditions.koncentracja', icon: 'icons/svg/aura.svg', flags: { hbm: { concentration: true, persistent: true } } },
// Provisional: book description incomplete for Przerażony (Aneks A placeholder).
{ id: 'przerazony', i18nKey: 'HBM.conditions.przerazony', icon: 'icons/svg/terror.svg', flags: { hbm: { thresholdSteps: { all: 1 }, todoBookGap: true } } },
{ id: 'przerazony', i18nKey: 'HBM.conditions.przerazony', icon: 'icons/svg/terror.svg', flags: { hbm: { fearSource: '', thresholdStepsVsSource: 1, noCloserToSource: true, expires: 'endOfNextTurn', refreshOnReapply: true } } },
// Mental illnesses (Klątwa Otchłani Ch. VIII - book chapter currently a placeholder;
// these are stub registrations with `todoBookGap: true` until full mechanics drop).
+22 -2
View File
@@ -7,11 +7,14 @@
*
* Bypass flags allow effects to skip individual layers.
*
* After resolving, if HP loss meets the Oszołomiony threshold ⌈(C+U+D)/3⌉
* the effect is auto-applied to character actors.
* After resolving, if HP loss (after reductions) is at least half of max
* Żywotność (= C+U+D) the Oszołomiony status is applied (lasts until the end
* of that character's next turn). Żywotność lost also yields a Koncentracja
* test 4:⌈⅓ lost⌉ (min 1).
*/
import { DamageType } from '../constants';
import { triggersStun, concentrationTest } from './rules';
export interface ApplyDamageOptions {
amount: number;
@@ -20,6 +23,8 @@ export interface ApplyDamageOptions {
ignoreMagicalShield?: boolean;
ignorePhysicalArmor?: boolean;
damageArmor?: boolean;
/** Magiczny Pocisk: at least 1 damage gets through Pancerz to Żywotność. */
minDamageAfterArmor?: boolean;
postChat?: boolean;
}
@@ -29,6 +34,8 @@ export interface DamageReport {
hpDamage: number;
shieldDropped: boolean;
oszolomionyApplied: boolean;
/** Dusza (Determinacja) 4:S for a concentrating target; absent when no Żywotność was lost. */
concentrationTest?: { threshold: number; successes: number };
}
interface ActorLike {
@@ -90,6 +97,10 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (absorbed > 0) {
report.absorbed.physicalArmor = absorbed;
remaining -= absorbed;
if (opts.minDamageAfterArmor && remaining === 0 && absorbed > 0) {
remaining = 1;
report.absorbed.physicalArmor = absorbed - 1;
}
}
if (opts.damageArmor && actor.type === 'character' && a.physicalArmor.condition != null) {
// Decrement durability of the first equipped armor piece (character only)
@@ -116,6 +127,15 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (Object.keys(updates).length > 0) await actor.update(updates);
// Oszołomiony: a single hit that costs ≥ half max Żywotność after all reductions.
if (triggersStun(report.hpDamage, Number(a.health?.max) || 0) && actor.toggleStatusEffect) {
const already = actor.effects?.find((e: any) => e.statuses?.has?.('oszolomiony'));
if (!already) await actor.toggleStatusEffect('oszolomiony', { active: true });
report.oszolomionyApplied = true;
}
const concentration = concentrationTest(report.hpDamage);
if (concentration) report.concentrationTest = concentration;
if (opts.postChat ?? true) {
const lines: string[] = [
`<strong>${actor.name}</strong> - ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
+16 -1
View File
@@ -4,6 +4,7 @@
* Breather (Odetchnięcie, 10 min):
* - Restore 1d6 + Wytrzymałość skill value HP (capped at max).
* - Restore 1d6 mana (capped at daily pool max).
* - At most twice between two Długie Odpoczynki (counter attributes.breathersTaken).
*
* Short Rest (Krótki Odpoczynek, 1h+):
* - Restore half of max HP.
@@ -22,6 +23,7 @@
import type { CastableActor } from './spell-cast';
import { CONDITIONS } from './conditions';
import { MAX_BREATHERS_BETWEEN_LONG_RESTS } from './rules';
export type RestKind = 'breather' | 'short' | 'long';
@@ -49,11 +51,19 @@ export interface RestResult {
bloodRestored: number;
toleranceRecovered: number;
effectsCleared: number;
/** True when an Odetchnięcie was refused (already 2 since the last Długi Odpoczynek). */
blocked?: boolean;
}
export async function rest(actor: CastableActor, kind: RestKind): Promise<RestResult> {
Hooks.callAll('hbm.beforeRest', actor, kind);
const a = actor.system.attributes as any;
if (kind === 'breather' && (a.breathersTaken ?? 0) >= MAX_BREATHERS_BETWEEN_LONG_RESTS) {
return {
kind, hpRestored: 0, manaRestored: 0, zealRestored: 0, bloodRestored: 0,
toleranceRecovered: 0, effectsCleared: 0, blocked: true,
};
}
Hooks.callAll('hbm.beforeRest', actor, kind);
const update: Record<string, unknown> = {};
const hpBefore = a.health?.value ?? 0;
@@ -69,6 +79,7 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
let toleranceRecovered = 0;
if (kind === 'breather') {
update['system.attributes.breathersTaken'] = (a.breathersTaken ?? 0) + 1;
if (a.mana?.max != null && manaBefore < a.mana.max) {
const manaRoll = new Roll('1d6');
await manaRoll.evaluate();
@@ -106,6 +117,10 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
toleranceRecovered = 1;
}
} else {
// Długi Odpoczynek resets the per-rest counters.
update['system.attributes.breathersTaken'] = 0;
update['system.attributes.manaBorrowed'] = 0;
update['system.attributes.immediateActionsUsed'] = 0;
if (a.health?.max != null && hpBefore < a.health.max) {
update['system.attributes.health.value'] = a.health.max;
hpRestored = a.health.max - hpBefore;
+174
View File
@@ -0,0 +1,174 @@
/**
* Pure rule helpers from the 2026-10-20 vault sync (Foundry Sync Brief).
* No Foundry globals here, so everything is unit-testable.
*
* Rounding convention: everything fractional rounds UP, except
* - new-spell mana cost (nearest, min 1),
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1),
* - fire damage with Hartowany w Ogniu (halved, DOWN).
*/
/** Odetchnięcie: at most twice between two Długie Odpoczynki. */
export const MAX_BREATHERS_BETWEEN_LONG_RESTS = 2;
/** Pożyczanie Many (Wyczerpanie Many) – tier table, N d6 summed. */
export const MANA_EXHAUSTION_TIERS = [
{ min: 1, max: 4, id: 'zdyszanie', label: 'Zdyszanie' },
{ min: 5, max: 8, id: 'zmeczenie', label: 'Zmęczenie' },
{ min: 9, max: 12, id: 'wyczerpanie', label: 'Wyczerpanie' },
{ min: 13, max: 16, id: 'zalamanie-sil', label: 'Załamanie Sił' },
{ min: 17, max: 20, id: 'zapasc', label: 'Zapaść' },
{ min: 21, max: 24, id: 'ciezka-zapasc', label: 'Ciężka Zapaść' },
{ min: 25, max: Infinity, id: 'wypalenie', label: 'Wypalenie' },
] as const;
export function manaExhaustionTier(total: number) {
return MANA_EXHAUSTION_TIERS.find((t) => total >= t.min && total <= t.max) ?? MANA_EXHAUSTION_TIERS[0];
}
/** Mana beyond the remaining pool that has to be "borrowed" for a cast. */
export function manaToBorrow(cost: number, poolRemaining: number): number {
return Math.max(0, cost - Math.max(0, poolRemaining));
}
/** Number of d6 for the Wyczerpanie Many roll: cumulative borrowed mana incl. this cast. */
export function exhaustionDice(borrowedSoFar: number, borrowedNow: number): number {
return Math.max(0, borrowedSoFar) + Math.max(0, borrowedNow);
}
/** Over-limit overcast: T by base cost, S = E(E+1)/2. */
export function overLimitTest(baseCost: number, excess: number): { threshold: number; successes: number } {
const threshold = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6;
return { threshold, successes: (excess * (excess + 1)) / 2 };
}
/** Nadczarowanie bonus multiplier: levels 1–2 full, level 3+ halved DOWN, min 1. */
export function overcastBonus(level: number, perLevel: number): number {
if (level <= 0) return 0;
if (level <= 2) return perLevel * level;
const full = perLevel * 2;
const tapered = Math.max(1, Math.floor(perLevel / 2));
return full + tapered * (level - 2);
}
/** Halve a rolled dice-bonus result for level 3+ overcast (DOWN, min 1). */
export function taperedDiceBonus(rolled: number): number {
return Math.max(1, Math.floor(rolled / 2));
}
/** Zasada Potężniejszych Zaklęć: nadwyżka = caster's Sukcesy − S (min 0). */
export function spellSurplus(casterSuccesses: number, required: number): number {
return Math.max(0, casterSuccesses - required);
}
export const defenderSuccessesNeeded = (surplus: number) => 1 + surplus;
export const dispelSuccessesNeeded = (surplus: number, indestructible = false) =>
2 + surplus + (indestructible ? 1 : 0);
/** Oszołomiony trigger: one hit costs at least half max Żywotność (after reductions). */
export function triggersStun(healthLost: number, maxHealth: number): boolean {
return maxHealth > 0 && healthLost > 0 && healthLost * 2 >= maxHealth;
}
/** Koncentracja test: Dusza (Determinacja) 4:S, S = ⌈⅓ lost⌉, min 1. None if nothing lost. */
export function concentrationTest(healthLost: number): { threshold: number; successes: number } | null {
if (healthLost <= 0) return null;
return { threshold: 4, successes: Math.max(1, Math.ceil(healthLost / 3)) };
}
/** Unik: positive difference means dodge failed; damage uses difference + 2. */
export function dodgeResult(attackerSuccesses: number, defenderSuccesses: number) {
const diff = attackerSuccesses - defenderSuccesses;
return { hit: diff > 0, damageSuccesses: diff > 0 ? diff + 2 : 0 };
}
/** Parowanie (T−1): parried when parrier ≥ attacker − ⌈½ Walka Wręcz⌉. */
export function parrySucceeds(parrierSuccesses: number, attackerSuccesses: number, melee: number): boolean {
return parrierSuccesses >= attackerSuccesses - Math.ceil(melee / 2);
}
/** Przewrócony: standing up costs half Ruch (rounded up). */
export function standUpCost(movement: number): number {
return Math.ceil(movement / 2);
}
/** Spowolniony / Oszołomiony: Ruch halved, rounded up. */
export function halvedMovement(movement: number): number {
return Math.ceil(movement / 2);
}
/** Szarża requires a straight Ruch of ≥ 4 m started outside the target's reach. */
export const CHARGE_MIN_DISTANCE_M = 4;
/** Immediate Actions: 1 free per round; 1 Zapał buys two extra. Returns Zapał cost of the n-th action (1-based). */
export function immediateActionZealCost(nth: number): number {
if (nth <= 1) return 0;
const extra = nth - 1;
return Math.ceil(extra / 2);
}
/** Total Zapał for n Immediate Actions in one round. */
export function immediateActionsZealTotal(n: number): number {
return n <= 1 ? 0 : Math.ceil((n - 1) / 2);
}
/** Źródło Mocy mana-passing test: T by distance 4/5/6 for <1/<2/<3 m. */
export function manaPassThreshold(distanceM: number): number | null {
if (distanceM < 1) return 4;
if (distanceM < 2) return 5;
if (distanceM < 3) return 6;
return null;
}
/** Pomoc: regular mage contributes at most ⌈½ own Maksymalny Koszt⌉. */
export function helpContributionCap(maxPerSpell: number, manaSource = false): number {
return manaSource ? maxPerSpell : Math.ceil(maxPerSpell / 2);
}
/** Wchłanianie Krwi: HP per Krew (2 base, 3 for Większe). */
export function bloodAbsorbHp(blood: number, greater = false): number {
return Math.max(0, blood) * (greater ? 3 : 2);
}
/** Większe Wchłanianie Krwi: 1 Zapał per 5 Krew. */
export function bloodToZeal(blood: number): number {
return Math.floor(Math.max(0, blood) / 5);
}
/** Eliksir Odnowienia Many: +3 mana (capped by the pool); quick use = 1d3 as Immediate Action. */
export const MANA_ELIXIR_NORMAL = 3;
export const MANA_ELIXIR_QUICK_DIE = 3;
export function manaElixirGain(current: number, max: number, amount: number): number {
return Math.max(0, Math.min(amount, max - current));
}
/** Magiczny Pocisk (and similar): damage that reaches Żywotność after Pancerz is at least 1. */
export function minOneAfterArmor(damage: number, armor: number): number {
return Math.max(1, damage - armor);
}
/** Trade (Złoto, Stal i Magia). */
export const TRADE = {
companyFee: 5000,
companyTest: { threshold: 4, successes: 3 },
bribeTiers: [
{ rate: 0.02, discount: 0.1 },
{ rate: 0.05, discount: 0.2 },
{ rate: 0.08, discount: 0.3 },
],
minBribe: 500,
thresholdFloor: 2,
negotiationSuccessesPerStep: 4,
minigameFailureCost: 0.1,
/** Unconfirmed (brief §16.3) – keep configurable. */
eurRate: 4.36,
} as const;
/** Negotiation bands for the 4:1 test: successes → step (1–2, 3–4, 5–6, 7+). */
export function negotiationBand(successes: number): number {
if (successes <= 0) return 0;
return Math.min(4, Math.ceil(successes / 2));
}
/** Przerażony: modifier on tests aimed at the fear source (T+1), 0 otherwise. */
export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
return sourceId && targetId && sourceId === targetId ? 1 : 0;
}
+43 -3
View File
@@ -16,6 +16,7 @@
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest } from './rules';
import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage';
@@ -197,6 +198,8 @@ async function renderCastCard(
})),
type: spell.system.damageType ?? 'magical',
ignoresArmor: !!spell.system.ignoresArmor,
// Magiczny Pocisk: at least 1 damage reaches Żywotność after Pancerz.
minAfterArmor: ((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) === 'magic-missile',
targets: targets.length > 0 ? targets : null,
};
}
@@ -253,11 +256,17 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
const a = actor.system.attributes;
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - manaSpent };
// Casting beyond the remaining daily pool is allowed: the missing mana is
// "borrowed" and triggers a Wyczerpanie Many roll (N d6, N = cumulative
// borrowed mana since the last Długi Odpoczynek, incl. this cast).
const borrowed = manaToBorrow(manaSpent, a.mana.value);
const updates: Record<string, any> = { 'system.attributes.mana.value': Math.max(0, a.mana.value - manaSpent) };
if (borrowed > 0) updates['system.attributes.manaBorrowed'] = (a.manaBorrowed ?? 0) + borrowed;
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
}
await actor.update(updates);
if (borrowed > 0) await rollManaExhaustion(actor, exhaustionDice(a.manaBorrowed ?? 0, borrowed));
const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool
@@ -283,8 +292,7 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
// (E) over maxPerSpell: S = E * (E + 1) / 2 (Zasada Nadczarowywania Zaklęć).
if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
const excess = manaSpent - a.mana.maxPerSpell;
const tThreshold = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6;
const ySuccesses = (excess * (excess + 1)) / 2;
const { threshold: tThreshold, successes: ySuccesses } = overLimitTest(baseCost, excess);
const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`;
const overcastRoll = HbmTSRoll.fromParams({
pool,
@@ -294,11 +302,43 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
});
await overcastRoll.evaluate();
await recordRoll(rolls, overcastRoll, overcastFlavor);
// Failure → Kara za Nieudolność: N d6, N = missing Sukcesy.
const got = Number((overcastRoll as any).ts?.successes ?? 0);
if (got < ySuccesses) await rollIncompetencePenalty(actor, ySuccesses - got, baseCost);
}
return baseRoll;
}
/** Wyczerpanie Many: roll N d6 and post the tier (tiers in Wyczerpanie Many.md). */
async function rollManaExhaustion(actor: CastableActor, dice: number): Promise<void> {
if (dice <= 0) return;
const roll = new Roll(`${dice}d6`);
await roll.evaluate();
const tier = manaExhaustionTier(roll.total ?? 0);
await ChatMessage.create({
content: `<strong>${(actor as any).name}</strong>: ${game.i18n.localize('HBM.spellCast.manaExhaustion')} (${dice}k6 = ${roll.total}) → <strong>${tier.label}</strong>`,
rolls: [roll.toJSON()],
speaker: ChatMessage.getSpeaker({ actor: actor as any }),
});
}
/** Kara za Nieudolność: N d6 where N = missing Sukcesy; tier 5–8 also drains the daily pool by the base cost. */
async function rollIncompetencePenalty(actor: CastableActor, missing: number, baseCost: number): Promise<void> {
const roll = new Roll(`${missing}d6`);
await roll.evaluate();
const total = roll.total ?? 0;
if (total >= 5 && total <= 8) {
const a = actor.system.attributes;
await actor.update({ 'system.attributes.mana.value': Math.max(0, a.mana.value - baseCost) });
}
await ChatMessage.create({
content: `<strong>${(actor as any).name}</strong>: ${game.i18n.localize('HBM.spellCast.incompetencePenalty')} (${missing}k6 = ${total})`,
rolls: [roll.toJSON()],
speaker: ChatMessage.getSpeaker({ actor: actor as any }),
});
}
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes;
const zealCost = Math.max(1, opts.zealSpent ?? 1);
+1 -1
View File
@@ -17,7 +17,7 @@
* unless they map to a known machine effect ID.
*/
import { SYSTEM_ID } from '../hbm';
import { SYSTEM_ID } from '../constants';
export interface SpellTrigger {
event: string;
+2 -1
View File
@@ -100,7 +100,8 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) {
if (manaSpent > Number(a.mana?.value ?? 0)) {
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' });
// Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many).
warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' });
}
}
if (opts.castAsPrayer) {
+1
View File
@@ -63,3 +63,4 @@ export async function smuggling(actor: CastableActor, params: TradeTestParams =
}
return roll;
}
export { TRADE, negotiationBand } from './rules';