Files
hbm-foundry-system/src/logic/rules.ts
T

262 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
/**
* Runiczny Pancerz – points disabled by ONE hit (hits are never added together).
* Default: a hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point.
* Shield-breaking effects (`step` 2): every 2 damage disable 1 point.
* Disabled points return at the end of the fight or when restored; only Rozerwanie Run
* destroys a point permanently.
*/
export function runicArmorPointsDisabled(damage: number, step?: number): number {
const d = Math.max(0, Math.floor(damage));
if (step && step > 0 && step !== DEFAULT_RUNIC_THRESHOLD) return Math.floor(d / step);
return d > DEFAULT_RUNIC_THRESHOLD ? 1 : 0;
}
export const DEFAULT_RUNIC_THRESHOLD = 5;
/** Damage profile of spells that treat the magical defences non-standardly (by spell slug). */
export interface SpellDamageProfile {
ignoreMagicalArmor?: boolean;
ignoreMagicalShield?: boolean;
ignorePhysicalArmor?: boolean;
/** 2 → every 2 (instead of 5) damage disable a Runiczny Pancerz point. */
runicArmorStep?: number;
/** Damage counts this many times against Magiczna Osłona. */
shieldMultiplier?: number;
/** Fully destroys Magiczne Tarcze and disables all Runiczny Pancerz (Prawdziwa Dezintegracja). */
destroyMagicalDefenses?: boolean;
}
const SHIELD_BREAKER: SpellDamageProfile = { runicArmorStep: 2, shieldMultiplier: 2 };
export const SPELL_DAMAGE_PROFILES: Readonly<Record<string, SpellDamageProfile>> = {
'ether-pierce': { ...SHIELD_BREAKER, ignorePhysicalArmor: true },
'divine-spear': { ...SHIELD_BREAKER, ignorePhysicalArmor: true },
'energy-wave': SHIELD_BREAKER,
'mysterious-stream': SHIELD_BREAKER,
'arrow-of-desire': SHIELD_BREAKER,
'last-judgment': SHIELD_BREAKER,
'crimson-bolt': { ignoreMagicalArmor: true, ignoreMagicalShield: true },
'true-disintegration': { ignorePhysicalArmor: true, destroyMagicalDefenses: true },
};
/** Krew – sources (Arcanum Sanguinis). */
export type KrewTalent = 'respect-for-life' | 'krwisty-pojedynkowicz' | 'rytualista-krwi';
/** Samookaleczenie: lose HP = Sukcesy; 1 Krew per HP (2 with Większe Samookaleczenie). */
export function krewFromSelfHarm(hpLost: number, greater = false): number {
return Math.max(0, hpLost) * (greater ? 2 : 1);
}
/** Kradzież Życia: 1 Krew per HP the target actually lost (2 with the greater version). */
export function krewFromLifeSteal(hpLost: number, greater = false): number {
return Math.max(0, hpLost) * (greater ? 2 : 1);
}
/** Złożenie w Ofierze: heal Sukcesy + Magia, gain Krew = Sukcesy. */
export function sacrificeResult(successes: number, magic: number): { heal: number; krew: number } {
return { heal: Math.max(0, successes) + Math.max(0, magic), krew: Math.max(0, successes) };
}
/**
* Wielka Ofiara: Sukcesy above the target's highest Atrybut kill it (all its Żywotność is lost),
* otherwise the mage heals Sukcesy. Krew gained = Żywotność the target lost.
*/
export function greatSacrificeResult(successes: number, highestAttribute: number, targetHealth: number): { kills: boolean; heal: number; krew: number } {
if (successes > highestAttribute) return { kills: true, heal: 0, krew: Math.max(0, targetHealth) };
return { kills: false, heal: Math.max(0, successes), krew: 0 };
}
/** Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (cutting/piercing only). */
export function krewFromWeaponHpLoss(hpLost: number, talent: KrewTalent | null): number {
if (talent === 'respect-for-life') return Math.floor(Math.max(0, hpLost) / 4);
if (talent === 'krwisty-pojedynkowicz') return Math.floor(Math.max(0, hpLost) / 2);
return 0;
}
/** Krew at the start of a fight: Rytualista Krwi 4, everyone else 0. */
export function startingKrew(talent: KrewTalent | null): number {
return talent === 'rytualista-krwi' ? 4 : 0;
}
/** Runiczny Pancerz (Amulet Osłony): recharging costs 2 Mana per point, as an Akcja Natychmiastowa. */
export const RUNIC_ARMOR_MANA_PER_POINT = 2;
/** Points of Runiczny Pancerz that can actually be restored (never above max, never negative). */
export function runicArmorRestorable(value: number, max: number, requested: number): number {
return Math.max(0, Math.min(Math.floor(requested), max - value));
}
/** Mana needed to restore `points` points of Runiczny Pancerz. */
export function runicArmorManaCost(points: number): number {
return Math.max(0, points) * RUNIC_ARMOR_MANA_PER_POINT;
}
/** 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;
}