feat: weapon wound kinds (1.5.1) #7
@@ -5,6 +5,21 @@ All notable changes to the **HbM RPG v3** Foundry VTT system.
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.5.1] - 2026-10-22
|
||||
|
||||
### Added
|
||||
- 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` (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
|
||||
- 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
|
||||
|
||||
Vault answers (Foundry Sync Brief §17): blood magic and Runiczny Pancerz.
|
||||
|
||||
@@ -239,6 +239,13 @@
|
||||
"physical": "Physical",
|
||||
"magical": "Magical",
|
||||
"environmental": "Environmental",
|
||||
"woundKinds": "Wound kind",
|
||||
"woundKindsHint": "Several allowed. Only slashing and piercing wounds grant talent Krew (Respect for Life, Crimson Duelist). Empty = unspecified.",
|
||||
"wound": {
|
||||
"slashing": "Slashing",
|
||||
"piercing": "Piercing",
|
||||
"blunt": "Blunt"
|
||||
},
|
||||
"properties": "Properties",
|
||||
"damageReduction": "Damage Reduction",
|
||||
"damageReductionBonus": "DR Bonus",
|
||||
|
||||
@@ -259,6 +259,13 @@
|
||||
"physical": "Fizyczne",
|
||||
"magical": "Magiczne",
|
||||
"environmental": "Środowiskowe",
|
||||
"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": {
|
||||
"slashing": "Sieczne",
|
||||
"piercing": "Kłute",
|
||||
"blunt": "Obuchowe"
|
||||
},
|
||||
"properties": "Właściwości",
|
||||
"damageReduction": "Redukcja Obrażeń",
|
||||
"damageReductionBonus": "Premia do Redukcji",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hbm-rpg-v3-foundry",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -38,6 +38,27 @@ export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc
|
||||
return docs;
|
||||
};
|
||||
|
||||
type WoundKind = 'slashing' | 'piercing' | 'blunt';
|
||||
|
||||
/**
|
||||
* Vault `wound` frontmatter → woundKinds. Accepts one value, a comma list ("piercing, slashing"),
|
||||
* or a YAML list (flattened to a comma list by extractMarkdown). Unknown values are dropped,
|
||||
* absent stays empty – never guessed.
|
||||
*/
|
||||
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 } {
|
||||
const fm: Record<string, string> = {};
|
||||
let content = raw;
|
||||
@@ -48,9 +69,17 @@ function extractMarkdown(raw: string): { frontmatter: Record<string, string>; co
|
||||
const fmText = raw.slice(3, endIdx).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) {
|
||||
// "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(':');
|
||||
listKey = colonIdx > -1 && !line.slice(colonIdx + 1).trim() ? line.slice(0, colonIdx).trim() : '';
|
||||
if (colonIdx > -1) {
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let val = line.slice(colonIdx + 1).trim();
|
||||
@@ -91,6 +120,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
|
||||
weapon: {
|
||||
damage: '',
|
||||
damageType: 'physical',
|
||||
woundKinds: [],
|
||||
properties: [],
|
||||
},
|
||||
armor: {
|
||||
@@ -105,6 +135,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
|
||||
|
||||
if (category === 'weapon') {
|
||||
system.weapon.damage = fm.damage || fm.obrazenia || '';
|
||||
system.weapon.woundKinds = parseWoundKinds(fm.wound || fm.rana);
|
||||
if (fm.traits || fm.cechy) {
|
||||
system.weapon.properties = parseList(fm.traits || fm.cechy);
|
||||
}
|
||||
|
||||
@@ -192,6 +192,11 @@ export type GearCategory = (typeof GEAR_CATEGORIES)[number];
|
||||
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
|
||||
export type DamageType = (typeof DAMAGE_TYPES)[number];
|
||||
|
||||
/** Weapon wound kinds (sieczne / kłute / obuchowe). A weapon may have several; none = unspecified.
|
||||
* 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];
|
||||
|
||||
/** Armor types */
|
||||
export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const;
|
||||
export type ArmorType = (typeof ARMOR_TYPES)[number];
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants';
|
||||
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES, WOUND_KINDS } from '../constants';
|
||||
|
||||
/**
|
||||
* Unified Gear item - discriminated by `category`.
|
||||
@@ -39,6 +39,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
|
||||
choices: DAMAGE_TYPES as unknown as readonly string[],
|
||||
}),
|
||||
properties: makeArrayField(new f.StringField({ blank: false })),
|
||||
// Rodzaj rany: slashing (sieczne) / piercing (kłute) / blunt (obuchowe); several allowed, empty = unspecified.
|
||||
woundKinds: makeArrayField(new f.StringField({ blank: false, choices: WOUND_KINDS as unknown as string[] })),
|
||||
}),
|
||||
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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),
|
||||
* 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> {
|
||||
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). */
|
||||
destroyRunicPoints?: number;
|
||||
/** 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). */
|
||||
sourceActor?: any;
|
||||
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);
|
||||
|
||||
// Passive Krew from cutting / piercing weapons (talents): victim and attacker side.
|
||||
if (report.hpDamage > 0 && (opts.woundKind === 'cutting' || opts.woundKind === 'piercing')) {
|
||||
// Passive Krew from slashing / piercing weapons (talents): victim and attacker side.
|
||||
if (report.hpDamage > 0 && (opts.woundKinds ?? []).some((k) => k === 'slashing' || k === 'piercing')) {
|
||||
report.krewGained = {
|
||||
victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage),
|
||||
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) };
|
||||
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 {
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AOE_SHAPES,
|
||||
SPELL_DAMAGE_TYPES,
|
||||
TRIGGER_EVENTS,
|
||||
WOUND_KINDS,
|
||||
} from '../constants';
|
||||
|
||||
const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
|
||||
@@ -67,6 +68,7 @@ export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
|
||||
itemType: item.type,
|
||||
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
|
||||
choices: buildChoiceMaps(),
|
||||
woundChoices: Object.fromEntries(WOUND_KINDS.map((k) => [k, `HBM.gear.wound.${k}`])),
|
||||
effects,
|
||||
supportsEffects,
|
||||
};
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"id": "hbm-rpg-v3",
|
||||
"title": "Homebrew Magic: RPG v3",
|
||||
"description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
|
||||
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip",
|
||||
"compatibility": {
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
<option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label title="{{localize 'HBM.gear.woundKindsHint'}}">{{localize 'HBM.gear.woundKinds'}}:
|
||||
<multi-select name="system.weapon.woundKinds">
|
||||
{{selectOptions woundChoices selected=system.weapon.woundKinds localize=true}}
|
||||
</multi-select>
|
||||
</label>
|
||||
</fieldset>
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseWoundKinds } from '../../scripts/parsers/gear-parser';
|
||||
|
||||
describe('gear-parser parseWoundKinds', () => {
|
||||
it('maps English and Polish labels, single or listed', () => {
|
||||
expect(parseWoundKinds('slashing')).toEqual(['slashing']);
|
||||
expect(parseWoundKinds('kłute')).toEqual(['piercing']);
|
||||
expect(parseWoundKinds('piercing, slashing')).toEqual(['piercing', 'slashing']);
|
||||
expect(parseWoundKinds('slashing, piercing, blunt, slashing')).toEqual(['slashing', 'piercing', 'blunt']);
|
||||
});
|
||||
|
||||
it('never guesses: missing or unknown stays empty', () => {
|
||||
expect(parseWoundKinds(undefined)).toEqual([]);
|
||||
expect(parseWoundKinds('')).toEqual([]);
|
||||
expect(parseWoundKinds('ognista')).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user