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:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -63,3 +63,4 @@ export async function smuggling(actor: CastableActor, params: TradeTestParams =
|
||||
}
|
||||
return roll;
|
||||
}
|
||||
export { TRADE, negotiationBand } from './rules';
|
||||
|
||||
Reference in New Issue
Block a user