feat: implement core Foundry VTT system framework, including actor/item data models, spellcasting logic, dice mechanics, and automation scripts

This commit is contained in:
Octoturge
2026-06-14 10:12:47 +02:00
parent fb42c6e8cc
commit 624fe2ee1a
70 changed files with 1645 additions and 1146 deletions
+18 -18
View File
@@ -1,4 +1,4 @@
# Changelog hbm-rpg-v3 # Changelog - hbm-rpg-v3
All notable changes to the **HbM RPG v3** Foundry VTT system. All notable changes to the **HbM RPG v3** Foundry VTT system.
@@ -11,7 +11,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **Foundry v14 compatibility:** Migrated from deprecated `template.json` to `documentTypes` in `system.json`. The system now declares Actor and Item types directly in the manifest as required by Foundry v14+. `template.json` has been removed. - **Foundry v14 compatibility:** Migrated from deprecated `template.json` to `documentTypes` in `system.json`. The system now declares Actor and Item types directly in the manifest as required by Foundry v14+. `template.json` has been removed.
- Updated `compatibility.verified` to `"14"` to support Foundry VTT v14 build 363. - Updated `compatibility.verified` to `"14"` to support Foundry VTT v14 build 363.
## [1.2.0] 2026-04-30 ## [1.2.0] - 2026-04-30
Major mechanics + infrastructure release. Adds optional-rules sub-packs, Major mechanics + infrastructure release. Adds optional-rules sub-packs,
ActiveEffects integration, automated tests, full localization for the new ActiveEffects integration, automated tests, full localization for the new
@@ -19,8 +19,8 @@ features, and a v1.2.0 world migration.
### Added ### Added
- **Talent sub-packs** for optional rules: - **Talent sub-packs** for optional rules:
- `talents-blood` 5 talents from *Arcanum Sanguinis* - `talents-blood` - 5 talents from *Arcanum Sanguinis*
- `talents-abyss` 4 talents from *Klątwa Otchłani* - `talents-abyss` - 4 talents from *Klątwa Otchłani*
- Each parsed talent is stamped with `flags['hbm-rpg-v3'].sourceModule` - Each parsed talent is stamped with `flags['hbm-rpg-v3'].sourceModule`
so future module-aware UIs can filter or annotate them. *Note:* Foundry so future module-aware UIs can filter or annotate them. *Note:* Foundry
has no native gating of compendium content by external modules; the flag has no native gating of compendium content by external modules; the flag
@@ -38,7 +38,7 @@ features, and a v1.2.0 world migration.
attribute, NPCs default to `2d6 + 0`. attribute, NPCs default to `2d6 + 0`.
- **Per-turn Zeal regeneration is configurable.** The combat-turn handler - **Per-turn Zeal regeneration is configurable.** The combat-turn handler
now reads `flags['hbm-rpg-v3'].zealRegenBonus` (default `0`) added to the now reads `flags['hbm-rpg-v3'].zealRegenBonus` (default `0`) added to the
base `+1` regen talents can grant additional regen via an ActiveEffect base `+1` regen - talents can grant additional regen via an ActiveEffect
with `mode: ADD` targeting that flag. with `mode: ADD` targeting that flag.
- **Long-rest cleanup.** `rest(actor, 'long')` now also deletes temporary - **Long-rest cleanup.** `rest(actor, 'long')` now also deletes temporary
ActiveEffects on the actor (those with finite duration or with the ActiveEffects on the actor (those with finite duration or with the
@@ -51,16 +51,16 @@ features, and a v1.2.0 world migration.
activeEffect}` namespaces to both `lang/pl.json` and `lang/en.json`. activeEffect}` namespaces to both `lang/pl.json` and `lang/en.json`.
- **Vitest test harness** (`vitest.config.ts`, `tests/setup.ts`). 5 test - **Vitest test harness** (`vitest.config.ts`, `tests/setup.ts`). 5 test
files covering damage layers, rest mechanics, zeal-regen helper, talent files covering damage layers, rest mechanics, zeal-regen helper, talent
parser, and the v1.2.0 migration 18 tests, all passing. parser, and the v1.2.0 migration - 18 tests, all passing.
### Notes on skipped items ### Notes on skipped items
- **Spell preparation** is intentionally not implemented every spellcaster - **Spell preparation** is intentionally not implemented - every spellcaster
can cast any known spell directly (per design). can cast any known spell directly (per design).
- **Module gating of sub-pack talents** is not enforced because Foundry does - **Module gating of sub-pack talents** is not enforced because Foundry does
not provide a hook to hide compendium documents based on external module not provide a hook to hide compendium documents based on external module
presence. The `sourceModule` flag is preserved for future opt-in filtering. presence. The `sourceModule` flag is preserved for future opt-in filtering.
## [1.1.2] 2026-04-30 ## [1.1.2] - 2026-04-30
Mechanics polish release. No content changes; focuses on the spell-cast UX, the Mechanics polish release. No content changes; focuses on the spell-cast UX, the
NPC sheet, and the cast dialog. NPC sheet, and the cast dialog.
@@ -87,14 +87,14 @@ NPC sheet, and the cast dialog.
free-text actor-id field), so group casters can be picked visually. free-text actor-id field), so group casters can be picked visually.
### Changed ### Changed
- `src/logic/spell-cast.ts` no longer posts separate damage / status messages - `src/logic/spell-cast.ts` no longer posts separate damage / status messages -
everything is consolidated into the new chat card via `renderTemplate`. everything is consolidated into the new chat card via `renderTemplate`.
- `lang/{pl,en}.json` added `HBM.spellCast.*` keys for the new card and - `lang/{pl,en}.json` - added `HBM.spellCast.*` keys for the new card and
dialog widgets. dialog widgets.
- `styles/hbm.css` styling for the spell-cast card and the new dialog - `styles/hbm.css` - styling for the spell-cast card and the new dialog
widgets (caster checkbox list, variant radio list). widgets (caster checkbox list, variant radio list).
## [1.0.0] 2026-04-29 ## [1.0.0] - 2026-04-29
First production release. Adds the full magic and resource model from books IVIII First production release. Adds the full magic and resource model from books IVIII
plus a build pipeline that generates compendium content from the source markdown plus a build pipeline that generates compendium content from the source markdown
@@ -122,7 +122,7 @@ in `_books/`.
- **Cast dialog** with mana/zeal/blood inputs, group-caster picker, GM bypass - **Cast dialog** with mana/zeal/blood inputs, group-caster picker, GM bypass
checkboxes, validation feedback inline. checkboxes, validation feedback inline.
- **New mechanics modules**: `blood-magic`, `abyss-magic`, `brewing`, `rest`, - **New mechanics modules**: `blood-magic`, `abyss-magic`, `brewing`, `rest`,
`trade` all exposed under `game.hbm.*`. `trade` - all exposed under `game.hbm.*`.
- **Actor schema**: `attributes.blood`, `attributes.elixirTolerance`, - **Actor schema**: `attributes.blood`, `attributes.elixirTolerance`,
`attributes.insanity` with derived clamps and resource cards on the sheet. `attributes.insanity` with derived clamps and resource cards on the sheet.
- **Mental conditions**: paranoia, fobia, depresja, mania, schizofrenia - **Mental conditions**: paranoia, fobia, depresja, mania, schizofrenia
@@ -144,19 +144,19 @@ in `_books/`.
### Deferred ### Deferred
- `gear-*`, `actors-*`, `roll-tables-*` packs source books lack stat-block data. - `gear-*`, `actors-*`, `roll-tables-*` packs - source books lack stat-block data.
- Adventures package (`hbm-rpg-v3-adventures`). - Adventures package (`hbm-rpg-v3-adventures`).
- Macro packs. - Macro packs.
- Compendium browser premium-module integration. - Compendium browser premium-module integration.
## [0.6.1] 2026-04-29 ## [0.6.1] - 2026-04-29
- Localization parity (`en.json` mirrors all `pl.json` namespaces). - Localization parity (`en.json` mirrors all `pl.json` namespaces).
- CSS polish: badges, source pip, blood-pool tint, school details groups, - CSS polish: badges, source pip, blood-pool tint, school details groups,
fieldset and array-row layouts. fieldset and array-row layouts.
- Compendium browser metadata + `packFolders`. - Compendium browser metadata + `packFolders`.
## [0.6.0] 2026-04-29 ## [0.6.0] - 2026-04-29
- Spell item-sheet rewrite (8 fieldsets, conditional rendering). - Spell item-sheet rewrite (8 fieldsets, conditional rendering).
- Cast dialog (`askCastOptions`) with mana/zeal/blood/Hekate/group/bypass. - Cast dialog (`askCastOptions`) with mana/zeal/blood/Hekate/group/bypass.
@@ -164,12 +164,12 @@ in `_books/`.
- Resources: blood pool, elixir tolerance, insanity (conditional). - Resources: blood pool, elixir tolerance, insanity (conditional).
- Spells tab grouped by school with badges. - Spells tab grouped by school with badges.
## [0.5.0] 2026-04-29 ## [0.5.0] - 2026-04-29
- New mechanics modules under `game.hbm.*`: blood-magic, abyss-magic, brewing, - New mechanics modules under `game.hbm.*`: blood-magic, abyss-magic, brewing,
rest, trade. Actor schema gains `elixirTolerance`, `insanity`. rest, trade. Actor schema gains `elixirTolerance`, `insanity`.
## [0.4.0] 2026-04-29 ## [0.4.0] - 2026-04-29
- Phase 0 foundation: extended `constants`, `SpellData`, `CharacterData.attributes.blood`, - Phase 0 foundation: extended `constants`, `SpellData`, `CharacterData.attributes.blood`,
migration runner, mental conditions. migration runner, mental conditions.
+20 -20
View File
@@ -1,30 +1,30 @@
# Foundry VTT System: Homebrew Magic RPG v3 # Foundry VTT System: Homebrew Magic RPG v3
A custom **Foundry Virtual Tabletop v13** system implementing *Homebrew Magic: Role Playing Game v3* a Polish tabletop RPG with a **d6 success-pool (TS Trudność:Sukcesy)** dice mechanic. A custom **Foundry Virtual Tabletop v13** system implementing *Homebrew Magic: Role Playing Game v3* - a Polish tabletop RPG with a **d6 success-pool (TS - Trudność:Sukcesy)** dice mechanic.
> **Internal identifiers are English; user-facing labels are Polish (canonical) with English fallback.** The locale files in [`lang/`](./lang/) hold the translations. > **Internal identifiers are English; user-facing labels are Polish (canonical) with English fallback.** The locale files in [`lang/`](./lang/) hold the translations.
## Status ## Status
**v1.0.0 first production release.** Full magic and resource model from books IVIII, 152 compendium docs across 11 packs, automated build pipeline that parses `_books/*.md` directly. See [`CHANGELOG.md`](./CHANGELOG.md) for the full history. **v1.0.0 - first production release.** Full magic and resource model from books IVIII, 152 compendium docs across 11 packs, automated build pipeline that parses `_books/*.md` directly. See [`CHANGELOG.md`](./CHANGELOG.md) for the full history.
A companion lore module ([`foundry-lore`](https://github.com/iwo-strzebonski/hbm-foundry-lore)) ships JournalEntry compendia for narrative chapters. A companion lore module ([`foundry-lore`](https://github.com/iwo-strzebonski/hbm-foundry-lore)) ships JournalEntry compendia for narrative chapters.
## What's implemented ## What's implemented
- **Document types** Actor: `character`, `npc`. Item: `spell`, `gear`, `ability`, `class`, `race`, `discipline`, `talent`. - **Document types** - Actor: `character`, `npc`. Item: `spell`, `gear`, `ability`, `class`, `race`, `discipline`, `talent`.
- **TS d6 dice pool roller** (`HbmTSRoll`) `1` auto-fail, `6` auto-success, configurable Threshold (T) and required Successes (Y), plus T-step / Y-step modifiers driven by Active Effects. - **TS d6 dice pool roller** (`HbmTSRoll`) - `1` auto-fail, `6` auto-success, configurable Threshold (T) and required Successes (Y), plus T-step / Y-step modifiers driven by Active Effects.
- **Derived stats** `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter. - **Derived stats** - `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter.
- **4-layer damage model** Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss. - **4-layer damage model** - Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss.
- **Combat hooks** Mana resets each round; Zeal +1 each turn; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save. - **Combat hooks** - Mana resets each round; Zeal +1 each turn; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save.
- **Spell-cast workflow** `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`). - **Spell-cast workflow** - `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`).
- **Cast dialog** mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback. - **Cast dialog** - mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback.
- **Mechanics modules** (`game.hbm.*`) `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling). - **Mechanics modules** (`game.hbm.*`) - `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling).
- **Status conditions (Active Effects)** 15 physical conditions (Aneks A) plus 5 mental conditions from Klątwa Otchłani VIII (paranoja, fobia, depresja, mania, schizofrenia). - **Status conditions (Active Effects)** - 15 physical conditions (Aneks A) plus 5 mental conditions from Klątwa Otchłani VIII (paranoja, fobia, depresja, mania, schizofrenia).
- **Sheets** ApplicationV2 + Handlebars for every document type. Character sheet: short-rest / long-rest header buttons, conditional resource cards (blood pool, elixir tolerance, insanity), spells tab grouped by school with badges (`★ super`, `⛧ group`, `☮ noncombat`, `🔒 race-lock`) and source-book pip. - **Sheets** - ApplicationV2 + Handlebars for every document type. Character sheet: short-rest / long-rest header buttons, conditional resource cards (blood pool, elixir tolerance, insanity), spells tab grouped by school with badges (`★ super`, `⛧ group`, `☮ noncombat`, `🔒 race-lock`) and source-book pip.
- **Compendia** (11 packs, 152 docs) `spells-general`, `spells-elements`, `spells-sacred`, `spells-academic`, `spells-superspells`, `spells-crimson` (93 spells); `talents`, `talents-npc` (34 talents); `races` (5), `disciplines`, `disciplines-forbidden` (20). Each pack carries `flags.hbm-rpg-v3.{category, sourceBook}` for compendium-browser; `packFolders` group them in the sidebar. - **Compendia** (11 packs, 152 docs) - `spells-general`, `spells-elements`, `spells-sacred`, `spells-academic`, `spells-superspells`, `spells-crimson` (93 spells); `talents`, `talents-npc` (34 talents); `races` (5), `disciplines`, `disciplines-forbidden` (20). Each pack carries `flags.hbm-rpg-v3.{category, sourceBook}` for compendium-browser; `packFolders` group them in the sidebar.
- **Race / Class linking** drag a Race or Class item onto a Character to auto-link `system.details.raceId` / `system.details.classIds[year]`. Feeds the Advancement panel (attribute / skill points available). - **Race / Class linking** - drag a Race or Class item onto a Character to auto-link `system.details.raceId` / `system.details.classIds[year]`. Feeds the Advancement panel (attribute / skill points available).
- **Migration runner** `src/migrations/` upgrades v0.2.x worlds (`complexityLevel``components.symbols.length`, `targets` regex → `areaOfEffect`, `overcasting` text → `overcastOptions[0].description`). - **Migration runner** - `src/migrations/` upgrades v0.2.x worlds (`complexityLevel``components.symbols.length`, `targets` regex → `areaOfEffect`, `overcasting` text → `overcastOptions[0].description`).
## Project layout ## Project layout
@@ -107,7 +107,7 @@ Default `T = 4`, `Y = 1`. The roll dialog accepts both per-roll for harder tasks
## Out of scope (v1.0.0) ## Out of scope (v1.0.0)
Deferred to follow-up versions: Deferred to follow-up versions:
- Gear / artifact / actor / roll-table compendia source books lack stat-block data. - Gear / artifact / actor / roll-table compendia - source books lack stat-block data.
- Adventures package (`hbm-rpg-v3-adventures`). - Adventures package (`hbm-rpg-v3-adventures`).
- Character creation wizard, advancement automation (PD spending). - Character creation wizard, advancement automation (PD spending).
- Macro packs. - Macro packs.
@@ -117,9 +117,9 @@ Deferred to follow-up versions:
Game rules and content live in sibling folders of this repo: Game rules and content live in sibling folders of this repo:
- [`../../_books/`](../../_books/) synced PDFs of the rulebooks (read-only) - [`../../_books/`](../../_books/) - synced PDFs of the rulebooks (read-only)
- [`../../spells/`](../../spells/), [`../../creatures/`](../../creatures/), [`../../items/`](../../items/) structured content - [`../../spells/`](../../spells/), [`../../creatures/`](../../creatures/), [`../../items/`](../../items/) - structured content
- [`../../rules/`](../../rules/), [`../../revisions/`](../../revisions/) current and proposed mechanics - [`../../rules/`](../../rules/), [`../../revisions/`](../../revisions/) - current and proposed mechanics
## Useful links ## Useful links
@@ -128,4 +128,4 @@ Game rules and content live in sibling folders of this repo:
## License ## License
All Rights Reserved see [LICENSE.txt](./LICENSE.txt). All Rights Reserved - see [LICENSE.txt](./LICENSE.txt).
@@ -1,10 +1,10 @@
{ {
"label": "Forbidden Disciplines", "label": "Forbidden Disciplines",
"entries": { "entries": {
"Magia Otchłani Magia Aspektów": { "Magia Otchłani - Magia Aspektów": {
"name": "AbyssAspects" "name": "AbyssAspects"
}, },
"Magia Otchłani Pierwotna Magia": { "Magia Otchłani - Pierwotna Magia": {
"name": "AbyssPrimal" "name": "AbyssPrimal"
}, },
"Magia Krwi": { "Magia Krwi": {
+5 -3
View File
@@ -2,10 +2,12 @@
"label": "Magic Disciplines", "label": "Magic Disciplines",
"entries": { "entries": {
"Alchemia - Warzenie Eliksirów": { "Alchemia - Warzenie Eliksirów": {
"name": "Alchemy - Brewing of Elixirs" "name": "Alchemy - Brewing of Elixirs",
"description": "[[Alchemia]]"
}, },
"Alchemia - Transmutacja": { "Alchemia - Transmutacja": {
"name": "Alchemy - Transmutation" "name": "Alchemy - Transmutation",
"description": "[[Alchemia]]"
}, },
"Rzemiosło Artefaktów": { "Rzemiosło Artefaktów": {
"name": "Crafting of Artifacts", "name": "Crafting of Artifacts",
@@ -48,7 +50,7 @@
"Magia Sakralna": { "Magia Sakralna": {
"name": "Sacred Magic" "name": "Sacred Magic"
}, },
"Magia Sakralna Egzorcyzmy": { "Magia Sakralna - Egzorcyzmy": {
"name": "Sacred Magic - Exorcisms", "name": "Sacred Magic - Exorcisms",
"description": "[[Magic Sakralna]]" "description": "[[Magic Sakralna]]"
}, },
+3
View File
@@ -21,6 +21,9 @@
}, },
"Krótki Odpoczynek": { "Krótki Odpoczynek": {
"name": "Short Rest" "name": "Short Rest"
},
"Chwila Wytchnienia": {
"name": "Take a Breather"
} }
} }
} }
-82
View File
@@ -1,82 +0,0 @@
{
"Celność": {
"name": "Accuracy",
"description": "Mag wykorzystuje swój talent magiczny, aby ułatwić sobie celowanie oraz żeby nieznacznie korygować swoje ataki dystansowe.\n\nAtaki dystansowe maga są o jeden stopień prostsze, a ponadto otrzymuje dwie dodatkowe kości do testów związanych z Umiejętnością Umiejętności Strzeleckie."
},
"Powietrzna Fala": {
"name": "Air Wave",
"description": "Mag wyrzuca z siebie potężną falę wiatru.\n\nKażda osoba w obszarze działania zaklęcia musi wykonać udany Test na Body (Siła) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia cel zostaje Powalony. Niezależnie od wyniku testu każda osoba w stożku otrzymuje tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga."
},
"Gorejąca Skóra": {
"name": "Burning Skin",
"description": "Skóra i ubrania maga zaczynają się palić. Ogień ten nie niszczy ubrań maga ani nie zadaje mu obrażeń.\n\nWszyscy przeciwnicy, którzy zaatakują maga wręcz w trakcie działania tego zaklęcia otrzymują tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga.\n\n---"
},
"Wyczucie Zagrożenia": {
"name": "Danger Sense",
"description": "Mag zaczyna skupiać się na delikatnych zmianach w otaczającym go powietrzu, co pomaga mu w unikaniu ataków.\n\nWszystkie ataki, których celem jest mag rzucający to zaklęcie, są trudniejsze o dwa stopnie (do maksymalnie Trudności (T) równej „6”), a ponadto mag otrzymuje dwie dodatkowe kości do rzutów związanych z Umiejętnością Refleks."
},
"Trzęsienie Ziemi": {
"name": "Earthquake",
"description": "W wybranym przez maga obszarze ziemia zaczyna się silnie trząść.\n\nWszystkie istoty w obszarze działania muszą wykonać udany Test na Body (Atletyka) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać zostaje Unieruchomiona i Powalona do początku swojej następnej tury. Efekt ten aktywuje się na końcu każdej tury maga i kończy się na początku trzeciej tury maga od rzucenia zaklęcia. Ponadto, jeśli postać nie zda tego testu trzykrotnie w trakcie działania tego zaklęcia, postać staje się Nieprzytomna do końca trwania zaklęcia."
},
"Ognista Powłoka": {
"name": "Fire Cloak",
"description": "Broń pokrywa się płomieniami, obniżając Trudność ataków wykonywanych tą bronią o 1 stopień oraz powodując, że broń dodatkowo zadaje 2 punkty obrażeń magicznych."
},
"Kula Ognia": {
"name": "Fireball",
"description": "Mag splata ogień w kulę, którą następnie ciska w wybrany cel.\n\nCel otrzymuje tyle punktów obrażeń, ile mag ma punktów w umiejętności Zdolności Magiczne, a następnie musi wykonać udany Test na Body (Refleks) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia Postać zostaje Podpalona na trzy rundy."
},
"Latanie": {
"name": "Flight",
"description": "Postać może latać przez 1 minutę."
},
"Żwirowa Zbroja": {
"name": "Gravel Armor",
"description": "Mag zamyka się w twardej, kamiennej zbroi.\n\nMag otrzymuje +1 punkt Pancerza, ale ze względu na to, że zbroja utrudnia ruchy, jego Prędkość zostaje zmniejszona o połowę."
},
"Gradobicie": {
"name": "Hailstorm",
"description": "Mag przywołuje nad dany obszar chmurę, z której zaczyna padać grad.\n\nKażda osoba w obszarze działania zaklęcia musi wykonać udany Test na Body (Refleks) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać otrzymuje tyle punktów obrażeń, ile wynosi wartość Umiejętności Zdolności Magiczne maga.\n\nDodatkowo obszar działania zaklęcia jest traktowany jak Trudny Teren (wszystkie istoty poruszają się w nim o połowę wolniej)."
},
"Lodowa Podłoga": {
"name": "Ice Floor",
"description": "Na obszarze o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) pojawia się lód. Osoby w polu muszą wykonać udany Test na Body (Atletyka) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać zostaje Powalona."
},
"Podpalenie": {
"name": "Ignition",
"description": "Na obszarze o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) pojawia się ogień. Każda osoba znajdująca się w tym obszarze otrzymuje 3 punkty obrażeń, a ponadto na początku swojej tury każda postać otrzymuje kolejne 3 punkty obrażeń, tak długo, jak będą się znajdować w obszarze tego zaklęcia."
},
"Wzniecanie Ognia": {
"name": "Kindle",
"description": "Postać podpala dotkniętą przez siebie rzecz dotykaną przez siebie."
},
"Mistyczna Mgła": {
"name": "Mystic Fog",
"description": "Mag przywołuje bardzo gęstą magiczną mgłę dookoła siebie.\n\nWszyscy przeciwnicy są Oślepieni, zaś wszyscy sojusznicy (w tym Mag) są traktowani jakby byli w pełni zasłonięci (przeciwnicy nie mogą atakować ich atakami dystansowymi ani wpływać na nich zaklęciami, które wymagają wybrania określonego celu, chyba że takie zaklęcie jest zaklęciem dotykowym).\n\nWszyscy sojusznicy (w tym mag) bez problemu widzą przez mgłę oraz widzą jej granicę. Mgła podąża za magiem.\n\n---"
},
"Ruchome Piaski": {
"name": "Quicksand",
"description": "Mag tymczasowo zmienia podłoże w ruchome piaski, utrudniając poruszanie się w obszarze działania zaklęcia.\n\nWszystkie istoty w tym obszarze mogą się poruszyć maksymalnie o zaokrągloną w górę połowę swojego Ruchu (czyli jeśli dana istota mogła się normalnie ruszać 10 m, tak w obszarze działania zaklęcia może się poruszyć o zaledwie 5 m)."
},
"Ukojenie": {
"name": "Solace",
"description": "Dookoła maga pojawia się kojąca rany delikatna mgiełka.\n\nWszyscy sojusznicy (w tym mag) w promieniu działania zaklęcia na początku następnej rundy maga odzyskują tyle Punktów Żywotności, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga."
},
"Skalista Tarcza": {
"name": "Stone Shield",
"description": "Postać tworzy w pobliżu siebie w wolnej przestrzeni ścianę z ziemi, piasku bądź żwiru.\n\nŚciana ta wytrzymuje ilość obrażeń równą wartości Umiejętności Zdolności Magiczne maga, a ponadto blokuje wizję oraz możliwość przejścia przez dany obszar istotom rozmiaru Średniego lub większego."
},
"Skalista Pułapka": {
"name": "Stone Trap",
"description": "Mag zaklina ziemię, piasek i żwir w danym obszarze, aby stworzyć pułapkę, która przebije kolcami pierwszą osobę, która wejdzie w dany obszar.\n\nKolce zadają tyle punktów obrażeń, ile mag ma punktów w Umiejętności Zdolności Magiczne, a następnie zaklęcie przestaje działać.\n\n---"
},
"Tworzenie i Kontrolowanie Wody": {
"name": "Water Create",
"description": "Postać tworzy i/lub zaczyna kontrolować do 1 litra wody. Gdy kontrolowana woda opuści zasięg 5 m, mag przestaje ją kontrolować. Zaklęcie to pozwala również na usunięcie lub wyparowanie do 1 litra wody."
},
"Szybki jak Wiatr": {
"name": "Wind Swift",
"description": "Mag splata wiatr, aby wspomóc się przy poruszaniu się.\n\nPrędkość maga zwiększa się dwukrotnie, a ponadto mag nie może zostać Powalony.\n\n---"
}
}
+1 -1
View File
@@ -59,7 +59,7 @@
}, },
"Requiem": { "Requiem": {
"name": "Requiem", "name": "Requiem",
"description": "W dawnych kronikach Requiem symbolizowało narzędzie “ostatecznej separacji” - poprzez halabardę otwierano szczeliny w rzeczywistości, by elita Kultu mogła błyskawicznie przedrzeć się przez obronę, zasiewając zamęt i rozbicie wśród wrogów. Nikogo nie zabiera, nie drenuje życia, lecz wnosi do świata esencję Szkarłatu: rozprężenie struktur, dezorientację i przewagę dla wtajemniczonych.\n\nW ręce maga pojawia się utkana ze szkarłatnej energii halabarda, którą następnie może rzucić w wybrany przez siebie cel.\n\nGdy halabarda wbija się w wyznaczone miejsce, natychmiast zadaje wszystkim istotom w promieniu 10 metrów 3k6 obrażeń ignorujących Pancerz oraz wszystkie odporności, następnie mag (oraz do 2 sprzymierzeńców w promieniu 2 m) natychmiast teleportuje się do miejsca wbicia halabardy.\n\nPo wbiciu się ostrza, wokół (przez 1 godzinę na promieniu 50 m) gęstnieje szkarłatna aura. Każda istota (niebędąca członkiem Kultu) wchodząca lub przebywająca w strefie na początku swojej tury otrzymuje 2k6 obrażeń ignorujących Pancerz oraz wszystkie odporności oraz wykonuje Test na Soul (Determinacja) (T:S, 6:2) lub popada w panikę/dezorientację (opis narracyjny zależny od MG).\n\nW miejscu wbicia tworzy się trwały energetyczny obelisk (Szkarłatny Znak), działający jak fokus dla dalszych zaklęć Magii Szkarłatu (wszystkie zaklęcia Magii Szkarłatu rzucane przez członków Szkarłatnego Kultu są o jeden stopień prostsze, do minimalnej Trudności równej 3).\n\nUczestnicy rytuału podlegają “szkarłatnym snom” przez 7 dni, a ponadto wszystkie zaklęcia nienależące do dziedziny Magii Szkarłatu przez ten czas wymagają 3 Sukcesów więcej. “Szkarłatne sny” wpływają na zachowanie postaci (Mistrz Gry decyduje, czy da się uniknąć wykonywania poleceń “szeptów”).\n\nRequiem zawsze było zarówno narzędziem destrukcji, jak i rozłamu rzeczywistości halabarda powstająca z esencji “Szkarłatnego Wymiaru” nie tylko umożliwia wtargnięcie w centrum bitwy, ale natychmiast wywołuje falę energii dezorganizującą, rozbijającą linie wroga i uderzającą w samą czasoprzestrzeń. Pozostaje symbolem siły Kultu i jego destruktywnego potencjału." "description": "W dawnych kronikach Requiem symbolizowało narzędzie “ostatecznej separacji” - poprzez halabardę otwierano szczeliny w rzeczywistości, by elita Kultu mogła błyskawicznie przedrzeć się przez obronę, zasiewając zamęt i rozbicie wśród wrogów. Nikogo nie zabiera, nie drenuje życia, lecz wnosi do świata esencję Szkarłatu: rozprężenie struktur, dezorientację i przewagę dla wtajemniczonych.\n\nW ręce maga pojawia się utkana ze szkarłatnej energii halabarda, którą następnie może rzucić w wybrany przez siebie cel.\n\nGdy halabarda wbija się w wyznaczone miejsce, natychmiast zadaje wszystkim istotom w promieniu 10 metrów 3k6 obrażeń ignorujących Pancerz oraz wszystkie odporności, następnie mag (oraz do 2 sprzymierzeńców w promieniu 2 m) natychmiast teleportuje się do miejsca wbicia halabardy.\n\nPo wbiciu się ostrza, wokół (przez 1 godzinę na promieniu 50 m) gęstnieje szkarłatna aura. Każda istota (niebędąca członkiem Kultu) wchodząca lub przebywająca w strefie na początku swojej tury otrzymuje 2k6 obrażeń ignorujących Pancerz oraz wszystkie odporności oraz wykonuje Test na Soul (Determinacja) (T:S, 6:2) lub popada w panikę/dezorientację (opis narracyjny zależny od MG).\n\nW miejscu wbicia tworzy się trwały energetyczny obelisk (Szkarłatny Znak), działający jak fokus dla dalszych zaklęć Magii Szkarłatu (wszystkie zaklęcia Magii Szkarłatu rzucane przez członków Szkarłatnego Kultu są o jeden stopień prostsze, do minimalnej Trudności równej 3).\n\nUczestnicy rytuału podlegają “szkarłatnym snom” przez 7 dni, a ponadto wszystkie zaklęcia nienależące do dziedziny Magii Szkarłatu przez ten czas wymagają 3 Sukcesów więcej. “Szkarłatne sny” wpływają na zachowanie postaci (Mistrz Gry decyduje, czy da się uniknąć wykonywania poleceń “szeptów”).\n\nRequiem zawsze było zarówno narzędziem destrukcji, jak i rozłamu rzeczywistości - halabarda powstająca z esencji “Szkarłatnego Wymiaru” nie tylko umożliwia wtargnięcie w centrum bitwy, ale natychmiast wywołuje falę energii dezorganizującą, rozbijającą linie wroga i uderzającą w samą czasoprzestrzeń. Pozostaje symbolem siły Kultu i jego destruktywnego potencjału."
}, },
"Telekineza": { "Telekineza": {
"name": "Telekinesis", "name": "Telekinesis",
@@ -1,6 +0,0 @@
{
"Superzaklęcie - Requiem": {
"name": "Superspell Requiem",
"description": "W dawnych kronikach Requiem symbolizowało narzędzie “ostatecznej separacji” - poprzez halabardę otwierano szczeliny w rzeczywistości, by elita Kultu mogła błyskawicznie przedrzeć się przez obronę, zasiewając zamęt i rozbicie wśród wrogów. Nikogo nie zabiera, nie drenuje życia, lecz wnosi do świata esencję Szkarłatu: rozprężenie struktur, dezorientację i przewagę dla wtajemniczonych.\n\nW ręce maga pojawia się utkana ze szkarłatnej energii halabarda, którą następnie może rzucić w wybrany przez siebie cel.\n\nGdy halabarda wbija się w wyznaczone miejsce, natychmiast zadaje wszystkim istotom w promieniu 10 metrów 3k6 obrażeń ignorujących Pancerz oraz wszystkie odporności, następnie mag (oraz do 2 sprzymierzeńców w promieniu 2 m) natychmiast teleportuje się do miejsca wbicia halabardy.\n\nPo wbiciu się ostrza, wokół (przez 1 godzinę na promieniu 50 m) gęstnieje szkarłatna aura. Każda istota (niebędąca członkiem Kultu) wchodząca lub przebywająca w strefie na początku swojej tury otrzymuje 2k6 obrażeń ignorujących Pancerz oraz wszystkie odporności oraz wykonuje Test na Soul (Determinacja) (T:S, 6:2) lub popada w panikę/dezorientację (opis narracyjny zależny od MG).\n\nW miejscu wbicia tworzy się trwały energetyczny obelisk (Szkarłatny Znak), działający jak fokus dla dalszych zaklęć Magii Szkarłatu (wszystkie zaklęcia Magii Szkarłatu rzucane przez członków Szkarłatnego Kultu są o jeden stopień prostsze, do minimalnej Trudności równej 3).\n\nUczestnicy rytuału podlegają “szkarłatnym snom” przez 7 dni, a ponadto wszystkie zaklęcia nienależące do dziedziny Magii Szkarłatu przez ten czas wymagają 3 Sukcesów więcej. “Szkarłatne sny” wpływają na zachowanie postaci (Mistrz Gry decyduje, czy da się uniknąć wykonywania poleceń “szeptów”).\n\nRequiem zawsze było zarówno narzędziem destrukcji, jak i rozłamu rzeczywistości — halabarda powstająca z esencji “Szkarłatnego Wymiaru” nie tylko umożliwia wtargnięcie w centrum bitwy, ale natychmiast wywołuje falę energii dezorganizującą, rozbijającą linie wroga i uderzającą w samą czasoprzestrzeń. Pozostaje symbolem siły Kultu i jego destruktywnego potencjału."
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"label": "Talents - Curse of the Abyss",
"entries": {
"Swobodny Przepływ Magii": {
"name": "Free Flow Of Magic",
"description": "**Wymagania**: Dziedzina Magii (Magic Otchłani (Aspektów))\n\nAby móc otrzymać ten Talent, mag musi odrzucić swoją poprzednią dziedzinę i poświęcić się w pełni tej dziedzinie magii. Zmiana dziedziny magii jest możliwa wyłącznie jeden raz.\n\nMag może rzucać zaklęcia z innych Akademickich Dziedzin Magii niż jego pierwotna.\nMoże uczyć się zaklęć z Dziedzin innej niż swoja, ale są one o jeden punkt Many droższe, a ponadto do rzucania takich zaklęć wymagana jest esencja w formie płynnej. Mag natomiast nie może uczyć się ani rzucać modlitw. Ilość i potrzebne rodzaje esencji określa Mistrz Gry, natomiast zasada jest taka, że za każdy punkt Many, który kosztuje to zaklęcie, należy dodać kolejną fiolkę 50 ml Esencji. Fiolka może zostać podzielona na porcje - jedna fiolka zawiera pięć porcji Esencji.\n\nMagowie Aspektów mogą się ukrywać jako magowie swojej pierwotnej dziedziny"
},
"Dar Otchłani": {
"name": "Gift Of The Abyss",
"description": "**Wymagania**: Dziedzina Magii (Magic Otchłani (Pierwotna Magic)), Talent (Swobodny Przepływ Magii)\n\nAby móc otrzymać ten Talent, mag musi odrzucić swoją poprzednią dziedzinę i poświęcić się w pełni tej dziedzinie magii. Mag musi być obecnie Magiem Aspektów oraz musi być magiem poziomu co najmniej II oraz musi mieć talent Zakazana Wiedza (Otchłań). Zmiana Dziedziny magii jest możliwa wyłącznie jeden raz. Ten Talent zastępuje Talent “Swobodny Przepływ Magii”.\n\nMag może rzucać zaklęcia ze wszystkich Akademickich Dziedzin Magii oraz ich się uczyć.\nNie musi wykorzystywać many, ale za każdy punkt Many, który kosztuje zaklęcie, musi osiągnąć jeden sukces w Teście Magic (Zdolności Magiczne), gdzie Trudność (T) wynosi 5. W wypadku niepowodzenia zaklęcie się nie udaje, a mag musi rzucać tyloma kośćmi na tabelę Kary za Nieudolność, ile brakowało mu sukcesów do zdania Testu.\n\nMag Pierwotnej Magii traci wszelki związek ze swoją pierwotną dziedziną magii - jego ślad magiczny zmienia się nie do poznania."
},
"Metamagia": {
"name": "Metamagic",
"description": "*Ten Talent można wykupić wyłącznie podczas tworzenia postaci lub jeżeli Postać staje się Magiem Otchłani.*\n\n**Wymagania**: Dziedzina Magii (Wiedźmia Magic, Magic Alchemii (Transmutacji) lub Magic Otchłani)\n\nTego Talentu nie można wziąć jeżeli Postać ma już Talent Odporność na Magię lub Jedność z Magią.\n\nNiektórzy Magowie są w stanie zjednoczyć się z magią, jednak w przeciwieństwie do ras takich jak elfy, których magia jest naturą, jedność nie polega na umiejętności kierowania i rozkazywania magii, lecz na umiejętności zrozumienia i manipulacji magią w otoczeniu.\n\nTen Talent można brać wielokrotnie do maksymalnie dwóch dodatkowych razy, za każdym razem zwiększając maksymalną ilość Punktów Metamagii o 2.\n\nPostać może mieć jednocześnie tyle punktów Magii, ile wynosi zaokrąglona w górę połowa wartości Atrybutu Soul + 1. Każda z Akcji Metamagii może być wykonana wyłącznie raz w trakcie rundy oraz nie mogą wpływać na to samo zaklęcie, chyba że jest napisane inaczej.\n\nWszystkie Punkty Metamagii odnawiają się po Długim Odpoczynku.\n\nPostać z tym Talentem otrzymuje dostęp do następujących Akcji:\n#### Rozdwojone Zaklęcie\n**Koszt**: 3 Punkty Metamagii\n**Wymagania**: Zaklęcie, którego celem są pojedyncze cele i Nadczarowanie nie zwiększyło ilości celów.\n\nZaklęcie rzucane przez Postać działa na dodatkowy cel.\n\n#### Niezniszczalne Zaklęcie\n**Koszt**: 1 Punkt Metamagii\n\nPostać rzuca kością 1k3. Zaklęcie wymaga tyle dodatkowych Sukcesów do rozplecenia, ile wyniósł ten rzut.\n\n#### Rozproszenie Magii\n**Koszt**: 2 Punkty Metamagii, 1 Punkt Zapału\n**Wymagania**: Inna Postać rzucająca zaklęcie\n\nGdy wybrany cel rzuca zaklęcie, Postać może rzucić kością 1k3. Rzucane przez cel zaklęcie wymaga tyle dodatkowych Sukcesów, ile wyniósł ten rzut.\n\n#### Błyskawiczne Czarostwo\n**Koszt**: 2 Punkty Metamagii\n\nZaklęcie wymaga jednej tury mniej do rzucenia.\n\n---"
}
}
}
+60
View File
@@ -9,6 +9,10 @@
"name": "Ambidextrous", "name": "Ambidextrous",
"description": "Sprawnie posługujesz się bronią w obydwu rękach.\n\n\nGdy wykonujesz ataki dwiema broniami, otrzymujesz dwie dodatkowe kości" "description": "Sprawnie posługujesz się bronią w obydwu rękach.\n\n\nGdy wykonujesz ataki dwiema broniami, otrzymujesz dwie dodatkowe kości"
}, },
"Pasjonat Zwierząt": {
"name": "Animal Enthusiast",
"description": "Spowija cię aura, która naturalnie uspokaja zwierzęta w twojej obecności. Potrafisz mniej więcej zrozumieć, co zwierzę chce ci \"powiedzieć\". Niesprowokowane nie zaatakują cię. Podwajasz ilość kości wynikającą z Umiejętności Opieka nad Zwierzętami, jeżeli zwierzę nie jest wobec Ciebie agresywne."
},
"Arcymag (Dziedzina)": { "Arcymag (Dziedzina)": {
"name": "Archmage", "name": "Archmage",
"description": "Wszystkie Testy związane z daną Dziedziną Magii są prostsze o jeden stopień, a ponadto wszystkie zaklęcia są traktowane, jakby były nadczarowane o jeden stopień (ale nie zużywają dodatkowej many). Dodatkowo postać zna wszystkie zaklęcia z tej Dziedziny.\n\n\nTen Talent może zostać wybrane dla maksymalnie jednej Dziedziny - tej, w której jest Mistrzem Magii." "description": "Wszystkie Testy związane z daną Dziedziną Magii są prostsze o jeden stopień, a ponadto wszystkie zaklęcia są traktowane, jakby były nadczarowane o jeden stopień (ale nie zużywają dodatkowej many). Dodatkowo postać zna wszystkie zaklęcia z tej Dziedziny.\n\n\nTen Talent może zostać wybrane dla maksymalnie jednej Dziedziny - tej, w której jest Mistrzem Magii."
@@ -17,6 +21,10 @@
"name": "Armor Of Faith", "name": "Armor Of Faith",
"description": "Możesz rzucać zaklęcia będąc w zbroi Średniej lub Ciężkiej bez kar." "description": "Możesz rzucać zaklęcia będąc w zbroi Średniej lub Ciężkiej bez kar."
}, },
"Arytmetyczna Koncentracja": {
"name": "Arytmetyczna Koncentracja",
"description": "Ilość kości w Testach na utrzymanie Koncentracji jest zwiększona o połowę wartości Atrybutu Mind."
},
"Gadanina": { "Gadanina": {
"name": "Babble", "name": "Babble",
"description": "Podwajasz liczbę kości wynikających z ilości Punktów Umiejętności w Umiejętności Przekonywanie jeżeli chcesz kogoś zagadać lub odwrócić czyjąś uwagę swoją gadaniną." "description": "Podwajasz liczbę kości wynikających z ilości Punktów Umiejętności w Umiejętności Przekonywanie jeżeli chcesz kogoś zagadać lub odwrócić czyjąś uwagę swoją gadaniną."
@@ -33,6 +41,10 @@
"name": "Blessed Origin", "name": "Blessed Origin",
"description": "Anioły od Boga-Jahwe-Jedynego dostały dwa dary - naturalną zdolność do latania oraz możliwość odrodzenia się po śmierci." "description": "Anioły od Boga-Jahwe-Jedynego dostały dwa dary - naturalną zdolność do latania oraz możliwość odrodzenia się po śmierci."
}, },
"Błogosławieni Egzorcyści": {
"name": "Blogoslawieni Egzorcysci",
"description": "Zamiast pasywnej zdolności adeptów Magii Sakralnej, egzorcyści mają dostęp do egzorcyzmów oraz mogą wykupić i korzystać z talentu Zakazana Wiedza (Demonologia) bez żadnych kar."
},
"Sen na Jawie": { "Sen na Jawie": {
"name": "Daydream", "name": "Daydream",
"description": "Nie potrzebujesz snu.\n\n\nElfy w przeciwieństwie do innych ras nie śpią, ale nie wszystkie potrafią kontrolować swoje myśli w trakcie odpoczynku." "description": "Nie potrzebujesz snu.\n\n\nElfy w przeciwieństwie do innych ras nie śpią, ale nie wszystkie potrafią kontrolować swoje myśli w trakcie odpoczynku."
@@ -49,6 +61,10 @@
"name": "Feint", "name": "Feint",
"description": "Postać wykonuje zwodniczy atak, którego celem jest zmylenie przeciwnika.\n\n\nNależy wykonać Test Przeciwstawny zgodnie z zasadami Akcji Atak, jednak w przypadku, gdy Postać, która zadeklarowała, że będzie wykorzystywała ten Talent wygra pojedynek, nie zadaje Obrażeń. W zamian cel Finty do końca następnej rundy atakującego bądź do następnego Ataku atakującego skierowanego w cel nie może unikać, odpierać ani parować ataku Postaci, która z powodzeniem użyła Finty.\n\n\nJeżeli atakujący atakuje oburącz, a Postać również trzyma dwie bronie, może sparować obydwa ataki, jednak musi podzielić pulę kości na obydwa parowania (podobnie jak przy walce oburącz)." "description": "Postać wykonuje zwodniczy atak, którego celem jest zmylenie przeciwnika.\n\n\nNależy wykonać Test Przeciwstawny zgodnie z zasadami Akcji Atak, jednak w przypadku, gdy Postać, która zadeklarowała, że będzie wykorzystywała ten Talent wygra pojedynek, nie zadaje Obrażeń. W zamian cel Finty do końca następnej rundy atakującego bądź do następnego Ataku atakującego skierowanego w cel nie może unikać, odpierać ani parować ataku Postaci, która z powodzeniem użyła Finty.\n\n\nJeżeli atakujący atakuje oburącz, a Postać również trzyma dwie bronie, może sparować obydwa ataki, jednak musi podzielić pulę kości na obydwa parowania (podobnie jak przy walce oburącz)."
}, },
"Medyk Polowy": {
"name": "Field Medic",
"description": "Chirurgiem może i nie jesteś, ale równie dobrze potrafisz ludzi bandażować. Twoje testy na pomoc medyczną mają podwojoną ilość kości wynikającą z umiejętności. Dodatkowo, osoby które leczysz odnawiają 1k6 więcej Punktów Życia."
},
"Zakazana Wiedza (Dziedzina)": { "Zakazana Wiedza (Dziedzina)": {
"name": "Forbidden Knowledge", "name": "Forbidden Knowledge",
"description": "Liczba kości dla wybranej Umiejętności przy rzutach związanych z daną dziedziną jest podwajana. Jeśli pomimo tego Test się nie uda, musisz liczyć się z groźnymi konsekwencjami…" "description": "Liczba kości dla wybranej Umiejętności przy rzutach związanych z daną dziedziną jest podwajana. Jeśli pomimo tego Test się nie uda, musisz liczyć się z groźnymi konsekwencjami…"
@@ -61,6 +77,10 @@
"name": "Indestructible Spells", "name": "Indestructible Spells",
"description": "Ilość Sukcesów wymaganych do rozplecenia zaklęcia rzuconego przez Ciebie jest zwiększona o zaokrągloną w górę połowę wartości Umiejętności Zdolności Magiczne." "description": "Ilość Sukcesów wymaganych do rozplecenia zaklęcia rzuconego przez Ciebie jest zwiększona o zaokrągloną w górę połowę wartości Umiejętności Zdolności Magiczne."
}, },
"Jedność z Żywiołem": {
"name": "Jednosc Z Zywiolem",
"description": "Rzucasz jedną kością mniej (do minimum jednej) na kary związane z nieudolnym rzucaniem zaklęć, jeśli zaklęcie, które się nie powiodło, jest z Twojego żywiołu."
},
"Czuły Zmysł (Zmysł)": { "Czuły Zmysł (Zmysł)": {
"name": "Keen Sense", "name": "Keen Sense",
"description": "Jeden z Twoich zmysłów jest szczególnie rozwinięty. Wybierz jeden ze zmysłów (wzrok, słuch, dotyk, smak, węch). Jeśli wykonujesz Test na Spostrzegawczość z użyciem danego zmysłu, wartość Umiejętności Spostrzegawczość zostaje podwojona przy tym rzucie." "description": "Jeden z Twoich zmysłów jest szczególnie rozwinięty. Wybierz jeden ze zmysłów (wzrok, słuch, dotyk, smak, węch). Jeśli wykonujesz Test na Spostrzegawczość z użyciem danego zmysłu, wartość Umiejętności Spostrzegawczość zostaje podwojona przy tym rzucie."
@@ -81,10 +101,38 @@
"name": "Magical Sense", "name": "Magical Sense",
"description": "Masz wrodzony dar do wykrywania i obrazowania otaczającej Cię magii, jej ukierunkowania oraz efektu. Testy na opieranie się efektowi zaklęć i na rozplatanie zaklęć wymagają jednego sukcesu mniej. Ponadto wybierasz, którym zmysłem \"widzisz\" magię (wzrok - wstęgi w powietrzu, dotyk - drżenie w palcach o różnym natężeniu i częstotliwości, słuch - różne dźwięki itd.)." "description": "Masz wrodzony dar do wykrywania i obrazowania otaczającej Cię magii, jej ukierunkowania oraz efektu. Testy na opieranie się efektowi zaklęć i na rozplatanie zaklęć wymagają jednego sukcesu mniej. Ponadto wybierasz, którym zmysłem \"widzisz\" magię (wzrok - wstęgi w powietrzu, dotyk - drżenie w palcach o różnym natężeniu i częstotliwości, słuch - różne dźwięki itd.)."
}, },
"Magiczna Flora": {
"name": "Magiczna Flora",
"description": "PT testów związanych z rozpoznawaniem roślin jest niższy o jeden stopień, jeżeli Postać kiedykolwiek spotkała się z daną rośliną (nie musi o tym pamiętać)."
},
"Człowiek z Żelaza": {
"name": "Man Of Iron",
"description": "Twoja skóra jest niezwykle twarda i kiedy nie nosisz pancerza, daje ci 1 punkt pancerza."
},
"Mistrz Magii (Dziedzina)": { "Mistrz Magii (Dziedzina)": {
"name": "Master Of Magic", "name": "Master Of Magic",
"description": "Przy rzutach związanych z daną Dziedziną Magii liczba Kości otrzymywana we wszystkich Umiejętnościach zostaje podwojona. \n\n\nTen Talent może zostać wybrane dla maksymalnie jednej Dziedziny." "description": "Przy rzutach związanych z daną Dziedziną Magii liczba Kości otrzymywana we wszystkich Umiejętnościach zostaje podwojona. \n\n\nTen Talent może zostać wybrane dla maksymalnie jednej Dziedziny."
}, },
"Miłosierdzie": {
"name": "Milosierdzie",
"description": "Efekty leczące i chroniące zaklęć Magii Sakralnej są o połowę mocniejsze."
},
"Nadzwyczajna Odporność": {
"name": "Nadzwyczajna Odpornosc",
"description": "Rzucasz dodatkową kością na testy związane z odpornością na trucizny. Ponadto Tolerancja na eliksiry odnawia się po Krótkim Odpoczynku, nie po Długim, jak normalnie."
},
"Naturalny Lot": {
"name": "Natural Flight",
"description": "Anioły i demony posiadają naturalne skrzydła, które pozwalają im latać.\n\n\nAby utrzymać się w powietrzu, postać musi poruszyć się o co najmniej 5 metrów w każdej turze walki. Jeżeli postać nie ruszy się o wymaganą odległość, spada na ziemię na koniec tury.\n\n\nPostać z tym Talentem może nabyć Talenty Zawisanie w Powietrzu oraz Powietrzne Uniki."
},
"Niezwykła Intuicja": {
"name": "Niezwykla Intuicja",
"description": "PT Testów na wykrywanie kłamstw i iluzji jest niższe o jeden stopień."
},
"Olimpijczyk": {
"name": "Olympian",
"description": "Jesteś bardzo wysportowany. Testy na Siłę, Atletykę lub inne testy związane z wysportowaniem dostają 2 dodatkowe kości."
},
"Oportunista": { "Oportunista": {
"name": "Opportunist", "name": "Opportunist",
"description": "Twoja zdolność do reagowania na wydarzenia w trakcie walki jest zadziwiająca.\n\n\nRaz na rundę gdy przeciwnik odchodzi od Ciebie i masz możliwość zaatakowania go, możesz podjąć próbę ataku." "description": "Twoja zdolność do reagowania na wydarzenia w trakcie walki jest zadziwiająca.\n\n\nRaz na rundę gdy przeciwnik odchodzi od Ciebie i masz możliwość zaatakowania go, możesz podjąć próbę ataku."
@@ -97,6 +145,14 @@
"name": "Personal Charm", "name": "Personal Charm",
"description": "Wszystkie Testy związane z zauraczaniem kogoś lub wykorzystywaniem swojego wyglądu do zmanipulowania innej osoby są prostsze o jeden stopień." "description": "Wszystkie Testy związane z zauraczaniem kogoś lub wykorzystywaniem swojego wyglądu do zmanipulowania innej osoby są prostsze o jeden stopień."
}, },
"Pierwotna Energia": {
"name": "Pierwotna Energia",
"description": "Nie możesz rzucać ani rozplatać zaklęć. Twój Maksymalny Koszt Zaklęcia oraz Ilość Many zostają dwukrotnie zwiększone.\n\n\nW ramach Akcji możesz pomóc wybranemu magowi w zasięgu 3 m w rzucaniu zaklęć. Do początku Twojej następnej tury, jeżeli wybrany mag się od Ciebie nie odsunie, gdy ten mag rzuca jakiekolwiek zaklęcie, możesz zadeklarować, że zużywasz swoją manę, aby wzmocnić zaklęcie wybranego maga (Księga Magii). Mana wykorzystana w celu wzmocnienia zaklęcia nie liczy się do wykonywanego przez maga testu na to, czy uda mu się kontrolować dużą ilość mocy, ale wzmacniając zaklęcie, nie możesz przekroczyć swojego maksymalnego kosztu zaklęcia.\n\n\nDodatkowo w ramach Akcji Natychmiastowej możesz przekazać część swojej many. Jeżeli chcesz to zrobić, wykonaj Test na Zdolności Magiczne (Magic). Trudność zależy od odległości od maga (4:S poniżej 1 m, 5:S poniżej 2 m, 6:S poniżej 3 m), a Ilość Wymaganych Sukcesów (S) jest równa ilości przekazywanej many."
},
"Podejrzliwość Wobec Zmian": {
"name": "Podejrzliwosc Wobec Zmian",
"description": "Rzucasz dodatkową kością na testy związane z uniknięciem efektu magicznego."
},
"Odporność na Trucizny": { "Odporność na Trucizny": {
"name": "Poison Resistance", "name": "Poison Resistance",
"description": "Postać do pewnego stopnia uodporniła się na zatrucia.\n\n\nGdy postać musi wykonać test na Wytrzymałość aby uniknąć zatrucia, podwój ilość kości wynikającą z poziomu Umiejętności Wytrzymałość." "description": "Postać do pewnego stopnia uodporniła się na zatrucia.\n\n\nGdy postać musi wykonać test na Wytrzymałość aby uniknąć zatrucia, podwój ilość kości wynikającą z poziomu Umiejętności Wytrzymałość."
@@ -109,6 +165,10 @@
"name": "Spell Shattering", "name": "Spell Shattering",
"description": "Każdy mag posiadający ten Talent może raz na rundę za darmo dokonać próby rozplatania, jednak dane zaklęcie może być rozplatane wyłącznie raz.\n\n\nMagowie posiadający Zapał mogą próbować rozpleść więcej zaklęć - każda następna taka próba kosztuje jeden punkt Zapału. Magowie sakralni mogą potraktować rozplatanie zaklęcia jako modlitwę, zmieniając Atrybut swojego Testu na Duszę, ale jednocześnie zwiększając koszt wykorzystania tego Talentu o 1 punkt Zapału.\n\n\nJeżeli Postać widzi istotę rzucającą zaklęcie i jest od niej w zasięgu 20 m, może zadeklarować, że próbuje rozpleść rzucane zaklęcie. Postać wykonuje Test na Zdolności Magiczne (Magic lub Soul, jeśli jest magiem sakralnym), gdzie Trudność (T) jest równa trudności zaklęcia, a liczba wymaganych Sukcesów (S) jest o 1 większa od liczby sukcesów wyrzuconych przez maga rzucającego zaklęcie. Jeżeli wynik Testu Postaci będzie wyższy niż wynik Testu przeciwnika, zaklęcie zostaje rozplecione." "description": "Każdy mag posiadający ten Talent może raz na rundę za darmo dokonać próby rozplatania, jednak dane zaklęcie może być rozplatane wyłącznie raz.\n\n\nMagowie posiadający Zapał mogą próbować rozpleść więcej zaklęć - każda następna taka próba kosztuje jeden punkt Zapału. Magowie sakralni mogą potraktować rozplatanie zaklęcia jako modlitwę, zmieniając Atrybut swojego Testu na Duszę, ale jednocześnie zwiększając koszt wykorzystania tego Talentu o 1 punkt Zapału.\n\n\nJeżeli Postać widzi istotę rzucającą zaklęcie i jest od niej w zasięgu 20 m, może zadeklarować, że próbuje rozpleść rzucane zaklęcie. Postać wykonuje Test na Zdolności Magiczne (Magic lub Soul, jeśli jest magiem sakralnym), gdzie Trudność (T) jest równa trudności zaklęcia, a liczba wymaganych Sukcesów (S) jest o 1 większa od liczby sukcesów wyrzuconych przez maga rzucającego zaklęcie. Jeżeli wynik Testu Postaci będzie wyższy niż wynik Testu przeciwnika, zaklęcie zostaje rozplecione."
}, },
"Tajemna Technologia": {
"name": "Tajemna Technologia",
"description": "PT Testów na rozpoznawanie artefaktów i rozumienie ich działania jest niższy o jeden stopień, jeżeli Postać miała styczność z czymś podobnym (nie musi o tym pamiętać)."
},
"Technologia Jest Wśród Nas": { "Technologia Jest Wśród Nas": {
"name": "Technology Is Among Us", "name": "Technology Is Among Us",
"description": "Jak inne rasy na co dzień mają styczność z magią, tak ludzie posługują się znacznie bardziej zaawansowaną technologią niż inne rasy - telefony, komputery, samochody… Długo by tu wymieniać. Dla innych ras ludzka technologia jest niezrozumiała, ale ludzie potrafią się nią bez problemu posługiwać.\n\n\nPodstawowa obsługa ludzkich urządzeń nie sprawia Ci kłopotu, a dodatkowo jeżeli masz instrukcję do danego urządzenia w języku zrozumiałym dla ciebie, to bez problemu możesz skorzystać z technologii Obcej cywilizacji, której poziom technologiczny jest porównywalny bądź niższy do poziomu cywilizacji ziemskiej." "description": "Jak inne rasy na co dzień mają styczność z magią, tak ludzie posługują się znacznie bardziej zaawansowaną technologią niż inne rasy - telefony, komputery, samochody… Długo by tu wymieniać. Dla innych ras ludzka technologia jest niezrozumiała, ale ludzie potrafią się nią bez problemu posługiwać.\n\n\nPodstawowa obsługa ludzkich urządzeń nie sprawia Ci kłopotu, a dodatkowo jeżeli masz instrukcję do danego urządzenia w języku zrozumiałym dla ciebie, to bez problemu możesz skorzystać z technologii Obcej cywilizacji, której poziom technologiczny jest porównywalny bądź niższy do poziomu cywilizacji ziemskiej."
+21 -11
View File
@@ -1,7 +1,12 @@
{ {
"HBM": { "HBM": {
"system": { "name": "Homebrew Magic: RPG v3" }, "system": {
"actor": { "character": "Character", "npc": "NPC / Creature" }, "name": "Homebrew Magic: RPG v3"
},
"actor": {
"character": "Character",
"npc": "NPC / Creature"
},
"item": { "item": {
"spell": "Spell", "spell": "Spell",
"gear": "Gear", "gear": "Gear",
@@ -155,7 +160,8 @@
"sacred": "Sacred Magic", "sacred": "Sacred Magic",
"witch": "Witch Magic", "witch": "Witch Magic",
"blood": "Blood Magic", "blood": "Blood Magic",
"hekate": "Hekate Hybrid" "hekate": "Hekate Hybrid",
"prayer": "Prayer"
} }
}, },
"spellSchool": { "spellSchool": {
@@ -173,14 +179,15 @@
"manaSourceMage": "Mana Source", "manaSourceMage": "Mana Source",
"illusion": "Illusion", "illusion": "Illusion",
"sacred": "Sacred Magic", "sacred": "Sacred Magic",
"sacredExorcism": "Sacred Magic Exorcism", "sacredExorcism": "Sacred Magic - Exorcism",
"witch": "Witch Magic", "witch": "Witch Magic",
"necromancy": "Necromancy", "necromancy": "Necromancy",
"blood": "Blood Magic", "blood": "Blood Magic",
"crimson": "Crimson Magic", "crimson": "Crimson Magic",
"abyssAspects": "Abyss Aspects", "abyssAspects": "Abyss - Aspects",
"abyssPrimal": "Abyss Primal", "abyssPrimal": "Abyss - Primal",
"wildWitch": "Wild Witch" "wildWitch": "Wild Witch",
"eldritch": "Eldritch"
}, },
"deity": { "deity": {
"common": "Common Prayers", "common": "Common Prayers",
@@ -350,6 +357,7 @@
"spellCast": { "spellCast": {
"title": "Casting spell", "title": "Casting spell",
"manaSpent": "Mana spent", "manaSpent": "Mana spent",
"cannotBeUnraveled": "Cannot be unraveled",
"zealSpent": "Zeal spent", "zealSpent": "Zeal spent",
"bloodSpent": "Blood spent", "bloodSpent": "Blood spent",
"hekateMode": "Hekate mode (Sacred + Witch hybrid)", "hekateMode": "Hekate mode (Sacred + Witch hybrid)",
@@ -401,12 +409,12 @@
"physicalArmorReduced": "Physical Armor reduced: {n}", "physicalArmorReduced": "Physical Armor reduced: {n}",
"healthLost": "Health lost: {n}", "healthLost": "Health lost: {n}",
"armorBroken": "Armor broken!", "armorBroken": "Armor broken!",
"runeTriggered": "Runes triggered Magical Armor -1" "runeTriggered": "Runes triggered - Magical Armor -1"
} }
}, },
"combat": { "combat": {
"newRound": "New round Mana reset", "newRound": "New round - Mana reset",
"newTurn": "New turn Zeal +1", "newTurn": "New turn - Zeal +1",
"turnSkipped": "Turn skipped (condition)", "turnSkipped": "Turn skipped (condition)",
"deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)" "deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)"
}, },
@@ -457,6 +465,7 @@
"itemAlreadyOwned": "'{name}' is already on this character sheet.", "itemAlreadyOwned": "'{name}' is already on this character sheet.",
"dropTalentsHint": "Drag talents from the compendium onto this sheet.", "dropTalentsHint": "Drag talents from the compendium onto this sheet.",
"physicalArmorBonus": "Armor bonus", "physicalArmorBonus": "Armor bonus",
"initiativeBonus": "Initiative bonus",
"selectTalentParamTitle": "Talent Specialization", "selectTalentParamTitle": "Talent Specialization",
"selectTalentParamDesc": "This talent requires a choice of specialization. Enter a value for ({param}):", "selectTalentParamDesc": "This talent requires a choice of specialization. Enter a value for ({param}):",
"tabs": { "tabs": {
@@ -562,7 +571,8 @@
"technologia-jest-wsrod-nas": "Technology is Among Us", "technologia-jest-wsrod-nas": "Technology is Among Us",
"urok-osobisty": "Personal Charm", "urok-osobisty": "Personal Charm",
"zakazana-wiedza-dziedzina": "Forbidden Knowledge (Discipline)", "zakazana-wiedza-dziedzina": "Forbidden Knowledge (Discipline)",
"zawisanie-w-powietrzu": "Hovering" "zawisanie-w-powietrzu": "Hovering",
"naturalny-lot": "Natural Flight"
} }
} }
} }
+14 -9
View File
@@ -160,7 +160,8 @@
"sacred": "Magia Sakralna", "sacred": "Magia Sakralna",
"witch": "Wiedźmia Magia", "witch": "Wiedźmia Magia",
"blood": "Magia Krwi", "blood": "Magia Krwi",
"hekate": "Hybryda Hekate" "hekate": "Hybryda Hekate",
"prayer": "Modlitwa"
} }
}, },
"spellSchool": { "spellSchool": {
@@ -178,14 +179,15 @@
"manaSourceMage": "Źródło Mocy", "manaSourceMage": "Źródło Mocy",
"illusion": "Magia Iluzji", "illusion": "Magia Iluzji",
"sacred": "Magia Sakralna", "sacred": "Magia Sakralna",
"sacredExorcism": "Magia Sakralna Egzorcyzmy", "sacredExorcism": "Magia Sakralna - Egzorcyzmy",
"witch": "Wiedźmia Magia", "witch": "Wiedźmia Magia",
"necromancy": "Nekromancja", "necromancy": "Nekromancja",
"blood": "Magia Krwi", "blood": "Magia Krwi",
"crimson": "Magia Szkarłatu", "crimson": "Magia Szkarłatu",
"abyssAspects": "Magia Otchłani Aspekty", "abyssAspects": "Magia Otchłani - Aspekty",
"abyssPrimal": "Magia Otchłani Pierwotna Magia", "abyssPrimal": "Magia Otchłani - Pierwotna Magia",
"wildWitch": "Dzika Wiedźmia Magia" "wildWitch": "Dzika Wiedźmia Magia",
"eldritch": "Magia Otchłani"
}, },
"deity": { "deity": {
"common": "Modlitwy Powszechne", "common": "Modlitwy Powszechne",
@@ -375,6 +377,7 @@
"spellCast": { "spellCast": {
"title": "Rzucanie zaklęcia", "title": "Rzucanie zaklęcia",
"manaSpent": "Wydana mana", "manaSpent": "Wydana mana",
"cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć", "cast": "Rzuć",
"notEnoughMana": "Niewystarczająca mana.", "notEnoughMana": "Niewystarczająca mana.",
"notEnoughZeal": "Niewystarczający Zapał.", "notEnoughZeal": "Niewystarczający Zapał.",
@@ -409,12 +412,12 @@
"physicalArmorReduced": "Pancerz Fizyczny zredukował: {n}", "physicalArmorReduced": "Pancerz Fizyczny zredukował: {n}",
"healthLost": "Utracona Żywotność: {n}", "healthLost": "Utracona Żywotność: {n}",
"armorBroken": "Zbroja zniszczona!", "armorBroken": "Zbroja zniszczona!",
"runeTriggered": "Runy zadziałały Pancerz Magiczny -1" "runeTriggered": "Runy zadziałały - Pancerz Magiczny -1"
} }
}, },
"combat": { "combat": {
"newRound": "Nowa runda Mana zresetowana", "newRound": "Nowa runda - Mana zresetowana",
"newTurn": "Nowa tura Zapał +1", "newTurn": "Nowa tura - Zapał +1",
"turnSkipped": "Tura pominięta (stan)", "turnSkipped": "Tura pominięta (stan)",
"deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)" "deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)"
}, },
@@ -465,6 +468,7 @@
"itemAlreadyOwned": "'{name}' jest już na tej karcie postaci.", "itemAlreadyOwned": "'{name}' jest już na tej karcie postaci.",
"dropTalentsHint": "Przeciągnij talenty z kompendium na tę kartę.", "dropTalentsHint": "Przeciągnij talenty z kompendium na tę kartę.",
"physicalArmorBonus": "Premia do pancerza", "physicalArmorBonus": "Premia do pancerza",
"initiativeBonus": "Premia do inicjatywy",
"selectTalentParamTitle": "Specjalizacja Talentu", "selectTalentParamTitle": "Specjalizacja Talentu",
"selectTalentParamDesc": "Ten talent wymaga określenia specjalizacji/parametru. Wpisz wartość dla parametru ({param}):", "selectTalentParamDesc": "Ten talent wymaga określenia specjalizacji/parametru. Wpisz wartość dla parametru ({param}):",
"tabs": { "tabs": {
@@ -570,7 +574,8 @@
"technologia-jest-wsrod-nas": "Technologia jest wśród nas", "technologia-jest-wsrod-nas": "Technologia jest wśród nas",
"urok-osobisty": "Urok osobisty", "urok-osobisty": "Urok osobisty",
"zakazana-wiedza-dziedzina": "Zakazana wiedza (Dziedzina)", "zakazana-wiedza-dziedzina": "Zakazana wiedza (Dziedzina)",
"zawisanie-w-powietrzu": "Zawisanie w powietrzu" "zawisanie-w-powietrzu": "Zawisanie w powietrzu",
"naturalny-lot": "Naturalny lot"
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hbm-rpg-v3-foundry", "name": "hbm-rpg-v3-foundry",
"version": "1.3.12", "version": "1.3.30",
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3", "description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -223,7 +223,7 @@ for (const pack of byPack.keys()) {
console.log(` ✓ compiled ${pack}`); console.log(` ✓ compiled ${pack}`);
} }
console.log(`[build-packs] done ${byPack.size} packs in packs/`); console.log(`[build-packs] done - ${byPack.size} packs in packs/`);
/** Convert a ParsedDoc into a Foundry document JSON object suitable for compilePack. */ /** Convert a ParsedDoc into a Foundry document JSON object suitable for compilePack. */
function toFoundryDoc(doc: ParsedDoc, folderId: string | null = null): Record<string, unknown> { function toFoundryDoc(doc: ParsedDoc, folderId: string | null = null): Record<string, unknown> {
+1
View File
@@ -127,6 +127,7 @@ const nameTranslations: Record<string, string> = {
"necromancy": "Necromancy", "necromancy": "Necromancy",
"blood": "Blood Magic", "blood": "Blood Magic",
"wildwitch": "Wild Witch Magic", "wildwitch": "Wild Witch Magic",
"take-breather": "Take a Breather",
}; };
const folderNameTranslations: Record<string, string> = { const folderNameTranslations: Record<string, string> = {
+3 -3
View File
@@ -1,5 +1,5 @@
/** /**
* UUID reference linter scans parsed docs (packs-src/*) for any * UUID reference linter - scans parsed docs (packs-src/*) for any
* `@UUID[...]` references that don't resolve within the generated set. * `@UUID[...]` references that don't resolve within the generated set.
* *
* Run after build-packs.ts: `bun scripts/lint-uuid-refs.ts` * Run after build-packs.ts: `bun scripts/lint-uuid-refs.ts`
@@ -37,14 +37,14 @@ function walk(dir: string): void {
walk(packsSrcDir); walk(packsSrcDir);
const broken = refs.filter((r) => { const broken = refs.filter((r) => {
// UUID format: Compendium.system.pack.Item.<id> only validate Item refs. // UUID format: Compendium.system.pack.Item.<id> - only validate Item refs.
const m = r.uuid.match(/^Compendium\.[^.]+\.[^.]+\.Item\.(.+)$/); const m = r.uuid.match(/^Compendium\.[^.]+\.[^.]+\.Item\.(.+)$/);
if (!m) return false; if (!m) return false;
return !allIds.has(m[1]); return !allIds.has(m[1]);
}); });
if (broken.length === 0) { if (broken.length === 0) {
console.log(`[lint-uuid-refs] OK ${refs.length} refs scanned, none broken`); console.log(`[lint-uuid-refs] OK - ${refs.length} refs scanned, none broken`);
process.exit(0); process.exit(0);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// HbM Apply Condition pick a status effect and toggle it on targeted/selected tokens. // HbM Apply Condition - pick a status effect and toggle it on targeted/selected tokens.
const targets = game.user.targets.size > 0 ? Array.from(game.user.targets) : canvas.tokens.controlled; const targets = game.user.targets.size > 0 ? Array.from(game.user.targets) : canvas.tokens.controlled;
if (targets.length === 0) return ui.notifications.warn('Zaznacz lub naceluj token(y).'); if (targets.length === 0) return ui.notifications.warn('Zaznacz lub naceluj token(y).');
const effects = CONFIG.statusEffects.filter((e) => e.id?.startsWith('hbm.')); const effects = CONFIG.statusEffects.filter((e) => e.id?.startsWith('hbm.'));
+3 -3
View File
@@ -1,11 +1,11 @@
// HbM Attribute Check pick attribute on selected token's actor, roll d6 pool. // HbM Attribute Check - pick attribute on selected token's actor, roll d6 pool.
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character; const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.'); if (!actor) return ui.notifications.warn('Wybierz token postaci.');
const attrs = ['body', 'mind', 'soul', 'magic']; const attrs = ['body', 'mind', 'soul', 'magic'];
const labels = { body: 'Ciało', mind: 'Umysł', soul: 'Dusza', magic: 'Magia' }; const labels = { body: 'Ciało', mind: 'Umysł', soul: 'Dusza', magic: 'Magia' };
const opts = attrs.map((a) => `<option value="${a}">${labels[a]} (${actor.system.attributes[a]?.value ?? 0})</option>`).join(''); const opts = attrs.map((a) => `<option value="${a}">${labels[a]} (${actor.system.attributes[a]?.value ?? 0})</option>`).join('');
const result = await Dialog.prompt({ const result = await Dialog.prompt({
title: `Test atrybutu ${actor.name}`, title: `Test atrybutu - ${actor.name}`,
content: ` content: `
<form> <form>
<div class="form-group"> <div class="form-group">
@@ -27,4 +27,4 @@ const result = await Dialog.prompt({
if (!result) return; if (!result) return;
const pool = actor.system.attributes[result.attr]?.value ?? 1; const pool = actor.system.attributes[result.attr]?.value ?? 1;
const roll = await new Roll(`${pool}d6cs>=${result.threshold}`).evaluate(); const roll = await new Roll(`${pool}d6cs>=${result.threshold}`).evaluate();
await roll.toMessage({ flavor: `${actor.name} test ${labels[result.attr]} (TS ${result.threshold})`, speaker: ChatMessage.getSpeaker({ actor }) }); await roll.toMessage({ flavor: `${actor.name} - test ${labels[result.attr]} (TS ${result.threshold})`, speaker: ChatMessage.getSpeaker({ actor }) });
+2 -2
View File
@@ -1,4 +1,4 @@
// HbM Group Cast Helper sum mana of controlled token actors (potential co-casters). // HbM Group Cast Helper - sum mana of controlled token actors (potential co-casters).
const tokens = canvas.tokens.controlled; const tokens = canvas.tokens.controlled;
if (tokens.length < 2) return ui.notifications.warn('Zaznacz co najmniej 2 tokeny współrzucających.'); if (tokens.length < 2) return ui.notifications.warn('Zaznacz co najmniej 2 tokeny współrzucających.');
const lines = []; const lines = [];
@@ -11,7 +11,7 @@ for (const t of tokens) {
const magic = a.system.attributes?.magic?.value ?? 0; const magic = a.system.attributes?.magic?.value ?? 0;
totalMana += mana; totalMana += mana;
totalPool += magic; totalPool += magic;
lines.push(`<li><strong>${a.name}</strong> Mana ${mana}, Magia ${magic}</li>`); lines.push(`<li><strong>${a.name}</strong> - Mana ${mana}, Magia ${magic}</li>`);
} }
const html = ` const html = `
<div class="hbm-group-cast"> <div class="hbm-group-cast">
+3 -3
View File
@@ -1,6 +1,6 @@
// HbM Long Rest invoke game.hbm.rest(actor, 'long') on selected token's actor. // HbM Long Rest - invoke game.hbm.rest(actor, 'long') on selected token's actor.
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character; const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.'); if (!actor) return ui.notifications.warn('Wybierz token postaci.');
if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest system niezainicjowany.'); if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest - system niezainicjowany.');
const result = await game.hbm.rest(actor, 'long'); const result = await game.hbm.rest(actor, 'long');
ui.notifications.info(`${actor.name}: długi odpoczynek pełna regeneracja zasobów.`); ui.notifications.info(`${actor.name}: długi odpoczynek - pełna regeneracja zasobów.`);
+1 -1
View File
@@ -1,4 +1,4 @@
// HbM Pool Roll prompts for pool size and threshold, then rolls a d6 pool. // HbM Pool Roll - prompts for pool size and threshold, then rolls a d6 pool.
const result = await Dialog.prompt({ const result = await Dialog.prompt({
title: 'Rzut puli d6', title: 'Rzut puli d6',
content: ` content: `
+1 -1
View File
@@ -1,4 +1,4 @@
// HbM Refill Zeal bumps zeal by 1 on selected token's actor (debug helper). // HbM Refill Zeal - bumps zeal by 1 on selected token's actor (debug helper).
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character; const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.'); if (!actor) return ui.notifications.warn('Wybierz token postaci.');
const cur = actor.system.attributes?.zeal?.value ?? 0; const cur = actor.system.attributes?.zeal?.value ?? 0;
+3 -3
View File
@@ -1,6 +1,6 @@
// HbM Short Rest invoke game.hbm.rest(actor, 'short') on selected token's actor. // HbM Short Rest - invoke game.hbm.rest(actor, 'short') on selected token's actor.
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character; const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.'); if (!actor) return ui.notifications.warn('Wybierz token postaci.');
if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest system niezainicjowany.'); if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest - system niezainicjowany.');
const result = await game.hbm.rest(actor, 'short'); const result = await game.hbm.rest(actor, 'short');
ui.notifications.info(`${actor.name}: krótki odpoczynek przywrócono ${result?.healed ?? 0} PW.`); ui.notifications.info(`${actor.name}: krótki odpoczynek - przywrócono ${result?.hpRestored ?? 0} PW.`);
+6
View File
@@ -0,0 +1,6 @@
// HbM Take a Breather - invoke game.hbm.rest(actor, 'breather') on selected token's actor.
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest - system niezainicjowany.');
const result = await game.hbm.rest(actor, 'breather');
ui.notifications.info(`${actor.name}: chwila wytchnienia - przywrócono ${result?.hpRestored ?? 0} PW i ${result?.manaRestored ?? 0} Many.`);
+2 -1
View File
@@ -180,5 +180,6 @@
"echo-zmierzchu": "twilight-echo", "echo-zmierzchu": "twilight-echo",
"requiem": "requiem", "requiem": "requiem",
"przywolanie-istoty-z-otchlani": "summon-eldritch-entity", "przywolanie-istoty-z-otchlani": "summon-eldritch-entity",
"nic-przeznaczenia": "thread-of-destiny" "nic-przeznaczenia": "thread-of-destiny",
"naturalny-lot": "natural-flight"
} }
+12 -12
View File
@@ -3,20 +3,20 @@
"schools": { "schools": {
"magia ogólna": "general", "magia ogólna": "general",
"magia żywiołów": "elements", "magia żywiołów": "elements",
"magia żywiołów powietrze": "elementsAir", "magia żywiołów - powietrze": "elementsAir",
"magia żywiołów (powietrze)": "elementsAir", "magia żywiołów (powietrze)": "elementsAir",
"magia powietrza": "elementsAir", "magia powietrza": "elementsAir",
"magia żywiołów woda": "elementsWater", "magia żywiołów - woda": "elementsWater",
"magia żywiołów (woda)": "elementsWater", "magia żywiołów (woda)": "elementsWater",
"magia wody": "elementsWater", "magia wody": "elementsWater",
"magia żywiołów ogień": "elementsFire", "magia żywiołów - ogień": "elementsFire",
"magia żywiołów (ogień)": "elementsFire", "magia żywiołów (ogień)": "elementsFire",
"magia ognia": "elementsFire", "magia ognia": "elementsFire",
"magia żywiołów ziemia": "elementsEarth", "magia żywiołów - ziemia": "elementsEarth",
"magia żywiołów (ziemia)": "elementsEarth", "magia żywiołów (ziemia)": "elementsEarth",
"magia ziemi": "elementsEarth", "magia ziemi": "elementsEarth",
"magia sakralna": "sacred", "magia sakralna": "sacred",
"magia sakralna egzorcyzmy": "sacredExorcism", "magia sakralna - egzorcyzmy": "sacredExorcism",
"magia sakralna - egzorcyzmy": "sacredExorcism", "magia sakralna - egzorcyzmy": "sacredExorcism",
"egzorcyzmy": "sacredExorcism", "egzorcyzmy": "sacredExorcism",
"wiedźmia magia": "witch", "wiedźmia magia": "witch",
@@ -35,8 +35,8 @@
"nekromancja": "necromancy", "nekromancja": "necromancy",
"magia krwi": "blood", "magia krwi": "blood",
"magia szkarłatu": "crimson", "magia szkarłatu": "crimson",
"magia otchłani magia aspektów": "eldritchAspects", "magia otchłani - magia aspektów": "eldritchAspects",
"magia otchłani pierwotna magia": "eldritchPrimal", "magia otchłani - pierwotna magia": "eldritchPrimal",
"dzika wiedźmia magia": "wildWitch" "dzika wiedźmia magia": "wildWitch"
}, },
"disciplines": { "disciplines": {
@@ -46,13 +46,13 @@
"warzenie eliksirów": "alchemyBrewing", "warzenie eliksirów": "alchemyBrewing",
"alchemia - warzenie eliksirów": "alchemyBrewing", "alchemia - warzenie eliksirów": "alchemyBrewing",
"botanika": "botany", "botanika": "botany",
"magia żywiołów powietrze": "elementsAir", "magia żywiołów - powietrze": "elementsAir",
"magia żywiołów (powietrze)": "elementsAir", "magia żywiołów (powietrze)": "elementsAir",
"magia żywiołów woda": "elementsWater", "magia żywiołów - woda": "elementsWater",
"magia żywiołów (woda)": "elementsWater", "magia żywiołów (woda)": "elementsWater",
"magia żywiołów ogień": "elementsFire", "magia żywiołów - ogień": "elementsFire",
"magia żywiołów (ogień)": "elementsFire", "magia żywiołów (ogień)": "elementsFire",
"magia żywiołów ziemia": "elementsEarth", "magia żywiołów - ziemia": "elementsEarth",
"magia żywiołów (ziemia)": "elementsEarth", "magia żywiołów (ziemia)": "elementsEarth",
"rzemiosło artefaktów": "artifice", "rzemiosło artefaktów": "artifice",
"golemancja": "golemancy", "golemancja": "golemancy",
@@ -61,7 +61,7 @@
"źródło mocy": "manaSourceMage", "źródło mocy": "manaSourceMage",
"magia iluzji": "illusion", "magia iluzji": "illusion",
"magia sakralna": "sacred", "magia sakralna": "sacred",
"magia sakralna egzorcyzmy": "sacredExorcism", "magia sakralna - egzorcyzmy": "sacredExorcism",
"magia sakralna - egzorcyzmy": "sacredExorcism", "magia sakralna - egzorcyzmy": "sacredExorcism",
"egzorcyzmy": "sacredExorcism", "egzorcyzmy": "sacredExorcism",
"wiedźmia magia": "witch", "wiedźmia magia": "witch",
+4 -4
View File
@@ -27,7 +27,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
export interface BookBlock { export interface BookBlock {
/** The line directly preceding the bullet block usually the item name. */ /** The line directly preceding the bullet block - usually the item name. */
name: string; name: string;
/** Bullet keys/values, preserved in order. */ /** Bullet keys/values, preserved in order. */
fields: Array<{ key: string; value: string }>; fields: Array<{ key: string; value: string }>;
@@ -45,7 +45,7 @@ const SEPARATOR = /^_{3,}$/;
const BULLET = /^\*\s+([^:]+):\s*(.+)$/; const BULLET = /^\*\s+([^:]+):\s*(.+)$/;
// Match any markdown heading level (1-6 `#` chars). // Match any markdown heading level (1-6 `#` chars).
const HEADING = /^#{1,6}\s+(.+)$/; const HEADING = /^#{1,6}\s+(.+)$/;
// Chapter headings are the two highest heading levels we encounter heuristic: // Chapter headings are the two highest heading levels we encounter - heuristic:
// treat ## headings as "chapter" and ### headings as "section". // treat ## headings as "chapter" and ### headings as "section".
const CHAPTER_HEADING = /^#{1,2}\s+(.+)$/; const CHAPTER_HEADING = /^#{1,2}\s+(.+)$/;
const SECTION_HEADING = /^#{3,4}\s+(.+)$/; const SECTION_HEADING = /^#{3,4}\s+(.+)$/;
@@ -65,7 +65,7 @@ function skipFrontmatter(lines: string[]): number {
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1; if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
} }
return 0; // malformed start from 0 return 0; // malformed - start from 0
} }
export interface WalkOptions { export interface WalkOptions {
@@ -92,7 +92,7 @@ export function walkBook(path: string, opts: WalkOptions = {}): BookBlock[] {
for (let i = startIdx; i < lines.length; i++) { for (let i = startIdx; i < lines.length; i++) {
const rawLine = lines[i].trimEnd(); const rawLine = lines[i].trimEnd();
// Skip Obsidian TOC bullets they look like `- [[#Section|Label]]`. // Skip Obsidian TOC bullets - they look like `- [[#Section|Label]]`.
if (OBSIDIAN_TOC_BULLET.test(rawLine.trim())) continue; if (OBSIDIAN_TOC_BULLET.test(rawLine.trim())) continue;
// Chapter headings: ## or # level (the two highest we honour). // Chapter headings: ## or # level (the two highest we honour).
+5 -5
View File
@@ -4,7 +4,7 @@
* with name, source book, and an empty description (filled in later). * with name, source book, and an empty description (filled in later).
* *
* Many disciplines also have a "passive ability" mentioned right after the * Many disciplines also have a "passive ability" mentioned right after the
* discipline header in Podręcznik Gry we capture the next non-empty line * discipline header in Podręcznik Gry - we capture the next non-empty line
* if it looks like a short ability description. * if it looks like a short ability description.
*/ */
@@ -34,13 +34,13 @@ const DISCIPLINE_SEEDS: DisciplineSeed[] = [
{ id: 'manaSourceMage', polishName: 'Źródło Mocy', pack: 'disciplines', book: 'podrecznik-gry' }, { id: 'manaSourceMage', polishName: 'Źródło Mocy', pack: 'disciplines', book: 'podrecznik-gry' },
{ id: 'illusion', polishName: 'Magia Iluzji', pack: 'disciplines', book: 'ksiega-magii' }, { id: 'illusion', polishName: 'Magia Iluzji', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'sacred', polishName: 'Magia Sakralna', pack: 'disciplines', book: 'ksiega-magii' }, { id: 'sacred', polishName: 'Magia Sakralna', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'sacredExorcism', polishName: 'Magia Sakralna Egzorcyzmy', pack: 'disciplines', book: 'ksiega-magii' }, { id: 'sacredExorcism', polishName: 'Magia Sakralna - Egzorcyzmy', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'witch', polishName: 'Wiedźmia Magia', pack: 'disciplines', book: 'ksiega-magii' }, { id: 'witch', polishName: 'Wiedźmia Magia', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'necromancy', polishName: 'Nekromancja', pack: 'disciplines', book: 'ksiega-magii' }, { id: 'necromancy', polishName: 'Nekromancja', pack: 'disciplines', book: 'ksiega-magii' },
{ id: 'blood', polishName: 'Magia Krwi', pack: 'disciplines-forbidden', book: 'arcanum-sanguinis' }, { id: 'blood', polishName: 'Magia Krwi', pack: 'disciplines-forbidden', book: 'arcanum-sanguinis' },
{ id: 'crimson', polishName: 'Magia Szkarłatu', pack: 'disciplines-forbidden', book: 'crimson-cult' }, { id: 'crimson', polishName: 'Magia Szkarłatu', pack: 'disciplines-forbidden', book: 'crimson-cult' },
{ id: 'abyssAspects', polishName: 'Magia Otchłani Magia Aspektów', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' }, { id: 'abyssAspects', polishName: 'Magia Otchłani - Magia Aspektów', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
{ id: 'abyssPrimal', polishName: 'Magia Otchłani Pierwotna Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' }, { id: 'abyssPrimal', polishName: 'Magia Otchłani - Pierwotna Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
{ id: 'wildWitch', polishName: 'Dzika Wiedźmia Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' }, { id: 'wildWitch', polishName: 'Dzika Wiedźmia Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
]; ];
@@ -81,7 +81,7 @@ function lookupDescription(repoRoot: string, polishName: string): string {
resolve(repoRoot, 'ObsidianNotes/rules/02. Klątwa Otchłani.md'), resolve(repoRoot, 'ObsidianNotes/rules/02. Klątwa Otchłani.md'),
resolve(repoRoot, 'ObsidianNotes/rules/00. Podręcznik Gry.md'), resolve(repoRoot, 'ObsidianNotes/rules/00. Podręcznik Gry.md'),
]; ];
const target = normalizeKey(polishName.split('')[0]); const target = normalizeKey(polishName.split('-')[0]);
for (const file of splitSources) { for (const file of splitSources) {
let text = ''; let text = '';
+2 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Macro parser. Reads hand-coded macro source files from `scripts/macros/*.js` * Macro parser. Reads hand-coded macro source files from `scripts/macros/*.js`
* and emits ParsedDoc[] for the build pipeline. Macros are not parsed from * and emits ParsedDoc[] for the build pipeline. Macros are not parsed from
* books they're a curated set of GM/player utilities. * books - they're a curated set of GM/player utilities.
*/ */
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -23,6 +23,7 @@ const MACROS: MacroDef[] = [
{ id: 'pool-roll', name: 'Rzut Puli d6', file: 'pool-roll.js', img: 'icons/svg/d20.svg' }, { id: 'pool-roll', name: 'Rzut Puli d6', file: 'pool-roll.js', img: 'icons/svg/d20.svg' },
{ id: 'attribute-check', name: 'Test Atrybutu', file: 'attribute-check.js', img: 'icons/svg/dice-target.svg' }, { id: 'attribute-check', name: 'Test Atrybutu', file: 'attribute-check.js', img: 'icons/svg/dice-target.svg' },
{ id: 'apply-condition', name: 'Nałóż Przypadłość', file: 'apply-condition.js', img: 'icons/svg/aura.svg' }, { id: 'apply-condition', name: 'Nałóż Przypadłość', file: 'apply-condition.js', img: 'icons/svg/aura.svg' },
{ id: 'take-breather', name: 'Chwila Wytchnienia', file: 'take-breather.js', img: 'icons/svg/clockwork.svg' },
{ id: 'short-rest', name: 'Krótki Odpoczynek', file: 'short-rest.js', img: 'icons/svg/regen.svg' }, { id: 'short-rest', name: 'Krótki Odpoczynek', file: 'short-rest.js', img: 'icons/svg/regen.svg' },
{ id: 'long-rest', name: 'Długi Odpoczynek', file: 'long-rest.js', img: 'icons/svg/sun.svg' }, { id: 'long-rest', name: 'Długi Odpoczynek', file: 'long-rest.js', img: 'icons/svg/sun.svg' },
{ id: 'group-cast-helper', name: 'Pomocnik Rzucania Grupowego', file: 'group-cast-helper.js', img: 'icons/svg/upgrade.svg' }, { id: 'group-cast-helper', name: 'Pomocnik Rzucania Grupowego', file: 'group-cast-helper.js', img: 'icons/svg/upgrade.svg' },
+1 -1
View File
@@ -5,7 +5,7 @@ import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
import { slugify } from './helpers'; import { slugify } from './helpers';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
/** Looser zod schema mirrors `SpellData` defaults; build pipeline converts to system payload. */ /** Looser zod schema - mirrors `SpellData` defaults; build pipeline converts to system payload. */
const SpellSystemSchema = z.object({ const SpellSystemSchema = z.object({
castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']), castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']),
school: z.string(), school: z.string(),
+99 -4
View File
@@ -10,7 +10,7 @@
* - Podręcznik Gry, "Rozdział IV - Talenty" * - Podręcznik Gry, "Rozdział IV - Talenty"
* - Bestiariusz, "Rozdział III - Talenty" (NPC-only talents) * - Bestiariusz, "Rozdział III - Talenty" (NPC-only talents)
* *
* The walker can't help us here we segment by `________________` * The walker can't help us here - we segment by `________________`
* separators within the talent chapter and look for a "Wymagania:" line. * separators within the talent chapter and look for a "Wymagania:" line.
*/ */
@@ -73,7 +73,7 @@ const TALENT_SOURCES: TalentSource[] = [
]; ];
/** /**
* Talent chapters are noisy split into segments by separator lines, then * Talent chapters are noisy - split into segments by separator lines, then
* within each segment find consecutive talent blocks. A talent block is: * within each segment find consecutive talent blocks. A talent block is:
* - first non-empty line = name (short, no trailing punctuation, no colon) * - first non-empty line = name (short, no trailing punctuation, no colon)
* - optional `Wymagania: ...` line * - optional `Wymagania: ...` line
@@ -109,7 +109,7 @@ export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<Parsed
if (i >= slice.length) break; if (i >= slice.length) break;
const rawLine = slice[i].trim(); const rawLine = slice[i].trim();
// Stop heuristic bail out of obvious chapter changes. // Stop heuristic - bail out of obvious chapter changes.
if (/^Rozdział\b/.test(rawLine) || /^Aneks\b/.test(rawLine) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(rawLine)) break; if (/^Rozdział\b/.test(rawLine) || /^Aneks\b/.test(rawLine) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(rawLine)) break;
let isName = false; let isName = false;
@@ -127,7 +127,7 @@ export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<Parsed
} }
if (!isName) { if (!isName) {
// Can't read this skip the line and continue. // Can't read this - skip the line and continue.
i++; i++;
continue; continue;
} }
@@ -205,9 +205,104 @@ export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<Parsed
} }
} }
// Parse discipline-specific talents from Wybór Dziedziny Magii
docs.push(...(await parseDisciplineTalents(ctx)));
return docs; return docs;
}; };
async function parseDisciplineTalents(ctx: ParserContext): Promise<ParsedDoc[]> {
const docs: ParsedDoc[] = [];
const path = resolve(ctx.repoRoot, 'ObsidianNotes/rules/00. Podr\u0119cznik Gry.md');
let lines: string[] = [];
try {
lines = readFileSync(path, 'utf8').split(/\r?\n/);
} catch {
return [];
}
const startIdx = lines.findIndex((l) => /^\s*Wyb[o\u00f3\u0143A3\ufffd]r Dziedziny Magii\s*$/i.test(l));
if (startIdx < 0) return [];
const endIdx = lines.findIndex((l, i) => i > startIdx && /^\s*Umiej[e\u0119\u0118\ufffd]tno[s\u015b\u015a\ufffd]ci i Talenty\s*$/i.test(l));
const slice = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length);
const talentNames = [
'Podejrzliwo\u015b\u0107 Wobec Zmian',
'Nadzwyczajna Odporno\u015b\u0107',
'Magiczna Flora',
'Mi\u0142osierdzie',
'B\u0142ogos\u0142awieni Egzorcy\u015bci',
'Jedno\u015b\u0107 z \u017bywio\u0142em',
'Tajemna Technologia',
'Pierwotna Energia',
'Niezwyk\u0142a Intuicja',
'Arytmetyczna Koncentracja'
];
const cleanNames = talentNames.map(n => slugify(n));
for (let i = 0; i < slice.length; i++) {
const rawLine = slice[i].trim();
if (!rawLine) continue;
const slug = slugify(rawLine);
const talentIndex = cleanNames.indexOf(slug);
if (talentIndex !== -1) {
const name = talentNames[talentIndex];
const descLines: string[] = [];
let j = i + 1;
while (j < slice.length) {
const nextLine = slice[j].trim();
if (SEPARATOR.test(nextLine) || /^Rozdzia/i.test(nextLine) || /^___/.test(nextLine)) {
break;
}
const nextSlug = slugify(nextLine);
if (cleanNames.includes(nextSlug)) {
break;
}
// Stop if we hit a main section like Alchemia, Botanika, Magia Sakralna etc.
const disciplineHeaders = [
'Alchemia', 'Transmutacja', 'Warzenie Eliksirów', 'Botanika',
'Magia Sakralna', 'Egzorcyzmy', 'Magia Żywiołów', 'Rzemiosło Artefaktów',
'Źródło Mocy', 'Magia Iluzji', 'Wiedźmia Magia'
];
if (disciplineHeaders.some(h => slugify(h) === nextSlug)) {
break;
}
descLines.push(slice[j]);
j++;
}
const description = descLines.join('\n').trim();
const baseSlug = slugify(name);
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
docs.push({
id,
name,
documentType: 'Item',
subType: 'talent',
pack: 'talents',
source: { book: 'podrecznik-gry', chapter: 'Wyb\u00f3r Dziedziny Magii', line: startIdx + i + 2 },
system: {
requirements: { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' },
multiSelect: false,
cost: '',
damageReductionBonus: 0,
description,
effect: '',
},
description,
});
i = j - 1;
}
}
return docs;
}
/** A talent name is short, doesn't end with `.`, no `:`, not a bullet, no digits-only. */ /** A talent name is short, doesn't end with `.`, no `:`, not a bullet, no digits-only. */
function isPlausibleName(line: string): boolean { function isPlausibleName(line: string): boolean {
if (!line || line.length > 80) return false; if (!line || line.length > 80) return false;
+2 -2
View File
@@ -1,5 +1,5 @@
/** /**
* Shared parser types what each parser emits before being handed off * Shared parser types - what each parser emits before being handed off
* to the pack builder. * to the pack builder.
* *
* Every parsed document has a stable id (slug) and a sourceBook ref so * Every parsed document has a stable id (slug) and a sourceBook ref so
@@ -21,7 +21,7 @@ export interface ParsedDoc {
id: string; id: string;
/** Display name (Polish, as in book). */ /** Display name (Polish, as in book). */
name: string; name: string;
/** Foundry document type `Item` or `Actor`. */ /** Foundry document type - `Item` or `Actor`. */
documentType: 'Item' | 'Actor'; documentType: 'Item' | 'Actor';
/** Foundry sub-type for the data model (e.g. `spell`, `talent`). */ /** Foundry sub-type for the data model (e.g. `spell`, `talent`). */
subType: string; subType: string;
+36 -35
View File
@@ -49,29 +49,29 @@ export const SKILLS: Readonly<Record<string, AttributeKey>> = Object.freeze({
* `blocksTags` on a condition prevents rolls of any skill containing that tag. * `blocksTags` on a condition prevents rolls of any skill containing that tag.
*/ */
export const SKILL_TAGS: Readonly<Record<string, readonly SkillTag[]>> = Object.freeze({ export const SKILL_TAGS: Readonly<Record<string, readonly SkillTag[]>> = Object.freeze({
athletics: ['movement'], athletics: ['movement'],
agility: ['movement'], agility: ['movement'],
strength: [], strength: [],
melee: ['attack'], melee: ['attack'],
ranged: ['attack', 'sight'], ranged: ['attack', 'sight'],
stealth: ['movement', 'sight'], stealth: ['movement', 'sight'],
endurance: [], endurance: [],
reflex: [], reflex: [],
perception: ['sight', 'hearing'], perception: ['sight', 'hearing'],
intuition: ['social'], intuition: ['social'],
craft: [], craft: [],
medicine: [], medicine: [],
generalLore: [], generalLore: [],
natureLore: [], natureLore: [],
magicLore: [], magicLore: [],
theology: [], theology: [],
empathy: ['social', 'hearing'], empathy: ['social', 'hearing'],
persuasion: ['social', 'hearing'], persuasion: ['social', 'hearing'],
intimidation: ['social'], intimidation: ['social'],
determination: [], determination: [],
devotion: [], devotion: [],
disguise: ['social'], disguise: ['social'],
animalHandling: ['social'], animalHandling: ['social'],
magicalAbilities: ['magic'], magicalAbilities: ['magic'],
}); });
@@ -90,17 +90,17 @@ export interface MagicPowerEntry {
} }
export const MAGIC_POWER_TABLE: readonly MagicPowerEntry[] = Object.freeze([ export const MAGIC_POWER_TABLE: readonly MagicPowerEntry[] = Object.freeze([
{ level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerRound: 20 }, { level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerRound: 20 },
{ level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerRound: 12 }, { level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerRound: 12 },
{ level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerRound: 10 }, { level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerRound: 10 },
{ level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerRound: 7 }, { level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerRound: 7 },
{ level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerRound: 8 }, { level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerRound: 8 },
{ level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerRound: 6 }, { level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerRound: 6 },
{ level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerRound: 6 }, { level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerRound: 6 },
{ level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerRound: 4 }, { level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerRound: 4 },
{ level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerRound: 3 }, { level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerRound: 3 },
{ level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerRound: 2 }, { level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerRound: 2 },
{ level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerRound: 1 }, { level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerRound: 1 },
]); ]);
export function getMagicPowerEntry(level: number): MagicPowerEntry { export function getMagicPowerEntry(level: number): MagicPowerEntry {
@@ -133,6 +133,7 @@ export const SPELL_SCHOOLS = [
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy',
// Forbidden / extra-academic // Forbidden / extra-academic
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch', 'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
'eldritch',
] as const; ] as const;
export type SpellSchool = (typeof SPELL_SCHOOLS)[number]; export type SpellSchool = (typeof SPELL_SCHOOLS)[number];
@@ -144,7 +145,7 @@ export const SACRED_DEITIES = [
export type SacredDeity = (typeof SACRED_DEITIES)[number]; export type SacredDeity = (typeof SACRED_DEITIES)[number];
/** /**
* Witch magic symbols. EXTENSIBLE additional content may add more. * Witch magic symbols. EXTENSIBLE - additional content may add more.
* Used for autocomplete only; spell.components.symbols accepts arbitrary strings. * Used for autocomplete only; spell.components.symbols accepts arbitrary strings.
*/ */
export const WITCH_SYMBOLS: readonly string[] = Object.freeze([ export const WITCH_SYMBOLS: readonly string[] = Object.freeze([
+17 -24
View File
@@ -15,7 +15,7 @@ import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from
* - attributes.health.max = 3 * (body + mind + soul) * - attributes.health.max = 3 * (body + mind + soul)
* - attributes.zeal.max = ceil(soul / 2) * - attributes.zeal.max = ceil(soul / 2)
* - attributes.initiative = mind + skills.reflex.value + skills.perception.value * - attributes.initiative = mind + skills.reflex.value + skills.perception.value
* - attributes.mana.max / .maxPerSpell / magic.dicePool from MAGIC_POWER_TABLE * - attributes.mana.max / .maxPerSpell / magic.dicePool - from MAGIC_POWER_TABLE
*/ */
export class CharacterData extends foundry.abstract.TypeDataModel { export class CharacterData extends foundry.abstract.TypeDataModel {
static defineSchema() { static defineSchema() {
@@ -66,10 +66,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
maxPerSpell: makeIntField(0, { min: 0 }), maxPerSpell: makeIntField(0, { min: 0 }),
}), }),
zeal: makeValueMaxField(0, 0), zeal: makeValueMaxField(0, 0),
// Blood Pool (Arcanum Sanguinis Ch. III) experimental until full spell list ships. // Blood Pool (Arcanum Sanguinis Ch. III) - experimental until full spell list ships.
// Placeholder formula: max = body + soul. // Placeholder formula: max = body + soul.
blood: makeValueMaxField(0, 0), blood: makeValueMaxField(0, 0),
// Elixir tolerance (Podręcznik Gry brewing). Each potion consumed adds 1; // Elixir tolerance (Podręcznik Gry - brewing). Each potion consumed adds 1;
// recovers 1 per long rest. Soft cap = body + 1; over cap → poisoning. // recovers 1 per long rest. Soft cap = body + 1; over cap → poisoning.
elixirTolerance: makeIntField(0, { min: 0 }), elixirTolerance: makeIntField(0, { min: 0 }),
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls // Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
@@ -90,7 +90,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
condition: makeIntField(0, { min: 0 }), // derived: sum of armor conditions condition: makeIntField(0, { min: 0 }), // derived: sum of armor conditions
conditionMax: makeIntField(0, { min: 0 }), // derived: sum of armor conditionMax conditionMax: makeIntField(0, { min: 0 }), // derived: sum of armor conditionMax
}), }),
initiative: makeIntField(0, { min: 0 }), initiative: new f.SchemaField({
value: makeIntField(0, { min: 0 }),
bonus: makeIntField(0, { min: 0 }),
}),
}), }),
skills: new f.SchemaField(skillEntries), skills: new f.SchemaField(skillEntries),
details: new f.SchemaField({ details: new f.SchemaField({
@@ -101,7 +104,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
discipline: makeStringField('', { blank: true }), discipline: makeStringField('', { blank: true }),
title: makeStringField('', { blank: true }), title: makeStringField('', { blank: true }),
biography: makeHtmlField(''), biography: makeHtmlField(''),
customEquipment: makeHtmlField(''), // legacy textarea kept for migration customEquipment: makeHtmlField(''), // legacy textarea - kept for migration
customItems: makeArrayField(new f.SchemaField({ customItems: makeArrayField(new f.SchemaField({
name: makeStringField('', { blank: true }), name: makeStringField('', { blank: true }),
qty: makeIntField(1, { min: 0 }), qty: makeIntField(1, { min: 0 }),
@@ -144,7 +147,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
magicalArmor: { value: number; max: number; runicCounter: number }; magicalArmor: { value: number; max: number; runicCounter: number };
magicalShield: { value: number }; magicalShield: { value: number };
physicalArmor: { value: number; max: number; condition: number; conditionMax: number }; physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
initiative: number; initiative: { value: number; bonus: number };
}; };
skills: Record<string, { value: number; defaultAttribute: AttributeKey }>; skills: Record<string, { value: number; defaultAttribute: AttributeKey }>;
details: { raceId?: string; classIds?: string[] }; details: { raceId?: string; classIds?: string[] };
@@ -214,11 +217,12 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
} }
} }
a.initiative = a.initiative.value =
a.mind.value + a.mind.value +
(sys.skills.reflex?.value ?? 0) + (sys.skills.reflex?.value ?? 0) +
(sys.skills.perception?.value ?? 0) + (sys.skills.perception?.value ?? 0) +
initiativeBonus; initiativeBonus +
(a.initiative.bonus ?? 0);
// Clamp magical armor / shield // Clamp magical armor / shield
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max; if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
@@ -261,28 +265,13 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
// Man of Iron: +1 passive armor per stack, only when wearing no physical armor // Man of Iron: +1 passive armor per stack, only when wearing no physical armor
if (manOfIronStacks > 0 && armorPieces === 0) { if (manOfIronStacks > 0 && armorPieces === 0) {
a.physicalArmor.max = Math.max(a.physicalArmor.max, manOfIronStacks); a.physicalArmor.max = Math.max(a.physicalArmor.max, manOfIronStacks);
a.physicalArmor.value = Math.max(a.physicalArmor.value, manOfIronStacks); a.physicalArmor.value = Math.max(a.physicalArmor.value, manOfIronStacks);
} }
// Blood Pool (placeholder formula): max = body + soul. // Blood Pool (placeholder formula): max = body + soul.
a.blood.max = a.body.value + a.soul.value; a.blood.max = a.body.value + a.soul.value;
if (a.blood.value > a.blood.max) a.blood.value = a.blood.max; if (a.blood.value > a.blood.max) a.blood.value = a.blood.max;
// Advancement points (UUID-based race/class lookup; falls back to 0 if not set)
const raceItem = items.find((i) => i.type === 'race');
const classItems = items.filter((i) => i.type === 'class');
const startingAttr = Number(raceItem?.system?.attributePoints ?? 0);
const startingSkill = Number(raceItem?.system?.skillPoints ?? 0);
const startingFreeTalents = raceItem?.system?.freeTalents?.length ?? 0;
const classAttr = classItems.reduce((sum, c) => sum + Number(c.system?.attributePoints ?? 0), 0);
const classSkill = classItems.reduce((sum, c) => sum + Number(c.system?.skillPoints ?? 0), 0);
const spentAttr = (a.body.value - 1) + (a.mind.value - 1) + (a.soul.value - 1) + a.magic.value;
const spentSkill = Object.values(sys.skills).reduce((sum, s) => sum + (s.value ?? 0), 0);
const spentFreeTalents = items.filter((i) => i.type === 'talent').length;
sys.advancement.attributePointsAvailable = Math.max(0, startingAttr + classAttr + (sys.advancement.bonusAttributePoints ?? 0) - spentAttr);
sys.advancement.skillPointsAvailable = Math.max(0, startingSkill + classSkill + (sys.advancement.bonusSkillPoints ?? 0) - spentSkill);
sys.advancement.freeTalentsAvailable = Math.max(0, startingFreeTalents + (sys.advancement.bonusFreeTalents ?? 0) - spentFreeTalents);
} }
/** /**
@@ -298,6 +287,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
} }
delete initialized.attributes.armor; delete initialized.attributes.armor;
} }
if (initialized?.attributes && typeof initialized.attributes.initiative === 'number') {
const legacy = initialized.attributes.initiative;
initialized.attributes.initiative = { value: legacy, bonus: 0 };
}
return initialized; return initialized;
} }
} }
+43 -2
View File
@@ -6,11 +6,11 @@ import {
makeArrayField, makeArrayField,
fields, fields,
} from './fields'; } from './fields';
import { getMagicPowerEntry } from '../constants'; import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from '../constants';
/** /**
* NPC / Creature data model. * NPC / Creature data model.
* Looser than character (no derived health formula set directly). * Looser than character (no derived health formula - set directly).
*/ */
export class NpcData extends foundry.abstract.TypeDataModel { export class NpcData extends foundry.abstract.TypeDataModel {
static defineSchema() { static defineSchema() {
@@ -29,6 +29,17 @@ export class NpcData extends foundry.abstract.TypeDataModel {
description: makeStringField(''), description: makeStringField(''),
}); });
const skillEntries: Record<string, foundry.data.fields.DataField.Any> = {};
for (const key of SKILL_KEYS) {
skillEntries[key] = new f.SchemaField({
value: makeIntField(0, { min: 0, max: 8 }),
defaultAttribute: makeStringField(SKILLS[key] as AttributeKey, {
blank: false,
choices: ATTRIBUTES as unknown as readonly string[],
}),
});
}
return { return {
attributes: new f.SchemaField({ attributes: new f.SchemaField({
body: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }), body: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
@@ -49,8 +60,13 @@ export class NpcData extends foundry.abstract.TypeDataModel {
magicalArmor: makeValueMaxField(0, 0), magicalArmor: makeValueMaxField(0, 0),
magicalShield: makeValueMaxField(0, 0), magicalShield: makeValueMaxField(0, 0),
physicalArmor: new f.SchemaField({ value: makeIntField(0, { min: 0 }) }), physicalArmor: new f.SchemaField({ value: makeIntField(0, { min: 0 }) }),
initiative: new f.SchemaField({
value: makeIntField(0, { min: 0 }),
bonus: makeIntField(0, { min: 0 }),
}),
speed: makeStringField('5m'), speed: makeStringField('5m'),
}), }),
skills: new f.SchemaField(skillEntries),
details: new f.SchemaField({ details: new f.SchemaField({
type: makeStringField(''), type: makeStringField(''),
size: makeStringField('medium'), size: makeStringField('medium'),
@@ -88,11 +104,13 @@ export class NpcData extends foundry.abstract.TypeDataModel {
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>; const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
const sys = this as unknown as { const sys = this as unknown as {
attributes: { attributes: {
mind: { value: number };
magic: { value: number; actual: number }; magic: { value: number; actual: number };
mana: { value: number; max: number; maxPerSpell: number }; mana: { value: number; max: number; maxPerSpell: number };
health: { value: number; max: number }; health: { value: number; max: number };
magicalArmor: { value: number; max: number }; magicalArmor: { value: number; max: number };
magicalShield: { value: number; max: number }; magicalShield: { value: number; max: number };
initiative: { value: number; bonus: number };
}; };
}; };
const a = sys.attributes; const a = sys.attributes;
@@ -120,6 +138,25 @@ export class NpcData extends foundry.abstract.TypeDataModel {
a.mana.maxPerSpell = mp.maxPerSpell; a.mana.maxPerSpell = mp.maxPerSpell;
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max; if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
let initiativeBonus = 0;
for (const it of items) {
if (it.type === 'talent') {
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
const name = it.name?.toLowerCase() || '';
if (
slug === 'battle-readiness' || slug === 'gotowosc-do-walki' ||
name === 'gotowo\u015b\u0107 do walki' || name === 'battle readiness'
) {
initiativeBonus += 2;
}
}
}
a.initiative.value =
a.mind.value +
initiativeBonus +
(a.initiative.bonus ?? 0);
if (a.health.value > a.health.max) a.health.value = a.health.max; if (a.health.value > a.health.max) a.health.value = a.health.max;
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max; if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
if (a.magicalShield.value > a.magicalShield.max) a.magicalShield.value = a.magicalShield.max; if (a.magicalShield.value > a.magicalShield.max) a.magicalShield.value = a.magicalShield.max;
@@ -135,6 +172,10 @@ export class NpcData extends foundry.abstract.TypeDataModel {
} }
delete initialized.attributes.armor; delete initialized.attributes.armor;
} }
if (initialized?.attributes && typeof initialized.attributes.initiative === 'number') {
const legacy = initialized.attributes.initiative;
initialized.attributes.initiative = { value: legacy, bonus: 0 };
}
return initialized; return initialized;
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import {
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants'; import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants';
/** /**
* Unified Gear item discriminated by `category`. * Unified Gear item - discriminated by `category`.
* Weapon/armor/equipment all share base fields; specialised fields * Weapon/armor/equipment all share base fields; specialised fields
* are scoped under `weapon` and `armor` sub-schemas. * are scoped under `weapon` and `armor` sub-schemas.
*/ */
+2 -2
View File
@@ -85,7 +85,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
minCasters: makeIntField(1, { min: 1 }), minCasters: makeIntField(1, { min: 1 }),
nonCombatOnly: makeBoolField(false), nonCombatOnly: makeBoolField(false),
// Overcast structured // Overcast - structured
overcastOptions: makeArrayField(new f.SchemaField({ overcastOptions: makeArrayField(new f.SchemaField({
description: makeStringField(''), description: makeStringField(''),
manaPerStep: makeIntField(1, { min: 0 }), manaPerStep: makeIntField(1, { min: 0 }),
@@ -105,7 +105,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
// Description // Description
description: makeHtmlField(''), description: makeHtmlField(''),
higherCircles: makeHtmlField(''), // legacy superseded by overcastOptions higherCircles: makeHtmlField(''), // legacy - superseded by overcastOptions
// Legacy fields (kept for migration; will be removed in a later release) // Legacy fields (kept for migration; will be removed in a later release)
overcasting: makeStringField(''), overcasting: makeStringField(''),
+7
View File
@@ -76,6 +76,12 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/> <input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
</div>`); </div>`);
} }
if (mode !== 'sacred') {
fields.push(`
<div class="form-group">
<label><input type="checkbox" name="castAsPrayer"/> ${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</label>
</div>`);
}
if (showHekate) { if (showHekate) {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -157,6 +163,7 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
if (fd.zealSpent != null) opts.zealSpent = Math.max(1, Number(fd.zealSpent) || 1); if (fd.zealSpent != null) opts.zealSpent = Math.max(1, Number(fd.zealSpent) || 1);
if (fd.bloodSpent != null) opts.bloodSpent = Math.max(baseBlood, Number(fd.bloodSpent) || baseBlood); if (fd.bloodSpent != null) opts.bloodSpent = Math.max(baseBlood, Number(fd.bloodSpent) || baseBlood);
if (fd.hekateMode) opts.hekateMode = 'witch'; if (fd.hekateMode) opts.hekateMode = 'witch';
if (fd.castAsPrayer) opts.castAsPrayer = true;
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true; if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true; if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true;
const groupRaw = String(fd.groupCasters ?? '').trim(); const groupRaw = String(fd.groupCasters ?? '').trim();
+1 -1
View File
@@ -16,7 +16,7 @@ export async function askApplyDamage(actor: ActorLike, presetAmount = 0): Promis
return new Promise((resolve) => { return new Promise((resolve) => {
new foundry.appv1.api.Dialog({ new foundry.appv1.api.Dialog({
title: `${game.i18n.localize('HBM.damage.title')} ${actor.name}`, title: `${game.i18n.localize('HBM.damage.title')} - ${actor.name}`,
content: ` content: `
<form class="hbm-dialog"> <form class="hbm-dialog">
<div class="form-group"> <div class="form-group">
+1 -1
View File
@@ -21,7 +21,7 @@ export function collectRollModifiers(actor: any, tags: readonly SkillTag[]): Rol
const hbm = ef?.flags?.hbm; const hbm = ef?.flags?.hbm;
if (!hbm) continue; if (!hbm) continue;
// Threshold step modifiers keys may be tag names or 'all' // Threshold step modifiers - keys may be tag names or 'all'
const ts = hbm.thresholdSteps as Record<string, number> | undefined; const ts = hbm.thresholdSteps as Record<string, number> | undefined;
if (ts && typeof ts === 'object') { if (ts && typeof ts === 'object') {
if (typeof ts['all'] === 'number') result.thresholdSteps += ts['all']; if (typeof ts['all'] === 'number') result.thresholdSteps += ts['all'];
+19 -6
View File
@@ -1,10 +1,10 @@
/** /**
* Roll helpers convenience wrappers around HbmTSRoll bound to actor data. * Roll helpers - convenience wrappers around HbmTSRoll bound to actor data.
*/ */
import { HbmTSRoll } from './ts-roll'; import { HbmTSRoll } from './ts-roll';
import { collectRollModifiers, RollBlockedError } from './effect-mods'; import { collectRollModifiers, RollBlockedError } from './effect-mods';
import { ATTRIBUTES, AttributeKey, SKILLS, SKILL_TAGS, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants'; import { ATTRIBUTES, AttributeKey, SKILLS, SKILL_TAGS, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
export interface SkillRollOptions { export interface SkillRollOptions {
/** Override the skill's default attribute (GM allows another for a specific roll). */ /** Override the skill's default attribute (GM allows another for a specific roll). */
@@ -26,6 +26,13 @@ export interface ActorLike {
function getAttributeValue(actor: ActorLike, key: AttributeKey): number { function getAttributeValue(actor: ActorLike, key: AttributeKey): number {
const a = actor.system.attributes[key]; const a = actor.system.attributes[key];
if (key === 'magic' && a) {
if ('dicePool' in a && typeof a.dicePool === 'number') {
return a.dicePool;
}
const actual = (a as any).actual ?? a.value ?? 0;
return getMagicPowerEntry(actual).dicePool;
}
return a?.value ?? 0; return a?.value ?? 0;
} }
@@ -91,10 +98,16 @@ export async function rollAttribute(
} }
export async function rollInitiative(actor: ActorLike): Promise<any> { export async function rollInitiative(actor: ActorLike): Promise<any> {
const mind = getAttributeValue(actor, 'mind'); const sysAttr = actor.system.attributes as any;
const reflex = actor.system.skills?.reflex?.value ?? 0; let mod = 0;
const perception = actor.system.skills?.perception?.value ?? 0; if (sysAttr.initiative && typeof sysAttr.initiative.value === 'number') {
const mod = mind + reflex + perception; mod = sysAttr.initiative.value;
} else {
const mind = getAttributeValue(actor, 'mind');
const reflex = actor.system.skills?.reflex?.value ?? 0;
const perception = actor.system.skills?.perception?.value ?? 0;
mod = mind + reflex + perception;
}
const flavor = game.i18n.localize('HBM.resources.initiative'); const flavor = game.i18n.localize('HBM.resources.initiative');
const roll = new Roll('2d6 + @mod', { mod }); const roll = new Roll('2d6 + @mod', { mod });
+6
View File
@@ -9,6 +9,7 @@ export interface RollDialogResult {
pool: number; pool: number;
threshold: number; threshold: number;
required: number; required: number;
modifier: number;
flavor?: string; flavor?: string;
} }
@@ -36,6 +37,10 @@ export async function askRollParams(initial: {
<label>${game.i18n.localize('HBM.roll.required')} (110)</label> <label>${game.i18n.localize('HBM.roll.required')} (110)</label>
<input type="number" name="required" value="${y}" min="1" max="10"/> <input type="number" name="required" value="${y}" min="1" max="10"/>
</div> </div>
<div class="form-group">
<label>${game.i18n.localize('HBM.roll.modifier')}</label>
<input type="number" name="modifier" value="0"/>
</div>
</form>`, </form>`,
buttons: { buttons: {
roll: { roll: {
@@ -48,6 +53,7 @@ export async function askRollParams(initial: {
pool: initial.pool, pool: initial.pool,
threshold: Math.min(6, Math.max(2, Number(fd['threshold']) || t)), threshold: Math.min(6, Math.max(2, Number(fd['threshold']) || t)),
required: Math.min(10, Math.max(1, Number(fd['required']) || y)), required: Math.min(10, Math.max(1, Number(fd['required']) || y)),
modifier: Number(fd['modifier']) || 0,
flavor: initial.flavor, flavor: initial.flavor,
}); });
}, },
+2 -2
View File
@@ -9,7 +9,7 @@
* otherwise face >= T → success * otherwise face >= T → success
* - Roll succeeds when total successes ≥ requiredSuccesses (Y). * - Roll succeeds when total successes ≥ requiredSuccesses (Y).
* *
* Formula syntax: `ts(N, T, Y)` e.g. `/r ts(5, 4, 2)`. * Formula syntax: `ts(N, T, Y)` - e.g. `/r ts(5, 4, 2)`.
* The N/T/Y parameters are also accepted via constructor data. * The N/T/Y parameters are also accepted via constructor data.
*/ */
@@ -121,7 +121,7 @@ function computeTsResult(roll: Roll): HbmTsRollResult {
// Collect raw die faces from all DiceTerm results in the roll. // Collect raw die faces from all DiceTerm results in the roll.
const faces: number[] = []; const faces: number[] = [];
for (const term of roll.terms) { for (const term of roll.terms) {
// foundry.dice.terms.DiceTerm has .results array with { result } entries. // foundry.dice.terms.DiceTerm - has .results array with { result } entries.
const anyTerm = term as unknown as { results?: Array<{ result: number; active?: boolean; discarded?: boolean }> }; const anyTerm = term as unknown as { results?: Array<{ result: number; active?: boolean; discarded?: boolean }> };
if (Array.isArray(anyTerm.results)) { if (Array.isArray(anyTerm.results)) {
for (const r of anyTerm.results) { for (const r of anyTerm.results) {
+7 -7
View File
@@ -1,5 +1,5 @@
/** /**
* Homebrew Magic: RPG v3 Foundry VTT system entry point. * Homebrew Magic: RPG v3 - Foundry VTT system entry point.
* *
* All internal identifiers are English. Polish is reserved for user-facing * All internal identifiers are English. Polish is reserved for user-facing
* labels supplied via `lang/pl.json` (the canonical localisation). * labels supplied via `lang/pl.json` (the canonical localisation).
@@ -111,16 +111,16 @@ Hooks.once('init', () => {
// Override default initiative formula to HbM's 2d6 + initiative attribute. // Override default initiative formula to HbM's 2d6 + initiative attribute.
CONFIG.Combat.initiative = { CONFIG.Combat.initiative = {
formula: '2d6 + @attributes.initiative', formula: '2d6 + @attributes.initiative.value',
decimals: 2, decimals: 2,
}; };
// Handlebars helpers used in templates // Handlebars helpers used in templates
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
Handlebars.registerHelper('gt', (a: number, b: number) => a > b); Handlebars.registerHelper('gt', (a: number, b: number) => a > b);
Handlebars.registerHelper('lt', (a: number, b: number) => a < b); Handlebars.registerHelper('lt', (a: number, b: number) => a < b);
Handlebars.registerHelper('or', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).some(Boolean)); Handlebars.registerHelper('or', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).some(Boolean));
Handlebars.registerHelper('and', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).every(Boolean)); Handlebars.registerHelper('and', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).every(Boolean));
Handlebars.registerHelper('concat', (...args: unknown[]) => (args.slice(0, -1) as string[]).join('')); Handlebars.registerHelper('concat', (...args: unknown[]) => (args.slice(0, -1) as string[]).join(''));
}); });
+8 -8
View File
@@ -1,18 +1,18 @@
/** /**
* Magia Otchłani Abyss Magic logic (Klątwa Otchłani Ch. IIIVIII). * Magia Otchłani - Abyss Magic logic (Klątwa Otchłani Ch. IIIVIII).
* *
* The Abyss splits into two disciplines: * The Abyss splits into two disciplines:
* - Magia Aspektów ("aspects") controlled, low-risk, predictable * - Magia Aspektów ("aspects") - controlled, low-risk, predictable
* - Pierwotna Magia ("primal") chaotic, high-power, requires `Dary Otchłani` rolls * - Pierwotna Magia ("primal") - chaotic, high-power, requires `Dary Otchłani` rolls
* *
* This module exposes: * This module exposes:
* - dispatchAbyssCast(spell) returns 'aspects' | 'primal' from spell.discipline. * - dispatchAbyssCast(spell) - returns 'aspects' | 'primal' from spell.discipline.
* - rollAbyssGift(actor) d100 against a roll table (resolved at runtime * - rollAbyssGift(actor) - d100 against a roll table (resolved at runtime
* from the `roll-tables-abyss.dary-otchlani` pack * from the `roll-tables-abyss.dary-otchlani` pack
* when present; otherwise falls back to a chat * when present; otherwise falls back to a chat
* prompt for the GM). * prompt for the GM).
* - rollMistrzLosuPenalty(actor) d100 penalty roll (Klątwa Otchłani VII). * - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII).
* - addInsanity(actor, n) increments actor.system.attributes.insanity; * - addInsanity(actor, n) - increments actor.system.attributes.insanity;
* when crossing a threshold, fires a hook for * when crossing a threshold, fires a hook for
* the GM to apply a mental condition. * the GM to apply a mental condition.
*/ */
@@ -69,7 +69,7 @@ async function rollAgainstAbyssTable(actor: CastableActor, slug: string, flavor:
await roll.evaluate(); await roll.evaluate();
const speaker = ChatMessage.getSpeaker({ actor: actor as unknown as Actor }); const speaker = ChatMessage.getSpeaker({ actor: actor as unknown as Actor });
await roll.toMessage({ await roll.toMessage({
flavor: `${flavor} ${actor.name} (${slug})`, flavor: `${flavor} - ${actor.name} (${slug})`,
speaker, speaker,
}); });
Hooks.callAll('hbm.abyssTableRoll', actor, slug, roll.total); Hooks.callAll('hbm.abyssTableRoll', actor, slug, roll.total);
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Magia Krwi Blood Magic logic (Arcanum Sanguinis Ch. III). * Magia Krwi - Blood Magic logic (Arcanum Sanguinis Ch. III).
* *
* Three primitives: * Three primitives:
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient. * - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient.
+9 -5
View File
@@ -1,5 +1,5 @@
/** /**
* Warzenie Eliksirów Brewing logic (Podręcznik Gry, Alchemia / Aneks C). * Warzenie Eliksirów - Brewing logic (Podręcznik Gry, Alchemia / Aneks C).
* *
* Each character has `attributes.elixirTolerance` (default 0). Soft cap = body+1. * Each character has `attributes.elixirTolerance` (default 0). Soft cap = body+1.
* Drinking a potion increments the counter; exceeding the cap triggers a * Drinking a potion increments the counter; exceeding the cap triggers a
@@ -12,12 +12,12 @@
*/ */
import { HbmTSRoll } from '../dice/ts-roll'; import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants'; import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
export interface ElixirRecipe { export interface ElixirRecipe {
name: string; name: string;
/** TS test target { threshold, successes }. */ /** TS test target - { threshold, successes }. */
difficulty: { threshold: number; successes: number }; difficulty: { threshold: number; successes: number };
/** Ingredient names (free-form). */ /** Ingredient names (free-form). */
ingredients: string[]; ingredients: string[];
@@ -44,7 +44,11 @@ export async function consumeElixir(actor: CastableActor, recipe: Pick<ElixirRec
export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Promise<{ roll: HbmTSRoll; success: boolean }> { export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Promise<{ roll: HbmTSRoll; success: boolean }> {
const skillKey = recipe.skill ?? 'alchemyBrewing'; const skillKey = recipe.skill ?? 'alchemyBrewing';
const skillValue = actor.system.skills?.[skillKey]?.value ?? 0; const skillValue = actor.system.skills?.[skillKey]?.value ?? 0;
const pool = actor.system.attributes.magic.actual + skillValue; const magicAttr = actor.system.attributes.magic as any;
const magicDice = typeof magicAttr.dicePool === 'number'
? magicAttr.dicePool
: getMagicPowerEntry(magicAttr.actual ?? magicAttr.value ?? 0).dicePool;
const pool = magicDice + skillValue;
const roll = HbmTSRoll.fromParams({ const roll = HbmTSRoll.fromParams({
pool, pool,
threshold: recipe.difficulty.threshold ?? TS_DEFAULT_THRESHOLD, threshold: recipe.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
@@ -53,7 +57,7 @@ export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Pr
}); });
await roll.evaluate(); await roll.evaluate();
await roll.toMessage({ await roll.toMessage({
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || ''})`, flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || '-'})`,
}); });
return { roll, success: !!roll.ts?.isSuccess }; return { roll, success: !!roll.ts?.isSuccess };
} }
+3 -3
View File
@@ -1,5 +1,5 @@
/** /**
* Combat hooks handle per-round Mana reset, per-turn Zeal regen, * Combat hooks - handle per-round Mana reset, per-turn Zeal regen,
* and condition-driven turn behavior (skip / damage tick / death save). * and condition-driven turn behavior (skip / damage tick / death save).
*/ */
@@ -50,14 +50,14 @@ export function registerCombatHooks(): void {
await applyDamage(actor, { amount: 1, type: 'environmental', ignoreMagicalArmor: true, ignoreMagicalShield: true, ignorePhysicalArmor: true }); await applyDamage(actor, { amount: 1, type: 'environmental', ignoreMagicalArmor: true, ignoreMagicalShield: true, ignorePhysicalArmor: true });
} }
// Dying: prompt death save (simplified posts a chat reminder) // Dying: prompt death save (simplified - posts a chat reminder)
if (hasStatus(actor, 'umierajacy')) { if (hasStatus(actor, 'umierajacy')) {
ChatMessage.create({ ChatMessage.create({
content: `<strong>${actor.name}</strong>: ${game.i18n.localize('HBM.combat.deathSavePrompt')}`, content: `<strong>${actor.name}</strong>: ${game.i18n.localize('HBM.combat.deathSavePrompt')}`,
}); });
} }
// Per-turn Zeal regen (characters) base 1 + talent bonus from flag. // Per-turn Zeal regen (characters) - base 1 + talent bonus from flag.
if (actor.type === 'character') { if (actor.type === 'character') {
const sys = actor.system as { attributes: { zeal: { value: number; max: number } } }; const sys = actor.system as { attributes: { zeal: { value: number; max: number } } };
const regen = getZealRegen(actor); const regen = getZealRegen(actor);
+26 -26
View File
@@ -1,5 +1,5 @@
/** /**
* HbM canonical status conditions (Aneks A Podręcznik Gry). * HbM canonical status conditions (Aneks A - Podręcznik Gry).
* 15 entries with Active-Effect change ops and `flags.hbm.*` metadata * 15 entries with Active-Effect change ops and `flags.hbm.*` metadata
* consumed by the roll pipeline and combat hooks. * consumed by the roll pipeline and combat hooks.
*/ */
@@ -17,51 +17,51 @@ export interface ConditionDef {
const CHANGE_MODE_ADD = 2; const CHANGE_MODE_ADD = 2;
export const CONDITIONS: ConditionDef[] = [ 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 } } },
{ id: 'unieruchomiony', i18nKey: 'HBM.conditions.unieruchomiony', icon: 'icons/svg/net.svg', flags: { hbm: { thresholdSteps: { attack: 1 }, defenseSteps: -1 } } }, { 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: '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: '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: '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: '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: '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 } } },
{ {
id: 'zatruty', id: 'zatruty',
i18nKey: 'HBM.conditions.zatruty', i18nKey: 'HBM.conditions.zatruty',
icon: 'icons/svg/poison.svg', icon: 'icons/svg/poison.svg',
changes: [ changes: [
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' }, { key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' }, { key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' }, { key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' }, { key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
], ],
}, },
{ id: 'podpalony', i18nKey: 'HBM.conditions.podpalony', icon: 'icons/svg/fire.svg', flags: { hbm: { damagePerRound: { amount: 1, type: 'environmental' } } } }, { id: 'podpalony', i18nKey: 'HBM.conditions.podpalony', icon: 'icons/svg/fire.svg', flags: { hbm: { damagePerRound: { amount: 1, type: 'environmental' } } } },
{ id: 'spowolniony', i18nKey: 'HBM.conditions.spowolniony', icon: 'icons/svg/clockwork.svg', flags: { hbm: { halveSpeed: true } } }, { id: 'spowolniony', i18nKey: 'HBM.conditions.spowolniony', icon: 'icons/svg/clockwork.svg', flags: { hbm: { halveSpeed: true } } },
{ {
id: 'przeklety', id: 'przeklety',
i18nKey: 'HBM.conditions.przeklety', i18nKey: 'HBM.conditions.przeklety',
icon: 'icons/svg/sun.svg', icon: 'icons/svg/sun.svg',
changes: [ changes: [
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' }, { key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' }, { key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' }, { key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' }, { key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
], ],
flags: { hbm: { blocksRegen: true } }, flags: { hbm: { blocksRegen: true } },
}, },
{ id: 'zauroczony', i18nKey: 'HBM.conditions.zauroczony', icon: 'icons/svg/heal.svg', flags: { hbm: { thresholdSteps: { social: 1 }, charmedBy: '' } } }, { 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 } } }, { 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). // 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: { thresholdSteps: { all: 1 }, todoBookGap: true } } },
// Mental illnesses (Klątwa Otchłani Ch. VIII book chapter currently a placeholder; // Mental illnesses (Klątwa Otchłani Ch. VIII - book chapter currently a placeholder;
// these are stub registrations with `todoBookGap: true` until full mechanics drop). // these are stub registrations with `todoBookGap: true` until full mechanics drop).
{ id: 'paranoja', i18nKey: 'HBM.conditions.paranoja', icon: 'icons/svg/eye.svg', flags: { hbm: { mental: true, todoBookGap: true } } }, { id: 'paranoja', i18nKey: 'HBM.conditions.paranoja', icon: 'icons/svg/eye.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'fobia', i18nKey: 'HBM.conditions.fobia', icon: 'icons/svg/silenced.svg', flags: { hbm: { mental: true, todoBookGap: true } } }, { id: 'fobia', i18nKey: 'HBM.conditions.fobia', icon: 'icons/svg/silenced.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'depresja', i18nKey: 'HBM.conditions.depresja', icon: 'icons/svg/sleep.svg', flags: { hbm: { mental: true, todoBookGap: true } } }, { id: 'depresja', i18nKey: 'HBM.conditions.depresja', icon: 'icons/svg/sleep.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'mania', i18nKey: 'HBM.conditions.mania', icon: 'icons/svg/lightning.svg', flags: { hbm: { mental: true, todoBookGap: true } } }, { id: 'mania', i18nKey: 'HBM.conditions.mania', icon: 'icons/svg/lightning.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'schizofrenia', i18nKey: 'HBM.conditions.schizofrenia', icon: 'icons/svg/stoned.svg', flags: { hbm: { mental: true, todoBookGap: true } } }, { id: 'schizofrenia', i18nKey: 'HBM.conditions.schizofrenia', icon: 'icons/svg/stoned.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
]; ];
export function registerHbmConditions(): void { export function registerHbmConditions(): void {
+5 -5
View File
@@ -1,5 +1,5 @@
/** /**
* Damage application engine flows through 4 layers: * Damage application engine - flows through 4 layers:
* 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter) * 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter)
* 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0) * 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0)
* 3. Physical Armor (flat DR; optionally degrade `condition` by 1) * 3. Physical Armor (flat DR; optionally degrade `condition` by 1)
@@ -54,7 +54,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
let remaining = total; let remaining = total;
// Layer 1: Magical Armor DR-based; runicCounter accumulates incoming damage. // Layer 1: Magical Armor - DR-based; runicCounter accumulates incoming damage.
if (!opts.ignoreMagicalArmor && a.magicalArmor) { if (!opts.ignoreMagicalArmor && a.magicalArmor) {
const dr = Math.max(0, Number(a.magicalArmor.value) || 0); const dr = Math.max(0, Number(a.magicalArmor.value) || 0);
const absorbed = Math.min(dr, remaining); const absorbed = Math.min(dr, remaining);
@@ -70,7 +70,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
} }
} }
// Layer 2: Magical Shield pure temp HP (raw subtraction). // Layer 2: Magical Shield - pure temp HP (raw subtraction).
if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) { if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) {
const shield = Math.max(0, Number(a.magicalShield.value) || 0); const shield = Math.max(0, Number(a.magicalShield.value) || 0);
const taken = Math.min(shield, remaining); const taken = Math.min(shield, remaining);
@@ -83,7 +83,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
} }
} }
// Layer 3: Physical Armor flat DR; optionally degrade condition. // Layer 3: Physical Armor - flat DR; optionally degrade condition.
if (remaining > 0 && !opts.ignorePhysicalArmor && a.physicalArmor) { if (remaining > 0 && !opts.ignorePhysicalArmor && a.physicalArmor) {
const dr = Math.max(0, Number(a.physicalArmor.value) || 0); const dr = Math.max(0, Number(a.physicalArmor.value) || 0);
const absorbed = Math.min(dr, remaining); const absorbed = Math.min(dr, remaining);
@@ -118,7 +118,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (opts.postChat ?? true) { if (opts.postChat ?? true) {
const lines: string[] = [ const lines: string[] = [
`<strong>${actor.name}</strong> ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`, `<strong>${actor.name}</strong> - ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
]; ];
if (report.absorbed.magicalArmor) lines.push(`${game.i18n.localize('HBM.resources.magicalArmor')}: ${report.absorbed.magicalArmor}`); if (report.absorbed.magicalArmor) lines.push(`${game.i18n.localize('HBM.resources.magicalArmor')}: ${report.absorbed.magicalArmor}`);
if (report.absorbed.magicalShield) lines.push(`${game.i18n.localize('HBM.resources.magicalShield')}: ${report.absorbed.magicalShield}${report.shieldDropped ? ' ✦' : ''}`); if (report.absorbed.magicalShield) lines.push(`${game.i18n.localize('HBM.resources.magicalShield')}: ${report.absorbed.magicalShield}${report.shieldDropped ? ' ✦' : ''}`);
+5 -5
View File
@@ -3,14 +3,14 @@
* *
* Short Rest (Krótki Odpoczynek): * Short Rest (Krótki Odpoczynek):
* - Restore HP equal to actor.body.value (capped at max). * - Restore HP equal to actor.body.value (capped at max).
* - Restore mana to max-per-spell? no: short rest does NOT restore mana. * - Restore mana to max-per-spell? - no: short rest does NOT restore mana.
* - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance. * - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance.
* *
* Long Rest (Długi Odpoczynek): * Long Rest (Długi Odpoczynek):
* - Restore HP, mana, zeal to max. * - Restore HP, mana, zeal to max.
* - Restore 1 elixir tolerance (or all, if `Nadzwyczajna Odporność`? book says * - Restore 1 elixir tolerance (or all, if `Nadzwyczajna Odporność`? - book says
* elixir tolerance recovers per long rest by default; tracked here as -1). * elixir tolerance recovers per long rest by default; tracked here as -1).
* - Reset blood pool to max (assumption refine when AS spell list lands). * - Reset blood pool to max (assumption - refine when AS spell list lands).
* - Clear runic counter on magical armor. * - Clear runic counter on magical armor.
* *
* Hooks `hbm.beforeRest` and `hbm.afterRest` fire for module integration. * Hooks `hbm.beforeRest` and `hbm.afterRest` fire for module integration.
@@ -134,8 +134,8 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
const conditionIds = new Set(CONDITIONS.map(c => c.id)); const conditionIds = new Set(CONDITIONS.map(c => c.id));
for (const ef of actorAny.effects ?? []) { for (const ef of actorAny.effects ?? []) {
const isState = conditionIds.has(ef.id) || const isState = conditionIds.has(ef.id) ||
(ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) || (ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) ||
conditionIds.has(ef.flags?.core?.statusId); conditionIds.has(ef.flags?.core?.statusId);
if (isState) { if (isState) {
ids.push(ef.id); ids.push(ef.id);
+63 -23
View File
@@ -1,21 +1,21 @@
/** /**
* Spell casting workflow handles standard, sacred (Magia Sakralna), * Spell casting workflow - handles standard, sacred (Magia Sakralna),
* witch (Wiedźmia Magia), and blood (Magia Krwi) modes. * witch (Wiedźmia Magia), and blood (Magia Krwi) modes.
* *
* Pipeline per cast: * Pipeline per cast:
* 1. validateCast() gate on resources, race, talent, discipline, * 1. validateCast() - gate on resources, race, talent, discipline,
* deity, group-cast, in-combat, witch symbols * deity, group-cast, in-combat, witch symbols
* 2. Resource deduction mana / zeal / blood * 2. Resource deduction - mana / zeal / blood
* 3. TS test roll pool depends on mode * 3. TS test roll - pool depends on mode
* 4. (success) damage roll parses spell.damageBase * 4. (success) damage roll - parses spell.damageBase
* 5. Status effect auto-apply from spell.statusEffects[] * 5. Status effect auto-apply - from spell.statusEffects[]
* 6. Trigger registration from spell.triggers[] * 6. Trigger registration - from spell.triggers[]
* *
* Bypasses for GM workflows: opts.bypassSuperspellWarning, opts.bypassNonCombatBlock. * Bypasses for GM workflows: opts.bypassSuperspellWarning, opts.bypassNonCombatBlock.
*/ */
import { HbmTSRoll } from '../dice/ts-roll'; import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants'; import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import { validateCast } from './spell-validation'; import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation'; import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage'; import { rollSpellDamage } from './spell-damage';
@@ -28,7 +28,7 @@ export interface CastableActor {
type: 'character' | 'npc'; type: 'character' | 'npc';
system: { system: {
attributes: { attributes: {
magic: { value: number }; magic: { value: number; actual?: number; dicePool?: number };
soul: { value: number }; soul: { value: number };
mana: { value: number; max: number; maxPerSpell: number }; mana: { value: number; max: number; maxPerSpell: number };
zeal: { value: number; max: number }; zeal: { value: number; max: number };
@@ -71,6 +71,7 @@ export interface CastOptions {
zealSpent?: number; zealSpent?: number;
bloodSpent?: number; bloodSpent?: number;
hekateMode?: 'sacred' | 'witch'; hekateMode?: 'sacred' | 'witch';
castAsPrayer?: boolean;
bypassSuperspellWarning?: boolean; bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean; bypassNonCombatBlock?: boolean;
groupCasters?: string[]; groupCasters?: string[];
@@ -147,12 +148,16 @@ async function renderCastCard(
triggersRegistered: number, triggersRegistered: number,
): Promise<void> { ): Promise<void> {
const success = baseRoll?.ts?.isSuccess ?? false; const success = baseRoll?.ts?.isSuccess ?? false;
const mode = spell.system.castingMode ?? 'standard'; const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard');
// Costs ledger // Costs ledger
const costs: Array<{ label: string; amount: number }> = []; const costs: Array<{ label: string; amount: number }> = [];
if ((opts.manaSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.manaSpent'), amount: opts.manaSpent! }); if ((opts.manaSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.manaSpent'), amount: opts.manaSpent! });
if ((opts.zealSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! }); if (opts.castAsPrayer) {
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: 1 });
} else if ((opts.zealSpent ?? 0) > 0) {
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
}
if ((opts.bloodSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.bloodSpent'), amount: opts.bloodSpent! }); if ((opts.bloodSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.bloodSpent'), amount: opts.bloodSpent! });
// Damage block // Damage block
@@ -220,10 +225,21 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost)); const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
const a = actor.system.attributes; const a = actor.system.attributes;
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaSpent }); const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - manaSpent };
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
}
await actor.update(updates);
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0); const magicDice = typeof a.magic.dicePool === 'number'
const flavor = `${spell.name}`; ? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED; const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const baseRoll = HbmTSRoll.fromParams({ const baseRoll = HbmTSRoll.fromParams({
pool, pool,
@@ -234,7 +250,7 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
await baseRoll.evaluate(); await baseRoll.evaluate();
await baseRoll.toMessage({ flavor, speaker: opts.speaker }); await baseRoll.toMessage({ flavor, speaker: opts.speaker });
// Overcast extra TS test if manaSpent exceeds maxPerSpell. // Overcast - extra TS test if manaSpent exceeds maxPerSpell.
if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) { if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
const excess = manaSpent - a.mana.maxPerSpell; const excess = manaSpent - a.mana.maxPerSpell;
const tThreshold = Math.min(6, Math.max(2, baseCost)); const tThreshold = Math.min(6, Math.max(2, baseCost));
@@ -259,7 +275,7 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost }); await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0); const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0);
const flavor = `${spell.name} ${game.i18n.localize('HBM.spell.castingMode.sacred')}`; const flavor = `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED; const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({ const roll = HbmTSRoll.fromParams({
pool, pool,
@@ -279,10 +295,21 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
// Witch magic still has a mana cost. // Witch magic still has a mana cost.
const baseCost = Math.max(0, spell.system.manaCost ?? 0); const baseCost = Math.max(0, spell.system.manaCost ?? 0);
await actor.update({ 'system.attributes.mana.value': a.mana.value - baseCost }); const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
}
await actor.update(updates);
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0); const magicDice = typeof (a.magic as any).dicePool === 'number'
const flavor = `${spell.name}${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`; ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
const reqSuccesses = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED; const reqSuccesses = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({ const roll = HbmTSRoll.fromParams({
pool, pool,
@@ -300,18 +327,31 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
const baseCost = Math.max(0, spell.system.bloodCost ?? 0); const baseCost = Math.max(0, spell.system.bloodCost ?? 0);
const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost)); const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost));
const blood = a.blood; const blood = a.blood;
const updates: Record<string, any> = {};
if (blood) { if (blood) {
await actor.update({ 'system.attributes.blood.value': Math.max(0, blood.value - bloodSpent) }); updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent);
} }
// Mana cost (some blood spells may also have one) // Mana cost (some blood spells may also have one)
const manaCost = Math.max(0, spell.system.manaCost ?? 0); const manaCost = Math.max(0, spell.system.manaCost ?? 0);
if (manaCost > 0) { if (manaCost > 0) {
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaCost }); updates['system.attributes.mana.value'] = a.mana.value - manaCost;
} }
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
}
await actor.update(updates);
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0); const magicDice = typeof (a.magic as any).dicePool === 'number'
const flavor = `${spell.name}${game.i18n.localize('HBM.spell.castingMode.blood')}`; ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED; const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({ const roll = HbmTSRoll.fromParams({
pool, pool,
+7 -7
View File
@@ -25,13 +25,13 @@ function resolveToken(token: string, ctx: DamageContext): number {
const a = (ctx.actor as any).system?.attributes ?? {}; const a = (ctx.actor as any).system?.attributes ?? {};
const s = (ctx.actor as any).system?.skills ?? {}; const s = (ctx.actor as any).system?.skills ?? {};
switch (token) { switch (token) {
case 'body': return Number(a.body?.value ?? 0); case 'body': return Number(a.body?.value ?? 0);
case 'mind': return Number(a.mind?.value ?? 0); case 'mind': return Number(a.mind?.value ?? 0);
case 'soul': return Number(a.soul?.value ?? 0); case 'soul': return Number(a.soul?.value ?? 0);
case 'magic': return Number(a.magic?.value ?? 0); case 'magic': return Number(a.magic?.value ?? 0);
case 'magicalAbilities': return Number(s.magicalAbilities?.value ?? 0); case 'magicalAbilities': return Number(s.magicalAbilities?.value ?? 0);
case 'devotion': return Number(s.devotion?.value ?? 0); case 'devotion': return Number(s.devotion?.value ?? 0);
default: return 0; default: return 0;
} }
} }
@@ -49,7 +49,7 @@ export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext):
const tokenRe = /([a-zA-Z]+)(?:\s*([\/*])\s*(\d+))?/g; const tokenRe = /([a-zA-Z]+)(?:\s*([\/*])\s*(\d+))?/g;
formula = formula.replace(tokenRe, (match, name: string, op: string | undefined, num: string | undefined) => { formula = formula.replace(tokenRe, (match, name: string, op: string | undefined, num: string | undefined) => {
if (!ATTR_TOKENS.has(name)) { if (!ATTR_TOKENS.has(name)) {
// Leave alone (likely dice notation like 1d6 no, dice has digits before) // Leave alone (likely dice notation like 1d6 - no, dice has digits before)
return match; return match;
} }
let v = resolveToken(name, ctx); let v = resolveToken(name, ctx);
+3 -3
View File
@@ -7,8 +7,8 @@
* Hooks check the matching event and prompt/resolve the trigger. * Hooks check the matching event and prompt/resolve the trigger.
* *
* Supported events: * Supported events:
* - killWithWeapon (e.g. Szkarłatny Sztylet free spell after kill) * - killWithWeapon (e.g. Szkarłatny Sztylet - free spell after kill)
* - targetCastsSpell (e.g. Klątwa Szkarłatu opposed save → unconscious) * - targetCastsSpell (e.g. Klątwa Szkarłatu - opposed save → unconscious)
* - damageTaken (reactive shields) * - damageTaken (reactive shields)
* - turnStart (per-turn drains) * - turnStart (per-turn drains)
* *
@@ -86,7 +86,7 @@ export async function fireTriggers(
fired.push(t); fired.push(t);
// Post chat card so GM can resolve the effect manually if no auto-handler. // Post chat card so GM can resolve the effect manually if no auto-handler.
await ChatMessage.create({ await ChatMessage.create({
content: `<strong>${actor.name ?? ''}</strong> Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`, content: `<strong>${actor.name ?? ''}</strong> - Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`,
whisper: ChatMessage.getWhisperRecipients?.('GM') ?? [], whisper: ChatMessage.getWhisperRecipients?.('GM') ?? [],
}); });
if (!t.oneShot) remaining.push(t); if (!t.oneShot) remaining.push(t);
+57 -13
View File
@@ -41,18 +41,42 @@ function actorHasTalent(actor: CastableActor, talentId: string): boolean {
return false; return false;
} }
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean { function matchDiscipline(disciplineId: string, targetId: string): boolean {
const id = disciplineId.toLowerCase(); const d1 = disciplineId.toLowerCase().trim();
const items = (actor as any).items as Iterable<any> | undefined; const d2 = targetId.toLowerCase().trim();
if (!items) return false; if (d1 === d2) return true;
for (const it of items) {
if (it.type !== 'discipline') continue; const mappings: Record<string, string[]> = {
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '').toLowerCase(); crimson: ['crimson', 'magia szkarłatu', 'magia szkarłatnego kultu', 'crimson magic', 'crimson cult magic'],
if (d === id) return true; abyssaspects: ['abyssaspects', 'magia aspektów', 'aspects', 'aspect magic', 'abyss - aspects', 'magia otchłani - aspekty'],
abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'],
witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'],
blood: ['blood', 'magia krwi', 'blood magic'],
};
let key1: string | null = null;
let key2: string | null = null;
for (const [k, aliases] of Object.entries(mappings)) {
if (k === d1 || aliases.includes(d1)) key1 = k;
if (k === d2 || aliases.includes(d2)) key2 = k;
} }
// Also accept characters whose details.discipline matches
const detail = String((actor as any).system?.details?.discipline ?? '').toLowerCase(); if (key1 && key2 && key1 === key2) return true;
return detail === id; return false;
}
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
const items = (actor as any).items as Iterable<any> | undefined;
if (items) {
for (const it of items) {
if (it.type !== 'discipline') continue;
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '');
if (matchDiscipline(d, disciplineId)) return true;
}
}
const detail = String((actor as any).system?.details?.discipline ?? '');
return matchDiscipline(detail, disciplineId);
} }
function actorIsInCombat(actor: CastableActor): boolean { function actorIsInCombat(actor: CastableActor): boolean {
@@ -79,7 +103,11 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' }); errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' });
} }
} }
if (mode === 'sacred' && opts.hekateMode !== 'witch') { if (opts.castAsPrayer) {
if (1 > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
}
} else if (mode === 'sacred' && opts.hekateMode !== 'witch') {
const zealNeeded = Math.max(1, opts.zealSpent ?? 1); const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
if (zealNeeded > Number(a.zeal?.value ?? 0)) { if (zealNeeded > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' }); errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
@@ -122,7 +150,23 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
// Discipline gate (any-of) // Discipline gate (any-of)
const discReq: string[] = Array.isArray(sys.requirements?.discipline) ? sys.requirements.discipline : []; const discReq: string[] = Array.isArray(sys.requirements?.discipline) ? sys.requirements.discipline : [];
if (discReq.length > 0) { if (discReq.length > 0) {
const ok = discReq.some((d) => actorHasDiscipline(actor, d)); const hasEldritchMagic = actorHasDiscipline(actor, 'abyssPrimal');
const hasAspectMagic = actorHasDiscipline(actor, 'abyssAspects');
const hasWitchMagic = actorHasDiscipline(actor, 'witch');
// Is the spell an Eldritch Spell?
const isEldritchSpell = discReq.some((d) => matchDiscipline(d, 'abyssPrimal')) ||
matchDiscipline(sys.school ?? '', 'abyssPrimal') ||
matchDiscipline(sys.discipline ?? '', 'abyssPrimal');
let bypassDisc = false;
if (hasEldritchMagic) {
bypassDisc = true; // Eldritch Magic allows casting ANY spells
} else if ((hasWitchMagic || hasAspectMagic) && !isEldritchSpell) {
bypassDisc = true; // Witch/Aspect Magic allows casting any spells EXCEPT Eldritch spells
}
const ok = bypassDisc || discReq.some((d) => actorHasDiscipline(actor, d));
if (!ok) { if (!ok) {
const translatedDisciplines = discReq.map(d => { const translatedDisciplines = discReq.map(d => {
return game.i18n.has(`HBM.spellSchool.${d}`) ? game.i18n.localize(`HBM.spellSchool.${d}`) : d; return game.i18n.has(`HBM.spellSchool.${d}`) ? game.i18n.localize(`HBM.spellSchool.${d}`) : d;
+3 -3
View File
@@ -1,5 +1,5 @@
/** /**
* Trade logic founding companies, transactions, smuggling. * Trade logic - founding companies, transactions, smuggling.
* Source: Złoto Stal i Magia, Rozdział IX. * Source: Złoto Stal i Magia, Rozdział IX.
* *
* The book's exact difficulty formulas depend on commodity, route, and party * The book's exact difficulty formulas depend on commodity, route, and party
@@ -45,7 +45,7 @@ function rollTrade(actor: CastableActor, label: string, params: TradeTestParams)
}); });
} }
/** Founding a trading company usually a single TS test plus capital. */ /** Founding a trading company - usually a single TS test plus capital. */
export function foundCompany(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> { export function foundCompany(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
return rollTrade(actor, 'Założenie Firmy Handlowej', params); return rollTrade(actor, 'Założenie Firmy Handlowej', params);
} }
@@ -55,7 +55,7 @@ export function transaction(actor: CastableActor, params: TradeTestParams = {}):
return rollTrade(actor, 'Transakcja', params); return rollTrade(actor, 'Transakcja', params);
} }
/** Smuggling illegal transaction; failure should fire `hbm.smugglingFailed`. */ /** Smuggling - illegal transaction; failure should fire `hbm.smugglingFailed`. */
export async function smuggling(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> { export async function smuggling(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
const roll = await rollTrade(actor, 'Przemyt', params); const roll = await rollTrade(actor, 'Przemyt', params);
if (!roll.ts?.isSuccess) { if (!roll.ts?.isSuccess) {
+1 -1
View File
@@ -50,7 +50,7 @@ export async function runPendingMigrations(): Promise<void> {
await step.run(); await step.run();
} catch (err) { } catch (err) {
console.error(`${SYSTEM_ID} | Migration ${step.version} failed`, err); console.error(`${SYSTEM_ID} | Migration ${step.version} failed`, err);
ui.notifications?.error(`HbM migration ${step.version} failed see console.`); ui.notifications?.error(`HbM migration ${step.version} failed - see console.`);
return; return;
} }
} }
+69 -59
View File
@@ -1,5 +1,5 @@
/** /**
* Character sheet Foundry v13 ApplicationV2 + Handlebars. * Character sheet - Foundry v13 ApplicationV2 + Handlebars.
* Features: tabs, roll-dialogs, drag-and-drop item creation. * Features: tabs, roll-dialogs, drag-and-drop item creation.
*/ */
@@ -10,7 +10,7 @@ import { askCastOptions } from '../dice/cast-dialog';
import { askApplyDamage } from '../dice/damage-dialog'; import { askApplyDamage } from '../dice/damage-dialog';
import { applyDamage } from '../logic/damage'; import { applyDamage } from '../logic/damage';
import { rest } from '../logic/rest'; import { rest } from '../logic/rest';
import { ATTRIBUTES, AttributeKey, SKILL_KEYS } from '../constants'; import { ATTRIBUTES, AttributeKey, SKILL_KEYS, getMagicPowerEntry } from '../constants';
const { ActorSheetV2 } = foundry.applications.sheets as unknown as { const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2; ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
@@ -25,29 +25,29 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
position: { width: 760, height: 720 }, position: { width: 760, height: 720 },
window: { resizable: true, title: 'HBM.actor.character' }, window: { resizable: true, title: 'HBM.actor.character' },
actions: { actions: {
rollSkill: CharacterSheet._onRollSkill, rollSkill: CharacterSheet._onRollSkill,
rollAttribute: CharacterSheet._onRollAttribute, rollAttribute: CharacterSheet._onRollAttribute,
rollInitiative: CharacterSheet._onRollInitiative, rollInitiative: CharacterSheet._onRollInitiative,
castSpell: CharacterSheet._onCastSpell, castSpell: CharacterSheet._onCastSpell,
deleteItem: CharacterSheet._onDeleteItem, deleteItem: CharacterSheet._onDeleteItem,
editItem: CharacterSheet._onEditItem, editItem: CharacterSheet._onEditItem,
applyDamage: CharacterSheet._onApplyDamage, applyDamage: CharacterSheet._onApplyDamage,
takeBreather: CharacterSheet._onTakeBreather, takeBreather: CharacterSheet._onTakeBreather,
shortRest: CharacterSheet._onShortRest, shortRest: CharacterSheet._onShortRest,
longRest: CharacterSheet._onLongRest, longRest: CharacterSheet._onLongRest,
actorEffectCreate: CharacterSheet._onActorEffectCreate, actorEffectCreate: CharacterSheet._onActorEffectCreate,
actorEffectToggle: CharacterSheet._onActorEffectToggle, actorEffectToggle: CharacterSheet._onActorEffectToggle,
actorEffectEdit: CharacterSheet._onActorEffectEdit, actorEffectEdit: CharacterSheet._onActorEffectEdit,
actorEffectDelete: CharacterSheet._onActorEffectDelete, actorEffectDelete: CharacterSheet._onActorEffectDelete,
addArrayEntry: CharacterSheet._onAddArrayEntry, addArrayEntry: CharacterSheet._onAddArrayEntry,
removeArrayEntry: CharacterSheet._onRemoveArrayEntry, removeArrayEntry: CharacterSheet._onRemoveArrayEntry,
editImage: CharacterSheet._onEditImage, editImage: CharacterSheet._onEditImage,
toggleEquipped: CharacterSheet._onToggleEquipped, toggleEquipped: CharacterSheet._onToggleEquipped,
recalculateMoney: CharacterSheet._onRecalculateMoney, recalculateMoney: CharacterSheet._onRecalculateMoney,
}, },
form: { form: {
submitOnChange: true, submitOnChange: true,
closeOnSubmit: false, closeOnSubmit: false,
}, },
}; };
@@ -55,16 +55,16 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
main: { template: 'systems/hbm-rpg-v3/templates/actor/character.hbs' }, main: { template: 'systems/hbm-rpg-v3/templates/actor/character.hbs' },
}; };
/** Active tab per group persists across re-renders. */ /** Active tab per group - persists across re-renders. */
tabGroups: Record<string, string> = { primary: 'stats' }; tabGroups: Record<string, string> = { primary: 'stats' };
override async _prepareContext(options: unknown) { override async _prepareContext(options: unknown) {
const ctx = (await super._prepareContext(options)) as Record<string, unknown>; const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
const actor = (this as unknown as { actor: { system: unknown; items: any[] } }).actor; const actor = (this as unknown as { actor: { system: unknown; items: any[] } }).actor;
const spells = actor.items.filter((it: any) => it.type === 'spell'); const spells = actor.items.filter((it: any) => it.type === 'spell');
const gear = actor.items.filter((it: any) => it.type === 'gear'); const gear = actor.items.filter((it: any) => it.type === 'gear');
const abilities = actor.items.filter((it: any) => it.type === 'ability'); const abilities = actor.items.filter((it: any) => it.type === 'ability');
const talents = actor.items.filter((it: any) => it.type === 'talent'); const talents = actor.items.filter((it: any) => it.type === 'talent');
// Group spells by school for sheet display // Group spells by school for sheet display
const spellsBySchool = new Map<string, any[]>(); const spellsBySchool = new Map<string, any[]>();
@@ -125,7 +125,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
'alchemyTransmutation', 'alchemyBrewing', 'botany', 'alchemyTransmutation', 'alchemyBrewing', 'botany',
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth', 'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
'artifice', 'golemancy', 'runes', 'manaSourceMage', 'artifice', 'golemancy', 'runes', 'manaSourceMage',
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'blood' 'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'blood',
'crimson', 'abyssAspects', 'abyssPrimal', 'eldritch'
].map(key => ({ ].map(key => ({
key, key,
label: game.i18n.localize(`HBM.spellSchool.${key}`) label: game.i18n.localize(`HBM.spellSchool.${key}`)
@@ -136,18 +137,18 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const armors = gear.filter((it: any) => it.system?.category === 'armor'); const armors = gear.filter((it: any) => it.system?.category === 'armor');
const equipment = gear.filter((it: any) => it.system?.category === 'equipment'); const equipment = gear.filter((it: any) => it.system?.category === 'equipment');
// Localized talents list no longer needed (dropdown removed drag from compendium) // Localized talents list no longer needed (dropdown removed - drag from compendium)
return { return {
...ctx, ...ctx,
system: actor.system, system: actor.system,
attributes, attributes,
skills, skills,
races, races,
disciplines, disciplines,
tabGroups: this.tabGroups, tabGroups: this.tabGroups,
attributeKeys: ATTRIBUTES, attributeKeys: ATTRIBUTES,
skillKeys: SKILL_KEYS, skillKeys: SKILL_KEYS,
spells, spells,
spellSchoolGroups, spellSchoolGroups,
hasBloodMagic, hasBloodMagic,
@@ -159,7 +160,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
abilities, abilities,
talents, talents,
actorEffects, actorEffects,
tabs: this._prepareTabs(), tabs: this._prepareTabs(),
}; };
} }
@@ -168,8 +169,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const active = this.tabGroups['primary'] ?? 'stats'; const active = this.tabGroups['primary'] ?? 'stats';
return ['stats', 'actions', 'skills', 'spells', 'talents', 'inventory', 'effects', 'biography'].map((id) => ({ return ['stats', 'actions', 'skills', 'spells', 'talents', 'inventory', 'effects', 'biography'].map((id) => ({
id, id,
label: `HBM.ui.tabs.${id}`, label: `HBM.ui.tabs.${id}`,
active: active === id, active: active === id,
cssClass: active === id ? 'active' : '', cssClass: active === id ? 'active' : '',
})); }));
} }
@@ -211,7 +212,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const uuid = data['uuid'] as string; const uuid = data['uuid'] as string;
// Auto-link Race / Class items by UUID do NOT create embedded copies. // Auto-link Race / Class items by UUID - do NOT create embedded copies.
if (item.type === 'race') { if (item.type === 'race') {
await actor.update({ 'system.details.raceId': uuid }); await actor.update({ 'system.details.raceId': uuid });
return; return;
@@ -258,8 +259,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
type: 'button', action: 'confirm', type: 'button', action: 'confirm',
label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a', label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a',
default: true, default: true,
callback: (_ev: Event, _btn: any, html: HTMLElement) => { callback: (_ev: Event, _btn: any, dialog: any) => {
const val = (html.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim(); const val = (dialog.element.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
specified = val || null; specified = val || null;
}, },
}, },
@@ -290,17 +291,22 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
// Determine default pool so the dialog can show it // Determine default pool so the dialog can show it
const skill = actor.system.skills?.[skillKey]; const skill = actor.system.skills?.[skillKey];
const attrKey = (skill?.defaultAttribute ?? 'body') as AttributeKey; const attrKey = (skill?.defaultAttribute ?? 'body') as AttributeKey;
const pool = (actor.system.attributes[attrKey]?.value ?? 0) + (skill?.value ?? 0); const attr = actor.system.attributes[attrKey];
const attrVal = (attrKey === 'magic' && attr)
? (attr.dicePool ?? getMagicPowerEntry(attr.actual ?? attr.value ?? 0).dicePool)
: (attr?.value ?? 0);
const pool = attrVal + (skill?.value ?? 0);
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) }) }); const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) }) });
if (!params) return; if (!params) return;
await rollSkill(actor, skillKey, { await rollSkill(actor, skillKey, {
threshold: params.threshold, threshold: params.threshold,
required: params.required, required: params.required,
flavor: params.flavor, modifier: params.modifier,
flavor: params.flavor,
}); });
} }
@@ -309,13 +315,17 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
if (!attr) return; if (!attr) return;
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const pool = actor.system.attributes[attr]?.value ?? 0; const a = actor.system.attributes[attr];
const pool = (attr === 'magic' && a)
? (a.dicePool ?? getMagicPowerEntry(a.actual ?? a.value ?? 0).dicePool)
: (a?.value ?? 0);
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }) }); const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }) });
if (!params) return; if (!params) return;
await rollAttribute(actor, attr, { await rollAttribute(actor, attr, {
threshold: params.threshold, threshold: params.threshold,
required: params.required, required: params.required,
modifier: params.modifier,
}); });
} }
@@ -323,7 +333,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const itemId = target.dataset.itemId; const itemId = target.dataset.itemId;
if (!itemId) return; if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const spell = actor.items.get(itemId); const spell = actor.items.get(itemId);
if (!spell || spell.type !== 'spell') return; if (!spell || spell.type !== 'spell') return;
const opts = await askCastOptions(spell, actor); const opts = await askCastOptions(spell, actor);
@@ -337,7 +347,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const itemId = target.dataset.itemId; const itemId = target.dataset.itemId;
if (!itemId) return; if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId); const item = actor.items.get(itemId);
if (!item) return; if (!item) return;
await item.delete(); await item.delete();
} }
@@ -367,7 +377,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'breather'); const r = await rest(actor, 'breather');
await ChatMessage.create({ await ChatMessage.create({
content: `<strong>${actor.name}</strong> Chwila Wytchnienia: +${r.hpRestored} ŻYW · +${r.manaRestored} MN`, content: `<strong>${actor.name}</strong> - Chwila Wytchnienia: +${r.hpRestored} ŻYW · +${r.manaRestored} MN`,
speaker: ChatMessage.getSpeaker({ actor }), speaker: ChatMessage.getSpeaker({ actor }),
}); });
} }
@@ -376,7 +386,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'short'); const r = await rest(actor, 'short');
await ChatMessage.create({ await ChatMessage.create({
content: `<strong>${actor.name}</strong> Krótki Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN${r.toleranceRecovered ? `, ${r.toleranceRecovered} tolerancja` : ''}`, content: `<strong>${actor.name}</strong> - Krótki Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN${r.toleranceRecovered ? `, ${r.toleranceRecovered} tolerancja` : ''}`,
speaker: ChatMessage.getSpeaker({ actor }), speaker: ChatMessage.getSpeaker({ actor }),
}); });
} }
@@ -385,7 +395,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'long'); const r = await rest(actor, 'long');
await ChatMessage.create({ await ChatMessage.create({
content: `<strong>${actor.name}</strong> Długi Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN · +${r.zealRestored} ZP · +${r.bloodRestored} krew · ${r.toleranceRecovered} tolerancja`, content: `<strong>${actor.name}</strong> - Długi Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN · +${r.zealRestored} ZP · +${r.bloodRestored} krew · ${r.toleranceRecovered} tolerancja`,
speaker: ChatMessage.getSpeaker({ actor }), speaker: ChatMessage.getSpeaker({ actor }),
}); });
} }
@@ -500,14 +510,14 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
const isLive = !!(eurData || usdData); const isLive = !!(eurData || usdData);
const CURRENCIES: Record<string, { label: string; toPln: number; note: string }> = { const CURRENCIES: Record<string, { label: string; toPln: number; note: string }> = {
PLN: { label: 'PLN (Złoty)', toPln: 1, note: '' }, PLN: { label: 'PLN (Złoty)', toPln: 1, note: '' },
EUR: { label: 'EUR (Euro)', toPln: eurRate, note: `NBP ${rateDate}` }, EUR: { label: 'EUR (Euro)', toPln: eurRate, note: `NBP ${rateDate}` },
USD: { label: 'USD (Dolar)', toPln: usdRate, note: `NBP ${rateDate}` }, USD: { label: 'USD (Dolar)', toPln: usdRate, note: `NBP ${rateDate}` },
TH: { label: 'TH (Thrakka)', toPln: eurRate, note: '1:1 z EUR' }, TH: { label: 'TH (Thrakka)', toPln: eurRate, note: '1:1 z EUR' },
FC: { label: 'FC (Kredyt Federacji Sol-3)', toPln: 2.0, note: 'stały kurs 2 PLN = 1 FC' }, FC: { label: 'FC (Kredyt Federacji Sol-3)', toPln: 2.0, note: 'stały kurs 2 PLN = 1 FC' },
ST: { label: 'ST (Srebrny Talent)', toPln: 240, note: '1 ST = 240 PLN' }, ST: { label: 'ST (Srebrny Talent)', toPln: 240, note: '1 ST = 240 PLN' },
ZK: { label: 'ZK (Złota Korona)', toPln: 2880, note: '1 ZK = 12 ST' }, ZK: { label: 'ZK (Złota Korona)', toPln: 2880, note: '1 ZK = 12 ST' },
PL: { label: 'PL (Platynowy Lingot)', toPln: 34560, note: '1 PL = 12 ZK' }, PL: { label: 'PL (Platynowy Lingot)', toPln: 34560, note: '1 PL = 12 ZK' },
}; };
const currKeys = Object.keys(CURRENCIES); const currKeys = Object.keys(CURRENCIES);
const optHtml = (sel: string) => currKeys.map(k => const optHtml = (sel: string) => currKeys.map(k =>
@@ -562,8 +572,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
</div> </div>
<script>window._hbmRates=${ratesJson};</script>`; <script>window._hbmRates=${ratesJson};</script>`;
await (foundry.applications.api as any).DialogV2.inform({ await (foundry.applications.api as any).DialogV2.prompt({
title: game.i18n.localize('HBM.ui.moneyConverterTitle'), window: { title: game.i18n.localize('HBM.ui.moneyConverterTitle') },
content, content,
rejectClose: false, rejectClose: false,
}); });
+67 -25
View File
@@ -1,11 +1,11 @@
import { rollAttribute, rollInitiative } from '../dice/macros'; import { rollSkill, rollAttribute, rollInitiative } from '../dice/macros';
import { askRollParams } from '../dice/roll-dialog'; import { askRollParams } from '../dice/roll-dialog';
import { askApplyDamage } from '../dice/damage-dialog'; import { askApplyDamage } from '../dice/damage-dialog';
import { askCastOptions } from '../dice/cast-dialog'; import { askCastOptions } from '../dice/cast-dialog';
import { applyDamage } from '../logic/damage'; import { applyDamage } from '../logic/damage';
import { castSpell } from '../logic/spell-cast'; import { castSpell } from '../logic/spell-cast';
import { HbmTSRoll } from '../dice/ts-roll'; import { HbmTSRoll } from '../dice/ts-roll';
import { ATTRIBUTES, AttributeKey, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants'; import { ATTRIBUTES, AttributeKey, SKILL_KEYS, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
const { ActorSheetV2 } = foundry.applications.sheets as unknown as { const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2; ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
@@ -20,21 +20,22 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
position: { width: 640, height: 720 }, position: { width: 640, height: 720 },
window: { resizable: true, title: 'HBM.actor.npc' }, window: { resizable: true, title: 'HBM.actor.npc' },
actions: { actions: {
rollAttribute: NpcSheet._onRollAttribute, rollSkill: NpcSheet._onRollSkill,
rollInitiative: NpcSheet._onRollInitiative, rollAttribute: NpcSheet._onRollAttribute,
rollNpcAttack: NpcSheet._onRollNpcAttack, rollInitiative: NpcSheet._onRollInitiative,
applyDamage: NpcSheet._onApplyDamage, rollNpcAttack: NpcSheet._onRollNpcAttack,
castSpell: NpcSheet._onCastSpell, applyDamage: NpcSheet._onApplyDamage,
deleteItem: NpcSheet._onDeleteItem, castSpell: NpcSheet._onCastSpell,
editItem: NpcSheet._onEditItem, deleteItem: NpcSheet._onDeleteItem,
actorEffectCreate: NpcSheet._onActorEffectCreate, editItem: NpcSheet._onEditItem,
actorEffectToggle: NpcSheet._onActorEffectToggle, actorEffectCreate: NpcSheet._onActorEffectCreate,
actorEffectEdit: NpcSheet._onActorEffectEdit, actorEffectToggle: NpcSheet._onActorEffectToggle,
actorEffectDelete: NpcSheet._onActorEffectDelete, actorEffectEdit: NpcSheet._onActorEffectEdit,
addArrayEntry: NpcSheet._onAddArrayEntry, actorEffectDelete: NpcSheet._onActorEffectDelete,
removeArrayEntry: NpcSheet._onRemoveArrayEntry, addArrayEntry: NpcSheet._onAddArrayEntry,
editImage: NpcSheet._onEditImage, removeArrayEntry: NpcSheet._onRemoveArrayEntry,
recalculateMoney: NpcSheet._onRecalculateMoney, editImage: NpcSheet._onEditImage,
recalculateMoney: NpcSheet._onRecalculateMoney,
}, },
form: { submitOnChange: true, closeOnSubmit: false }, form: { submitOnChange: true, closeOnSubmit: false },
}; };
@@ -73,7 +74,13 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
}; };
}); });
return { ...ctx, system: actor.system, attributes, spells, actorEffects }; const skills = SKILL_KEYS.map(key => ({
key,
value: sys.skills?.[key]?.value ?? 0,
label: game.i18n.localize(`HBM.skills.${key}`)
}));
return { ...ctx, system: actor.system, attributes, skills, spells, actorEffects };
} }
override _onRender(context: unknown, options: unknown) { override _onRender(context: unknown, options: unknown) {
@@ -124,8 +131,8 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
type: 'button', action: 'confirm', type: 'button', action: 'confirm',
label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a', label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a',
default: true, default: true,
callback: (_ev: Event, _btn: any, html: HTMLElement) => { callback: (_ev: Event, _btn: any, dialog: any) => {
const val = (html.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim(); const val = (dialog.element.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
specified = val || null; specified = val || null;
}, },
}, },
@@ -148,17 +155,52 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
await actor.createEmbeddedDocuments('Item', [item.toObject()]); await actor.createEmbeddedDocuments('Item', [item.toObject()]);
} }
static async _onRollSkill(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const skillKey = target.dataset.skill;
if (!skillKey) return;
const actor = (this as unknown as { actor: any }).actor;
// Determine default pool so the dialog can show it
const skill = actor.system.skills?.[skillKey];
const attrKey = (skill?.defaultAttribute ?? 'body') as AttributeKey;
const attr = actor.system.attributes[attrKey];
const attrVal = (attrKey === 'magic' && attr)
? ((attr as any).dicePool ?? getMagicPowerEntry((attr as any).actual ?? attr.value ?? 0).dicePool)
: (attr?.value ?? 0);
const pool = attrVal + (skill?.value ?? 0);
const params = await askRollParams({
pool,
flavor: game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) })
});
if (!params) return;
await rollSkill(actor, skillKey, {
threshold: params.threshold,
required: params.required,
modifier: params.modifier,
flavor: params.flavor,
});
}
static async _onRollAttribute(this: NpcSheet, _event: PointerEvent, target: HTMLElement) { static async _onRollAttribute(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const attr = target.dataset.attribute as AttributeKey | undefined; const attr = target.dataset.attribute as AttributeKey | undefined;
if (!attr) return; if (!attr) return;
const actor = (this as unknown as { actor: any }).actor; const actor = (this as unknown as { actor: any }).actor;
const pool = actor.system.attributes[attr]?.value ?? 0; const a = actor.system.attributes[attr];
const pool = (attr === 'magic' && a)
? ((a as any).dicePool ?? getMagicPowerEntry((a as any).actual ?? a.value ?? 0).dicePool)
: (a?.value ?? 0);
const params = await askRollParams({ const params = await askRollParams({
pool, pool,
flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }), flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }),
}); });
if (!params) return; if (!params) return;
await rollAttribute(actor, attr, { threshold: params.threshold, required: params.required }); await rollAttribute(actor, attr, {
threshold: params.threshold,
required: params.required,
modifier: params.modifier,
});
} }
static async _onRollInitiative(this: NpcSheet, _event: PointerEvent, _target: HTMLElement) { static async _onRollInitiative(this: NpcSheet, _event: PointerEvent, _target: HTMLElement) {
@@ -177,7 +219,7 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
flavor: attack.name, flavor: attack.name,
}); });
if (!params) return; if (!params) return;
const flavor = `${attack.name}${attack.damage ? ` ${attack.damage}` : ''}`; const flavor = `${attack.name}${attack.damage ? ` - ${attack.damage}` : ''}`;
const roll = HbmTSRoll.fromParams({ const roll = HbmTSRoll.fromParams({
pool: params.pool, pool: params.pool,
threshold: params.threshold ?? TS_DEFAULT_THRESHOLD, threshold: params.threshold ?? TS_DEFAULT_THRESHOLD,
@@ -399,8 +441,8 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
</div> </div>
`; `;
await (foundry.applications.api as any).DialogV2.inform({ await (foundry.applications.api as any).DialogV2.prompt({
title: game.i18n.localize('HBM.ui.moneyConverterTitle'), window: { title: game.i18n.localize('HBM.ui.moneyConverterTitle') },
content: content, content: content,
rejectClose: false, rejectClose: false,
}); });
+78 -5
View File
@@ -1,5 +1,5 @@
/* ═══════════════════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════════════════
Homebrew Magic: RPG v3 Foundry VTT system styles Homebrew Magic: RPG v3 - Foundry VTT system styles
═══════════════════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════════════════ */
/* ── CSS custom properties ─────────────────────────────────────────────── */ /* ── CSS custom properties ─────────────────────────────────────────────── */
@@ -25,8 +25,8 @@
} }
/* Dark theme overrides */ /* Dark theme overrides */
.theme-dark .hbm, .theme-dark .hbm:not(.theme-light),
.hbm.theme-dark { .hbm.theme-dark:not(.theme-light) {
--hbm-fg: #e0d8c3; --hbm-fg: #e0d8c3;
--hbm-bg: #1a1712; --hbm-bg: #1a1712;
--hbm-bg-header: #0f0b07; --hbm-bg-header: #0f0b07;
@@ -45,9 +45,12 @@
height: 100%; height: 100%;
font-family: 'Signika', sans-serif; font-family: 'Signika', sans-serif;
color: var(--hbm-fg); color: var(--hbm-fg);
background: var(--hbm-bg); background: var(--hbm-bg) !important;
overflow: hidden; overflow: hidden;
} }
.hbm.sheet .window-content {
background: var(--hbm-bg) !important;
}
.hbm.sheet form, .hbm.sheet form,
.hbm.sheet .sheet-wrapper { .hbm.sheet .sheet-wrapper {
display: flex; display: flex;
@@ -235,7 +238,54 @@
} }
.hbm .resource-value .sep { color: var(--hbm-border); } .hbm .resource-value .sep { color: var(--hbm-border); }
.hbm .resource .static-value { font-size: 1.1rem; font-weight: 700; } .hbm .resource .static-value { font-size: 1.1rem; font-weight: 700; }
.hbm .resource small { font-size: 0.7rem; color: #666; }
/* ── Advancement panel ─────────────────────────────────────────────────── */
.hbm .advancement-panel {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--hbm-card-bg);
border: 1px solid var(--hbm-border);
border-radius: var(--hbm-radius);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.hbm .advancement-panel .advancement-title {
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--hbm-accent);
border-bottom: 1px solid var(--hbm-border);
padding-bottom: 0.2rem;
}
.hbm .advancement-panel .advancement-fields {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: space-around;
}
.hbm .advancement-panel .advancement-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hbm .advancement-panel .advancement-field label {
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hbm .advancement-panel .advancement-field input[type='number'] {
width: 4rem;
text-align: center;
font-size: 1.05rem;
font-weight: 700;
border: none;
border-bottom: 1px solid var(--hbm-border);
background: transparent;
color: var(--hbm-fg);
}
/* ── Skills grid ────────────────────────────────────────────────────────── */ /* ── Skills grid ────────────────────────────────────────────────────────── */
.hbm .skills-grid { .hbm .skills-grid {
@@ -351,6 +401,29 @@
background: var(--hbm-card-bg); background: var(--hbm-card-bg);
} }
/* ── Item Sheet Textareas ───────────────────────────────────────────────── */
.hbm.item-body textarea {
width: 100%;
min-height: 12rem;
resize: vertical;
font-family: inherit;
font-size: 0.9rem;
border: 1px solid var(--hbm-border);
border-radius: var(--hbm-radius);
padding: 0.5rem;
background: var(--hbm-card-bg);
color: var(--hbm-fg);
}
/* Secondary/smaller textareas */
.hbm.item-body textarea[name="system.physicalDescription"],
.hbm.item-body textarea[name="system.mechanics"],
.hbm.item-body textarea[name="system.effect"],
.hbm.item-body textarea[name="system.higherCircles"],
.hbm.item-body textarea[name="system.passiveAbility"] {
min-height: 6rem;
}
/* ── Dialog form ────────────────────────────────────────────────────────── */ /* ── Dialog form ────────────────────────────────────────────────────────── */
.hbm-dialog .form-group { .hbm-dialog .form-group {
display: flex; display: flex;
+5 -17
View File
@@ -1,10 +1,10 @@
{ {
"id": "hbm-rpg-v3", "id": "hbm-rpg-v3",
"title": "Homebrew Magic: 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).", "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.3.12", "version": "1.3.30",
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json", "manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.3.12.zip", "download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.3.30.zip",
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "14" "verified": "14"
@@ -211,7 +211,7 @@
}, },
{ {
"name": "talents-blood", "name": "talents-blood",
"label": "Talenty Magia Krwi", "label": "Talenty - Magia Krwi",
"system": "hbm-rpg-v3", "system": "hbm-rpg-v3",
"path": "packs/talents-blood", "path": "packs/talents-blood",
"type": "Item", "type": "Item",
@@ -229,19 +229,7 @@
}, },
{ {
"name": "talents-eldritch", "name": "talents-eldritch",
"label": "Talents - Eldritch", "label": "Talenty - Klątwa Otchłani",
"system": "hbm",
"path": "packs/talents-eldritch",
"type": "Item",
"ownership": {
"PLAYER": "OBSERVER",
"ASSISTANT": "OWNER"
},
"flags": {}
},
{
"name": "talents-eldritch",
"label": "Talenty — Klątwa Otchłani",
"system": "hbm-rpg-v3", "system": "hbm-rpg-v3",
"path": "packs/talents-eldritch", "path": "packs/talents-eldritch",
"type": "Item", "type": "Item",
+1 -1
View File
@@ -1,4 +1,4 @@
{{!-- Actor-level Active Effects section included by character and NPC sheets. --}} {{!-- Actor-level Active Effects section - included by character and NPC sheets. --}}
<fieldset class="hbm-effects actor-effects"> <fieldset class="hbm-effects actor-effects">
<legend>{{localize 'HBM.activeEffect.title'}}</legend> <legend>{{localize 'HBM.activeEffect.title'}}</legend>
<button type="button" class="hbm-effect-add" data-action="actorEffectCreate">+ {{localize 'HBM.activeEffect.addEffect'}}</button> <button type="button" class="hbm-effect-add" data-action="actorEffectCreate">+ {{localize 'HBM.activeEffect.addEffect'}}</button>
File diff suppressed because it is too large Load Diff
+29
View File
@@ -77,6 +77,19 @@
<input type="number" name="system.attributes.magicalArmor.max" value="{{system.attributes.magicalArmor.max}}" min="0" /> <input type="number" name="system.attributes.magicalArmor.max" value="{{system.attributes.magicalArmor.max}}" min="0" />
</span> </span>
</div> </div>
<div class="resource">
<label>{{localize 'HBM.resources.initiative'}}</label>
<span class="resource-value">
<span class="static-value"
title="{{localize 'HBM.resources.initiative'}}">{{system.attributes.initiative.value}}</span>
</span>
<div style="display:flex; align-items:center; gap:0.3rem; margin-top:0.15rem;">
<small style="white-space:nowrap;">{{localize 'HBM.ui.initiativeBonus'}}:</small>
<input type="number" name="system.attributes.initiative.bonus"
value="{{system.attributes.initiative.bonus}}" min="0"
style="width:2.5rem; text-align:center; font-size:0.8rem; border:none; border-bottom:1px solid var(--hbm-border); background:transparent;" />
</div>
</div>
<div class="resource"> <div class="resource">
<label>{{localize 'HBM.npc.speed'}}</label> <label>{{localize 'HBM.npc.speed'}}</label>
<input type="text" name="system.attributes.speed" value="{{system.attributes.speed}}"/> <input type="text" name="system.attributes.speed" value="{{system.attributes.speed}}"/>
@@ -94,6 +107,22 @@
<label>{{localize 'HBM.npc.languages'}}: <input type="text" name="system.details.languages" value="{{system.details.languages}}"/></label> <label>{{localize 'HBM.npc.languages'}}: <input type="text" name="system.details.languages" value="{{system.details.languages}}"/></label>
</section> </section>
<section class="npc-skills-section">
<fieldset>
<legend>{{localize 'HBM.ui.tabs.skills'}}</legend>
<div class="skills-grid">
{{#each skills as |sk|}}
<div class="skill">
<span class="skill-name">{{sk.label}}</span>
<input type="number" name="system.skills.{{sk.key}}.value" value="{{sk.value}}" min="0" max="8" />
<button type="button" class="roll-btn" data-action="rollSkill" data-skill="{{sk.key}}"
title="{{localize 'HBM.roll.roll'}}">⚂</button>
</div>
{{/each}}
</div>
</fieldset>
</section>
<section class="combat-lists"> <section class="combat-lists">
<fieldset> <fieldset>
<legend>{{localize 'HBM.npc.attacks'}} <legend>{{localize 'HBM.npc.attacks'}}
+4 -1
View File
@@ -5,6 +5,9 @@
{{#if mode}}<span class="badge mode mode-{{mode}}">{{localize (concat 'HBM.spell.castingMode.' mode)}}</span>{{/if}} {{#if mode}}<span class="badge mode mode-{{mode}}">{{localize (concat 'HBM.spell.castingMode.' mode)}}</span>{{/if}}
{{#if school}}<span class="badge school">{{localize (concat 'HBM.spellSchool.' school)}}</span>{{/if}} {{#if school}}<span class="badge school">{{localize (concat 'HBM.spellSchool.' school)}}</span>{{/if}}
{{#if circle}}<span class="badge circle">{{localize 'HBM.spell.circle'}} {{circle}}</span>{{/if}} {{#if circle}}<span class="badge circle">{{localize 'HBM.spell.circle'}} {{circle}}</span>{{/if}}
{{#if (or (eq mode "sacred") (eq mode "prayer"))}}
<span class="badge warning">{{localize 'HBM.spellCast.cannotBeUnraveled'}}</span>
{{/if}}
{{#if outcome.success}} {{#if outcome.success}}
<span class="badge result success">✓ {{localize 'HBM.spellCast.success'}}</span> <span class="badge result success">✓ {{localize 'HBM.spellCast.success'}}</span>
{{else}} {{else}}
@@ -72,7 +75,7 @@
<h4>{{localize 'HBM.spellCast.triggers'}}</h4> <h4>{{localize 'HBM.spellCast.triggers'}}</h4>
<ul> <ul>
{{#each triggers as |t|}} {{#each triggers as |t|}}
<li><span class="badge trigger-event">{{localize (concat 'HBM.triggerEvent.' t.event)}}</span> {{t.effect}}</li> <li><span class="badge trigger-event">{{localize (concat 'HBM.triggerEvent.' t.event)}}</span> - {{t.effect}}</li>
{{/each}} {{/each}}
</ul> </ul>
</section> </section>
+1 -1
View File
@@ -10,7 +10,7 @@
{{localize "HBM.roll.successes"}}: <strong>{{ts.successes}}</strong> {{localize "HBM.roll.successes"}}: <strong>{{ts.successes}}</strong>
/ {{localize "HBM.roll.required"}}: {{ts.required}} / {{localize "HBM.roll.required"}}: {{ts.required}}
({{localize "HBM.roll.threshold"}} {{ts.threshold}}) ({{localize "HBM.roll.threshold"}} {{ts.threshold}})
{{#if ts.isSuccess}}{{localize "HBM.roll.success"}}{{else}}{{localize "HBM.roll.failure"}}{{/if}} - {{#if ts.isSuccess}}{{localize "HBM.roll.success"}}{{else}}{{localize "HBM.roll.failure"}}{{/if}}
</div> </div>
{{#if ts.criticalSuccesses}} {{#if ts.criticalSuccesses}}
<div class="crit">{{localize "HBM.roll.criticalSuccess"}}: {{ts.criticalSuccesses}}</div> <div class="crit">{{localize "HBM.roll.criticalSuccess"}}: {{ts.criticalSuccesses}}</div>
+1 -1
View File
@@ -1,4 +1,4 @@
{{!-- Active Effects partial included by talent and gear sheets. --}} {{!-- Active Effects partial - included by talent and gear sheets. --}}
{{#if supportsEffects}} {{#if supportsEffects}}
<fieldset class="hbm-effects"> <fieldset class="hbm-effects">
<legend>{{localize 'HBM.activeEffect.title'}}</legend> <legend>{{localize 'HBM.activeEffect.title'}}</legend>
+1 -1
View File
@@ -3,7 +3,7 @@
<label>{{localize 'HBM.race.attributePoints'}}: <input type="number" name="system.attributePoints" value="{{system.attributePoints}}" min="0"/></label> <label>{{localize 'HBM.race.attributePoints'}}: <input type="number" name="system.attributePoints" value="{{system.attributePoints}}" min="0"/></label>
<label>{{localize 'HBM.race.skillPoints'}}: <input type="number" name="system.skillPoints" value="{{system.skillPoints}}" min="0"/></label> <label>{{localize 'HBM.race.skillPoints'}}: <input type="number" name="system.skillPoints" value="{{system.skillPoints}}" min="0"/></label>
</div> </div>
<label>{{localize 'HBM.race.physicalDescription'}}<input type="text" name="system.physicalDescription" value="{{system.physicalDescription}}"/></label> <label>{{localize 'HBM.race.physicalDescription'}}<textarea name="system.physicalDescription" rows="3">{{system.physicalDescription}}</textarea></label>
<fieldset> <fieldset>
<legend>{{localize 'HBM.race.traits'}} <legend>{{localize 'HBM.race.traits'}}
<button type="button" data-action="addArrayEntry" data-path="system.traits" <button type="button" data-action="addArrayEntry" data-path="system.traits"
+2 -2
View File
@@ -30,7 +30,7 @@
{{#if (eq system.castingMode 'sacred')}} {{#if (eq system.castingMode 'sacred')}}
<label>{{localize 'HBM.spell.deity'}}: <label>{{localize 'HBM.spell.deity'}}:
<select name="system.deity"> <select name="system.deity">
<option value=""></option> <option value="">-</option>
{{#each @root.choices.deities as |label key|}} {{#each @root.choices.deities as |label key|}}
<option value="{{key}}" {{#if (eq ../system.deity key)}}selected{{/if}}>{{label}}</option> <option value="{{key}}" {{#if (eq ../system.deity key)}}selected{{/if}}>{{label}}</option>
{{/each}} {{/each}}
@@ -39,7 +39,7 @@
{{/if}} {{/if}}
<label>{{localize 'HBM.spell.sourceBook'}}: <label>{{localize 'HBM.spell.sourceBook'}}:
<select name="system.sourceBook"> <select name="system.sourceBook">
<option value=""></option> <option value="">-</option>
{{#each @root.choices.sourceBooks as |label key|}} {{#each @root.choices.sourceBooks as |label key|}}
<option value="{{key}}" {{#if (eq ../system.sourceBook key)}}selected{{/if}}>{{label}}</option> <option value="{{key}}" {{#if (eq ../system.sourceBook key)}}selected{{/if}}>{{label}}</option>
{{/each}} {{/each}}
+5 -5
View File
@@ -1,5 +1,5 @@
/** /**
* Vitest setup stubs out Foundry globals that our modules touch on import. * Vitest setup - stubs out Foundry globals that our modules touch on import.
* Each test can override these by reaching into globalThis directly. * Each test can override these by reaching into globalThis directly.
*/ */
@@ -54,15 +54,15 @@ import { vi } from 'vitest';
mergeObject: (a: any, b: any) => Object.assign(a, b), mergeObject: (a: any, b: any) => Object.assign(a, b),
}, },
abstract: { abstract: {
TypeDataModel: class {}, TypeDataModel: class { },
}, },
applications: { applications: {
api: { api: {
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => base, HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => base,
}, },
sheets: { sheets: {
ActorSheetV2: class {}, ActorSheetV2: class { },
ItemSheetV2: class {}, ItemSheetV2: class { },
}, },
handlebars: { handlebars: {
loadTemplates: vi.fn(async () => []), loadTemplates: vi.fn(async () => []),
@@ -89,7 +89,7 @@ import { vi } from 'vitest';
}, },
}; };
// Roll stub pool roll counting successes >= threshold // Roll stub - pool roll counting successes >= threshold
class StubRoll { class StubRoll {
formula: string; formula: string;
data: any; data: any;