Sync parsers and game mechanics with vault changes (2026-08-18 to 08-23)
- talent-parser.ts, discipline-parser.ts, race-parser.ts: rebuilt to read from the vault's post-bab5a62 per-topic-file layout (rulebook monoliths were split into per-chapter subfolders; discipline/race writeups moved to their canonical disciplines/ and races/ folders). All three parsers were previously producing zero or broken output for their packs. - constants.ts, actor-character.ts, actor-npc.ts, rest.ts: mana is now a daily pool (Poziom Mocy x3) restored via rest tiers, not a per-round budget; Zywotnosc is 2x(Cialo+Umysl+Dusza), not 3x; rest recovery amounts updated to match Odetchniecie/Krotki Odpoczynek/Dlugi Odpoczynek. - combat.ts: removed the now-obsolete per-round mana reset hook. - actor-npc.ts: Initiative now includes Refleks/Spostrzegawczosc skill bonuses (was missing entirely); Magia attribute constrained to valid 1k10 character-creation values. - fields.ts: added `choices` support to makeIntField for the above. - spell-cast.ts: overcast test now uses the correct T-threshold tiers and triangular S = E*(E+1)/2 required-successes formula (Zasada Nadczarowywania Zaklec). Not implemented (flagged as new features, out of scope for a sync fix): mana-exhaustion/overcast-failure penalty tables, overcast bonus taper past step 2, shield-breaking damage mechanic, elixir-weapon-coating system. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+15
-13
@@ -79,28 +79,30 @@ export const SKILL_KEYS = Object.freeze(Object.keys(SKILLS)) as readonly string[
|
||||
|
||||
/**
|
||||
* Magic Power Level lookup table (level 0..10 → I..X).
|
||||
* Drives dice pool size, max mana per single spell, and total mana per round.
|
||||
* Drives dice pool size, max mana per single spell, and the daily mana pool
|
||||
* ("Ilość Many" - there is no more per-round mana limit, see `rules/00.
|
||||
* Podręcznik Gry/Rozdział II - Tworzenie Postaci/Atrybuty.md`).
|
||||
*/
|
||||
export interface MagicPowerEntry {
|
||||
level: number; // 0..10
|
||||
label: string; // '0' | 'I' | 'II' | ... | 'X'
|
||||
dicePool: number; // dice added when casting
|
||||
maxPerSpell: number; // max mana spent on one spell
|
||||
manaPerRound: number; // mana budget per combat round
|
||||
manaPerDay: number; // daily mana pool ("Ilość Many")
|
||||
}
|
||||
|
||||
export const MAGIC_POWER_TABLE: readonly MagicPowerEntry[] = Object.freeze([
|
||||
{ level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerRound: 20 },
|
||||
{ level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerRound: 12 },
|
||||
{ level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerRound: 10 },
|
||||
{ level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerRound: 7 },
|
||||
{ level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerRound: 8 },
|
||||
{ level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerRound: 6 },
|
||||
{ level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerRound: 6 },
|
||||
{ level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerRound: 4 },
|
||||
{ level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerRound: 3 },
|
||||
{ level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerRound: 2 },
|
||||
{ level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerRound: 1 },
|
||||
{ level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerDay: 60 },
|
||||
{ level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerDay: 36 },
|
||||
{ level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerDay: 30 },
|
||||
{ level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerDay: 21 },
|
||||
{ level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerDay: 24 },
|
||||
{ level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerDay: 18 },
|
||||
{ level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerDay: 18 },
|
||||
{ level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerDay: 12 },
|
||||
{ level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerDay: 9 },
|
||||
{ level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerDay: 6 },
|
||||
{ level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerDay: 3 },
|
||||
]);
|
||||
|
||||
export function getMagicPowerEntry(level: number): MagicPowerEntry {
|
||||
|
||||
@@ -12,10 +12,10 @@ import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from
|
||||
* Player Character data model.
|
||||
*
|
||||
* Derived stats (computed in prepareDerivedData):
|
||||
* - attributes.health.max = 3 * (body + mind + soul)
|
||||
* - attributes.health.max = 2 * (body + mind + soul)
|
||||
* - attributes.zeal.max = ceil(soul / 2)
|
||||
* - attributes.initiative = mind + skills.reflex.value + skills.perception.value
|
||||
* - attributes.mana.max / .maxPerSpell / magic.dicePool - from MAGIC_POWER_TABLE
|
||||
* - attributes.mana.max (daily pool) / .maxPerSpell / magic.dicePool - from MAGIC_POWER_TABLE
|
||||
*/
|
||||
export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
@@ -163,7 +163,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
const a = sys.attributes;
|
||||
|
||||
a.health.max = 3 * (a.body.value + a.mind.value + a.soul.value);
|
||||
a.health.max = 2 * (a.body.value + a.mind.value + a.soul.value);
|
||||
if (a.health.value > a.health.max) a.health.value = a.health.max;
|
||||
|
||||
a.zeal.max = Math.ceil(a.soul.value / 2);
|
||||
@@ -190,7 +190,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
const mp = getMagicPowerEntry(a.magic.actual);
|
||||
a.magic.dicePool = mp.dicePool;
|
||||
a.mana.max = mp.manaPerRound;
|
||||
a.mana.max = mp.manaPerDay;
|
||||
a.mana.maxPerSpell = mp.maxPerSpell;
|
||||
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
mind: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
soul: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
magic: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0, max: 10 }),
|
||||
// Valid 1k10 character-creation values only (Atrybuty.md).
|
||||
value: makeIntField(0, { choices: [0, 1, 2, 3, 4, 6] }),
|
||||
actual: makeIntField(0, { min: 0, max: 10 }), // derived
|
||||
}),
|
||||
mana: new f.SchemaField({
|
||||
@@ -112,6 +113,7 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
magicalShield: { value: number; max: number };
|
||||
initiative: { value: number; bonus: number };
|
||||
};
|
||||
skills: Record<string, { value: number }>;
|
||||
};
|
||||
const a = sys.attributes;
|
||||
|
||||
@@ -134,7 +136,7 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
// Derive NPC mana from MAGIC_POWER_TABLE
|
||||
const mp = getMagicPowerEntry(a.magic.actual);
|
||||
a.mana.max = mp.manaPerRound;
|
||||
a.mana.max = mp.manaPerDay;
|
||||
a.mana.maxPerSpell = mp.maxPerSpell;
|
||||
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
|
||||
|
||||
@@ -154,6 +156,8 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
a.initiative.value =
|
||||
a.mind.value +
|
||||
(sys.skills.reflex?.value ?? 0) +
|
||||
(sys.skills.perception?.value ?? 0) +
|
||||
initiativeBonus +
|
||||
(a.initiative.bonus ?? 0);
|
||||
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@
|
||||
// Foundry exposes fields globally; declared via fvtt-types.
|
||||
export const fields = (): typeof foundry.data.fields => foundry.data.fields;
|
||||
|
||||
export function makeIntField(initial = 0, options: Partial<{ min: number; max: number; nullable: boolean }> = {}) {
|
||||
export function makeIntField(initial = 0, options: Partial<{ min: number; max: number; nullable: boolean; choices: readonly number[] }> = {}) {
|
||||
const f = fields();
|
||||
return new f.NumberField({
|
||||
required: true,
|
||||
@@ -16,6 +16,7 @@ export function makeIntField(initial = 0, options: Partial<{ min: number; max: n
|
||||
initial,
|
||||
min: options.min,
|
||||
max: options.max,
|
||||
choices: options.choices as number[] | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+6
-9
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Combat hooks - handle per-round Mana reset, per-turn Zeal regen,
|
||||
* and condition-driven turn behavior (skip / damage tick / death save).
|
||||
* Combat hooks - handle per-turn Zeal regen and condition-driven turn
|
||||
* behavior (skip / damage tick / death save).
|
||||
*
|
||||
* Mana is a daily pool (see `constants.ts` MAGIC_POWER_TABLE), not a
|
||||
* per-round budget, so it is intentionally NOT reset on `combatRound`.
|
||||
*/
|
||||
|
||||
import { applyDamage } from './damage';
|
||||
@@ -18,13 +21,7 @@ function hasStatus(actor: any, id: string): boolean {
|
||||
}
|
||||
|
||||
export function registerCombatHooks(): void {
|
||||
Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
|
||||
for (const combatant of combat.combatants) {
|
||||
const actor = combatant.actor;
|
||||
if (!actor || actor.type !== 'character') continue;
|
||||
const sys = actor.system as { attributes: { mana: { max: number; value: number } } };
|
||||
await actor.update({ 'system.attributes.mana.value': sys.attributes.mana.max });
|
||||
}
|
||||
Hooks.on('combatRound', async (_combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
|
||||
ChatMessage.create({
|
||||
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
|
||||
whisper: ChatMessage.getWhisperRecipients('GM'),
|
||||
|
||||
+20
-8
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Rest logic.
|
||||
*
|
||||
* Short Rest (Krótki Odpoczynek):
|
||||
* - Restore HP equal to actor.body.value (capped at max).
|
||||
* - Restore mana to max-per-spell? - no: short rest does NOT restore mana.
|
||||
* Breather (Odetchnięcie, 10 min):
|
||||
* - Restore 1d6 + Wytrzymałość skill value HP (capped at max).
|
||||
* - Restore 1d6 mana (capped at daily pool max).
|
||||
*
|
||||
* Short Rest (Krótki Odpoczynek, 1h+):
|
||||
* - Restore half of max HP.
|
||||
* - Restore one third of the daily mana pool.
|
||||
* - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance.
|
||||
*
|
||||
* Long Rest (Długi Odpoczynek):
|
||||
@@ -66,8 +70,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
|
||||
|
||||
if (kind === 'breather') {
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
const manaRoll = new Roll('1d6');
|
||||
await manaRoll.evaluate();
|
||||
const manaGain = Math.min(manaRoll.total, a.mana.max - manaBefore);
|
||||
if (manaGain > 0) {
|
||||
update['system.attributes.mana.value'] = manaBefore + manaGain;
|
||||
manaRestored = manaGain;
|
||||
}
|
||||
}
|
||||
const endurance = actor.system.skills?.endurance?.value ?? 0;
|
||||
const roll = new Roll('1d6 + @endurance', { endurance });
|
||||
@@ -80,10 +89,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
|
||||
}
|
||||
} else if (kind === 'short') {
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
const manaGain = Math.min(Math.ceil(a.mana.max / 3), a.mana.max - manaBefore);
|
||||
if (manaGain > 0) {
|
||||
update['system.attributes.mana.value'] = manaBefore + manaGain;
|
||||
manaRestored = manaGain;
|
||||
}
|
||||
}
|
||||
const healAmount = Math.ceil((a.health?.max ?? 0) / 3);
|
||||
const healAmount = Math.ceil((a.health?.max ?? 0) / 2);
|
||||
const heal = Math.min(healAmount, (a.health?.max ?? 0) - hpBefore);
|
||||
if (heal > 0) {
|
||||
update['system.attributes.health.value'] = hpBefore + heal;
|
||||
|
||||
@@ -250,11 +250,13 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
|
||||
await baseRoll.evaluate();
|
||||
await baseRoll.toMessage({ flavor, speaker: opts.speaker });
|
||||
|
||||
// Overcast - extra TS test if manaSpent exceeds maxPerSpell.
|
||||
// Overcast beyond Maksymalny Koszt Zaklęcia - extra TS test.
|
||||
// T depends on the spell's BASE cost; S grows triangularly with the excess
|
||||
// (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 = Math.min(6, Math.max(2, baseCost));
|
||||
const ySuccesses = Math.min(10, Math.max(1, excess));
|
||||
const tThreshold = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6;
|
||||
const ySuccesses = (excess * (excess + 1)) / 2;
|
||||
const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`;
|
||||
const overcastRoll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
|
||||
Reference in New Issue
Block a user