feat: weapon wound kind (1.5.1) #6
+9
-3
@@ -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
@@ -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
@@ -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ń",
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user