feat: weapon wound kind (1.5.1) #6

Merged
octoturge merged 2 commits from feat/weapon-wound-kind into feat/blood-magic-runic-vault-answers 2026-10-22 21:32:37 +02:00
12 changed files with 76 additions and 49 deletions
Showing only changes of commit a5f2683d15 - Show all commits
+9 -3
View File
@@ -8,11 +8,17 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [1.5.1] - 2026-10-22 ## [1.5.1] - 2026-10-22
### Added ### Added
- Weapons have a wound kind (`system.weapon.woundKind`: tnąca / kłująca / obuchowa, empty = unspecified), editable on the gear sheet. - Weapons have wound kinds (`system.weapon.woundKinds`: `slashing` sieczne / `piercing` kłute / `blunt` obuchowe, several allowed, empty = unspecified), editable on the gear sheet as a multi-select. Matches Foundry Sync Brief §18.
- Gear parser reads `wound` / `rana` frontmatter from vault weapon notes. Missing or unknown values stay unspecified; nothing is guessed. - Gear parser reads `wound` (string, comma list or YAML list; Polish or English words) from vault weapon notes. Unknown values are dropped; nothing is guessed.
- Packs rebuilt from the current vault: Sztylet piercing+slashing, Bicz slashing, Laska blunt, bows and Włócznia piercing; generic one-/two-handed entries list all three (pick the real type per weapon).
### Changed
- `applyDamage` takes `woundKinds` (array) instead of a single kind; passive Krew applies when any is slashing or piercing. Blunt (and unarmed) never gives Krew.
- Vault sync of 2026-10-22 needs no further logic changes: runic armour wording, Zakazana Osłona retaliation, Rozerwanie Run artifact destruction and free Immediate Action for Kradzież Życia / Złożenie w Ofierze were already covered in 1.5.0 (spell, talent and discipline text arrives through the rebuilt packs).
### Notes ### Notes
- Only cutting and piercing weapons feed passive Krew; unspecified weapons give none. Callers of `applyDamage` still need to pass the weapon's `woundKind`. - Callers of `applyDamage` still need to pass the equipped weapon's `woundKinds`.
- Kości Losu use is now an Immediate Action that counts toward the limit (vault `Zużywanie Ładunków`); the system has no Fate-dice action code yet.
## [1.5.0] - 2026-10-20 ## [1.5.0] - 2026-10-20
+3 -3
View File
@@ -239,10 +239,10 @@
"physical": "Physical", "physical": "Physical",
"magical": "Magical", "magical": "Magical",
"environmental": "Environmental", "environmental": "Environmental",
"woundKind": "Wound kind", "woundKinds": "Wound kind",
"woundKindsHint": "Several allowed. Only slashing and piercing wounds grant talent Krew (Respect for Life, Crimson Duelist). Empty = unspecified.",
"wound": { "wound": {
"none": "Unspecified", "slashing": "Slashing",
"cutting": "Cutting",
"piercing": "Piercing", "piercing": "Piercing",
"blunt": "Blunt" "blunt": "Blunt"
}, },
+5 -5
View File
@@ -259,12 +259,12 @@
"physical": "Fizyczne", "physical": "Fizyczne",
"magical": "Magiczne", "magical": "Magiczne",
"environmental": "Środowiskowe", "environmental": "Środowiskowe",
"woundKind": "Rodzaj rany", "woundKinds": "Rodzaj rany",
"woundKindsHint": "Można wybrać kilka. Tylko rany sieczne i kłute dają Krew z talentów (Szacunek do Życia, Krwisty Pojedynkowicz). Puste = nieokreślony.",
"wound": { "wound": {
"none": "Nieokreślony", "slashing": "Sieczne",
"cutting": "Tnąca", "piercing": "Kłute",
"piercing": "Kłująca", "blunt": "Obuchowe"
"blunt": "Obuchowa"
}, },
"properties": "Właściwości", "properties": "Właściwości",
"damageReduction": "Redukcja Obrażeń", "damageReduction": "Redukcja Obrażeń",
+30 -10
View File
@@ -38,13 +38,25 @@ export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc
return docs; return docs;
}; };
/** Vault `wound` / `rana` frontmatter → woundKind ('' when absent or unknown – never guessed). */ type WoundKind = 'slashing' | 'piercing' | 'blunt';
export function parseWoundKind(raw: string | undefined): '' | 'cutting' | 'piercing' | 'blunt' {
const v = (raw ?? '').trim().toLowerCase(); /**
if (['cutting', 'tnąca', 'tnaca', 'tnący', 'tnacy'].includes(v)) return 'cutting'; * Vault `wound` frontmatter → woundKinds. Accepts one value, a comma list ("piercing, slashing"),
if (['piercing', 'kłująca', 'klujaca', 'kłujący', 'klujacy'].includes(v)) return 'piercing'; * or a YAML list (flattened to a comma list by extractMarkdown). Unknown values are dropped,
if (['blunt', 'obuchowa', 'obuchowy', 'miażdżąca', 'miazdzaca'].includes(v)) return 'blunt'; * absent stays empty – never guessed.
return ''; */
export function parseWoundKinds(raw: string | undefined): WoundKind[] {
const map: Record<string, WoundKind> = {
slashing: 'slashing', sieczne: 'slashing', sieczna: 'slashing',
piercing: 'piercing', kłute: 'piercing', kłuta: 'piercing', kluta: 'piercing', kłujące: 'piercing',
blunt: 'blunt', obuchowe: 'blunt', obuchowa: 'blunt',
};
const out: WoundKind[] = [];
for (const part of (raw ?? '').split(',')) {
const kind = map[part.trim().toLowerCase()];
if (kind && !out.includes(kind)) out.push(kind);
}
return out;
} }
function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } { function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
@@ -57,9 +69,17 @@ function extractMarkdown(raw: string): { frontmatter: Record<string, string>; co
const fmText = raw.slice(3, endIdx).trim(); const fmText = raw.slice(3, endIdx).trim();
content = raw.slice(endIdx + 3).trim(); content = raw.slice(endIdx + 3).trim();
const lines = fmText.split('\n'); const lines = fmText.split(/\r?\n/);
let listKey = '';
for (const line of lines) { for (const line of lines) {
// "key:" followed by " - item" lines → comma-joined value.
const item = line.match(/^\s+-\s+(.*)$/);
if (item && listKey) {
fm[listKey] = fm[listKey] ? `${fm[listKey]}, ${item[1].trim()}` : item[1].trim();
continue;
}
const colonIdx = line.indexOf(':'); const colonIdx = line.indexOf(':');
listKey = colonIdx > -1 && !line.slice(colonIdx + 1).trim() ? line.slice(0, colonIdx).trim() : '';
if (colonIdx > -1) { if (colonIdx > -1) {
const key = line.slice(0, colonIdx).trim(); const key = line.slice(0, colonIdx).trim();
let val = line.slice(colonIdx + 1).trim(); let val = line.slice(colonIdx + 1).trim();
@@ -100,7 +120,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
weapon: { weapon: {
damage: '', damage: '',
damageType: 'physical', damageType: 'physical',
woundKind: '', woundKinds: [],
properties: [], properties: [],
}, },
armor: { armor: {
@@ -115,7 +135,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
if (category === 'weapon') { if (category === 'weapon') {
system.weapon.damage = fm.damage || fm.obrazenia || ''; system.weapon.damage = fm.damage || fm.obrazenia || '';
system.weapon.woundKind = parseWoundKind(fm.wound || fm.rana); system.weapon.woundKinds = parseWoundKinds(fm.wound || fm.rana);
if (fm.traits || fm.cechy) { if (fm.traits || fm.cechy) {
system.weapon.properties = parseList(fm.traits || fm.cechy); system.weapon.properties = parseList(fm.traits || fm.cechy);
} }
+3 -2
View File
@@ -192,8 +192,9 @@ export type GearCategory = (typeof GEAR_CATEGORIES)[number];
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const; export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
export type DamageType = (typeof DAMAGE_TYPES)[number]; export type DamageType = (typeof DAMAGE_TYPES)[number];
/** Weapon wound kinds ('' = unspecified). Cutting / piercing feed the passive Krew talents. */ /** Weapon wound kinds (sieczne / kłute / obuchowe). A weapon may have several; none = unspecified.
export const WOUND_KINDS = ['', 'cutting', 'piercing', 'blunt'] as const; * Only slashing and piercing wounds feed the passive Krew talents. */
export const WOUND_KINDS = ['slashing', 'piercing', 'blunt'] as const;
export type WoundKind = (typeof WOUND_KINDS)[number]; export type WoundKind = (typeof WOUND_KINDS)[number];
/** Armor types */ /** Armor types */
+2 -2
View File
@@ -39,8 +39,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
choices: DAMAGE_TYPES as unknown as readonly string[], choices: DAMAGE_TYPES as unknown as readonly string[],
}), }),
properties: makeArrayField(new f.StringField({ blank: false })), properties: makeArrayField(new f.StringField({ blank: false })),
// Rodzaj rany: cutting (tnąca) / piercing (kłująca) / blunt (obuchowa); '' = not specified. // Rodzaj rany: slashing (sieczne) / piercing (kłute) / blunt (obuchowe); several allowed, empty = unspecified.
woundKind: makeStringField('', { blank: true, choices: WOUND_KINDS as unknown as readonly string[] }), woundKinds: makeArrayField(new f.StringField({ blank: false, choices: WOUND_KINDS as unknown as string[] })),
}), }),
armor: new f.SchemaField({ armor: new f.SchemaField({
+2 -2
View File
@@ -180,7 +180,7 @@ export async function resolveLifeSteal(actor: CastableActor, hpActuallyLost: num
/** /**
* Złożenie w Ofierze: 1 Zapał, free Akcja Natychmiastowa, once per round. Triggers when your attack * Złożenie w Ofierze: 1 Zapał, free Akcja Natychmiastowa, once per round. Triggers when your attack
* kills a target with a cutting / piercing wound within 1 m. `successes` are the Sukcesy of the * kills a target with a slashing / piercing wound within 1 m. `successes` are the Sukcesy of the
* killing attack's Test: heal Sukcesy + Magia, gain Krew = Sukcesy. * killing attack's Test: heal Sukcesy + Magia, gain Krew = Sukcesy.
*/ */
export async function sacrifice(actor: CastableActor, successes: number): Promise<BloodActionResult> { export async function sacrifice(actor: CastableActor, successes: number): Promise<BloodActionResult> {
@@ -256,7 +256,7 @@ export async function absorbBlood(
/** /**
* Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz), * Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz),
* by either side, only from cutting or piercing weapons. Call for the victim and the attacker. * by either side, only from slashing or piercing weapons. Call for the victim and the attacker.
*/ */
export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise<number> { export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise<number> {
const blood = (actor.system.attributes as any)?.blood; const blood = (actor.system.attributes as any)?.blood;
+4 -3
View File
@@ -35,7 +35,8 @@ export interface ApplyDamageOptions {
/** Rozerwanie Run: permanently destroys N Runiczny Pancerz points (lowers its maximum). */ /** Rozerwanie Run: permanently destroys N Runiczny Pancerz points (lowers its maximum). */
destroyRunicPoints?: number; destroyRunicPoints?: number;
/** Wound kind of a weapon hit (gear data has none yet – callers pass it); drives passive Krew. */ /** Wound kind of a weapon hit (gear data has none yet – callers pass it); drives passive Krew. */
woundKind?: 'cutting' | 'piercing' | 'blunt'; /** Wound kinds of the attacking weapon; only slashing / piercing give passive Krew. */
woundKinds?: ('slashing' | 'piercing' | 'blunt')[];
/** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */ /** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */
sourceActor?: any; sourceActor?: any;
postChat?: boolean; postChat?: boolean;
@@ -161,8 +162,8 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (Object.keys(updates).length > 0) await actor.update(updates); if (Object.keys(updates).length > 0) await actor.update(updates);
// Passive Krew from cutting / piercing weapons (talents): victim and attacker side. // Passive Krew from slashing / piercing weapons (talents): victim and attacker side.
if (report.hpDamage > 0 && (opts.woundKind === 'cutting' || opts.woundKind === 'piercing')) { if (report.hpDamage > 0 && (opts.woundKinds ?? []).some((k) => k === 'slashing' || k === 'piercing')) {
report.krewGained = { report.krewGained = {
victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage), victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage),
source: opts.sourceActor ? await gainKrewFromWeaponDamage(opts.sourceActor, report.hpDamage) : 0, source: opts.sourceActor ? await gainKrewFromWeaponDamage(opts.sourceActor, report.hpDamage) : 0,
+1 -1
View File
@@ -196,7 +196,7 @@ export function greatSacrificeResult(successes: number, highestAttribute: number
if (successes > highestAttribute) return { kills: true, heal: 0, krew: Math.max(0, targetHealth) }; if (successes > highestAttribute) return { kills: true, heal: 0, krew: Math.max(0, targetHealth) };
return { kills: false, heal: Math.max(0, successes), krew: 0 }; 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). */ /** Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (slashing/piercing only). */
export function krewFromWeaponHpLoss(hpLost: number, talent: KrewTalent | null): number { export function krewFromWeaponHpLoss(hpLost: number, talent: KrewTalent | null): number {
if (talent === 'respect-for-life') return Math.floor(Math.max(0, hpLost) / 4); 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); if (talent === 'krwisty-pojedynkowicz') return Math.floor(Math.max(0, hpLost) / 2);
+2
View File
@@ -9,6 +9,7 @@ import {
AOE_SHAPES, AOE_SHAPES,
SPELL_DAMAGE_TYPES, SPELL_DAMAGE_TYPES,
TRIGGER_EVENTS, TRIGGER_EVENTS,
WOUND_KINDS,
} from '../constants'; } from '../constants';
const { ItemSheetV2 } = foundry.applications.sheets as unknown as { const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
@@ -67,6 +68,7 @@ export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
itemType: item.type, itemType: item.type,
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs', bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
choices: buildChoiceMaps(), choices: buildChoiceMaps(),
woundChoices: Object.fromEntries(WOUND_KINDS.map((k) => [k, `HBM.gear.wound.${k}`])),
effects, effects,
supportsEffects, supportsEffects,
}; };
+4 -7
View File
@@ -31,13 +31,10 @@
<option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option> <option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option>
</select> </select>
</label> </label>
<label>{{localize 'HBM.gear.woundKind'}}: <label title="{{localize 'HBM.gear.woundKindsHint'}}">{{localize 'HBM.gear.woundKinds'}}:
<select name="system.weapon.woundKind"> <multi-select name="system.weapon.woundKinds">
<option value="" {{#if (eq system.weapon.woundKind '')}}selected{{/if}}>{{localize 'HBM.gear.wound.none'}}</option> {{selectOptions woundChoices selected=system.weapon.woundKinds localize=true}}
<option value="cutting" {{#if (eq system.weapon.woundKind 'cutting')}}selected{{/if}}>{{localize 'HBM.gear.wound.cutting'}}</option> </multi-select>
<option value="piercing" {{#if (eq system.weapon.woundKind 'piercing')}}selected{{/if}}>{{localize 'HBM.gear.wound.piercing'}}</option>
<option value="blunt" {{#if (eq system.weapon.woundKind 'blunt')}}selected{{/if}}>{{localize 'HBM.gear.wound.blunt'}}</option>
</select>
</label> </label>
</fieldset> </fieldset>
{{/if}} {{/if}}
+11 -11
View File
@@ -1,17 +1,17 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseWoundKind } from '../../scripts/parsers/gear-parser'; import { parseWoundKinds } from '../../scripts/parsers/gear-parser';
describe('gear-parser parseWoundKind', () => { describe('gear-parser parseWoundKinds', () => {
it('maps Polish and English labels', () => { it('maps English and Polish labels, single or listed', () => {
expect(parseWoundKind('tnąca')).toBe('cutting'); expect(parseWoundKinds('slashing')).toEqual(['slashing']);
expect(parseWoundKind('Kłująca')).toBe('piercing'); expect(parseWoundKinds('kłute')).toEqual(['piercing']);
expect(parseWoundKind('obuchowa')).toBe('blunt'); expect(parseWoundKinds('piercing, slashing')).toEqual(['piercing', 'slashing']);
expect(parseWoundKind('piercing')).toBe('piercing'); expect(parseWoundKinds('slashing, piercing, blunt, slashing')).toEqual(['slashing', 'piercing', 'blunt']);
}); });
it('never guesses: missing or unknown stays unspecified', () => { it('never guesses: missing or unknown stays empty', () => {
expect(parseWoundKind(undefined)).toBe(''); expect(parseWoundKinds(undefined)).toEqual([]);
expect(parseWoundKind('')).toBe(''); expect(parseWoundKinds('')).toEqual([]);
expect(parseWoundKind('ognista')).toBe(''); expect(parseWoundKinds('ognista')).toEqual([]);
}); });
}); });