Author SHA1 Message Date
octoturgeandClaude Sonnet 5.5 27c900e26d feat: dice modifier in the spell cast dialog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E48f1kXhsEbAUxdkNP1yDM
2026-10-26 21:31:20 +01:00
octoturge 582b189ce6 Merge pull request 'feat: weapon wound kinds (1.5.1)' (#7) from feat/blood-magic-runic-vault-answers into main 2026-10-22 21:33:15 +02:00
octoturge 5e6dc0698e Merge pull request 'feat: weapon wound kind (1.5.1)' (#6) from feat/weapon-wound-kind into feat/blood-magic-runic-vault-answers 2026-10-22 21:32:37 +02:00
octoturge 96b2935ff5 Merge pull request 'Blood magic + Runiczny Pancerz per vault answers (1.5.0)' (#5) from feat/blood-magic-runic-vault-answers into main 2026-10-22 21:32:28 +02:00
octoturge 8d5fd7338a Merge pull request 'Runic armour mana restore + NBP rates module (1.4.1)' (#4) from feat/runic-armor-restore-nbp-rates into main 2026-10-22 00:24:31 +02:00
octoturgeandClaude Sonnet 5.5 a5f2683d15 feat: weapon wound kinds as vault slashing/piercing/blunt list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 00:23:15 +02:00
octoturgeandClaude Sonnet 5.5 23027cf1b3 feat: weapon wound kind (1.5.1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 00:05:57 +02:00
octoturgeandClaude Sonnet 5.5 7b1f6794ad feat: blood magic and Runiczny Pancerz per vault answers (1.5.0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:12:21 +02:00
octoturgeandClaude Sonnet 5.5 e4dc1842d3 feat: restore Runiczny Pancerz with Mana; NBP rates module with latest-rate fallback
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:34:58 +02:00
octoturge 601f154b75 Merge pull request 'Vault sync 2026-10-20: rules, counters, migration 1.4.0' (#3) from sync/2026-10-20 into main 2026-10-20 15:37:37 +02:00
octoturgeandClaude Sonnet 5.5 c533ad8040 feat: sync rules with vault 2026-10-20 (borrowed mana, Odetchnięcie limit, Oszołomiony, Akcje Natychmiastowe)
Adds src/logic/rules.ts, counters, migration 1.4.0, III/IV mana swap, thaumaturgy school, tests, recompiled translations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:22:31 +02:00
octoturgeandClaude Sonnet 5.5 6b8d93f772 feat: flat spell frontmatter, full source-book titles, drop circle
Parser reads human-friendly flat keys (range, targets, castingTime, overcast
list, threshold/successes, ...) and expands them to SpellData; fixes
[object Object] range, empty targets/casting time and blank overcast rows.
SOURCE_BOOKS ids now match vault slugs and show full book titles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 12:35:16 +02:00
octoturgeandClaude Sonnet 5.5 5f8e62ba02 chore: bump version to 1.3.38
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:50:12 +02:00
octoturgeandClaude Sonnet 5.5 ee6c1248ea chore: regenerate compendium translations
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:31:43 +02:00
octoturgeandClaude Sonnet 5.5 522aac8678 fix: assign eldritch spells to Abyss - Primal Magic signature list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:29:10 +02:00
octoturgeandClaude Sonnet 5.5 2f3f0057b1 feat: signatureSpells lists all spells of a discipline
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:19:24 +02:00
octoturgeandClaude Sonnet 5.5 9ca94268c5 fix: show dice in spell cast card, dedupe Alchemy folders, assign signature spells
- Spell cast card now embeds the TS dice (base + overcast) and damage dice
  instead of posting separate roll messages; Dice So Nice still animates.
- Spell pack Alchemy sub-folders renamed to Transmutacja / Warzenie Eliksirów
  so the label no longer repeats under the Alchemia folder.
- Discipline schema gains signatureSpells (the sheet already bound to it);
  build-packs fills it with each discipline's own spells.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:53:05 +02:00
octoturge e5716e276c Merge pull request 'chore: bump version to 1.3.37, regenerate compendium translations' (#2) from chore/release-1.3.37 into main
Reviewed-on: #2
2026-08-23 10:33:08 +02:00
65 changed files with 1827 additions and 287 deletions
+60
View File
@@ -7,6 +7,66 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased] ## [Unreleased]
### Added
- Spell cast dialog has a **Modyfikator liczby kości** (dice modifier, +/-). It changes the number of dice in the casting test for every casting mode (standard, sacred, witch, blood, prayer); the pool never drops below 0. The over-limit Nadczarowanie test uses the same modified pool. API: `CastOptions.diceModifier`, `applyDiceModifier()` in `rules.ts`.
## [1.5.1] - 2026-10-22
### Added
- Weapons have wound kinds (`system.weapon.woundKinds`: `slashing` sieczne / `piercing` kłute / `blunt` obuchowe, several allowed, empty = unspecified), editable on the gear sheet as a multi-select. Matches Foundry Sync Brief §18.
- Gear parser reads `wound` (string, comma list or YAML list; Polish or English words) from vault weapon notes. Unknown values are dropped; nothing is guessed.
- Packs rebuilt from the current vault: Sztylet piercing+slashing, Bicz slashing, Laska blunt, bows and Włócznia piercing; generic one-/two-handed entries list all three (pick the real type per weapon).
### Changed
- `applyDamage` takes `woundKinds` (array) instead of a single kind; passive Krew applies when any is slashing or piercing. Blunt (and unarmed) never gives Krew.
- Vault sync of 2026-10-22 needs no further logic changes: runic armour wording, Zakazana Osłona retaliation, Rozerwanie Run artifact destruction and free Immediate Action for Kradzież Życia / Złożenie w Ofierze were already covered in 1.5.0 (spell, talent and discipline text arrives through the rebuilt packs).
### Notes
- Callers of `applyDamage` still need to pass the equipped weapon's `woundKinds`.
- Kości Losu use is now an Immediate Action that counts toward the limit (vault `Zużywanie Ładunków`); the system has no Fate-dice action code yet.
## [1.5.0] - 2026-10-20
Vault answers (Foundry Sync Brief §17): blood magic and Runiczny Pancerz.
### Changed
- **Runiczny Pancerz:** one hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point; hits no longer accumulate (`runicCounter` is unused). Disabled points return at the end of the fight (`deleteCombat`), on Długi Odpoczynek, or when restored (Akcja Natychmiastowa; Amulet Osłony 2 Many per point). `destroyRunicPoints` (Rozerwanie Run) lowers the maximum permanently.
- **Shield-breaking spells** (Przebicie Eteru, Boska Włócznia, Fala Energii, Tajemniczy Strumień, Strzała Pożądania, Ostatni Sąd): every 2 damage disable a point and damage counts ×2 against Magiczna Osłona. Szkarłatny Pocisk ignores Runiczny Pancerz and Magiczna Osłona but not Pancerz; Prawdziwa Dezintegracja ignores Pancerz and destroys shields / disables all Runiczny Pancerz (`SPELL_DAMAGE_PROFILES`, chat-card `data-profile`).
- **Blood magic rewritten to the vault:** Samookaleczenie (Test Magia (Walka Wręcz) T=2, lose HP = Sukcesy, 1 Krew per HP, once per round; Większe: 2 Krew per HP), Kradzież Życia / Większa (1 Zapał, arms the next attack, Krew per HP actually lost), Złożenie w Ofierze, Wielka Ofiara (once per day), Wchłanianie Krwi (min. 1 Krew, once per round). Removed the invented ×3 self-harm penalty and `floor(damage/4)` life steal.
- Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (cutting/piercing, either side; callers pass `woundKind`/`sourceActor` to `applyDamage`); Rytualista Krwi starts a fight with 4 Krew; leftover Krew heals 2 HP each at the end of the fight.
### Added
- Actor attributes `bloodUsed.*` (per round) and `greatSacrificeUsed` (per day).
## [1.4.1] - 2026-10-20
### Added
- Runiczny Pancerz can be restored with Mana: `game.hbm.runicArmor.rechargeRunicArmor(actor, points, { partial })` – 2 Many per point, capped at the armor's maximum (Amulet Osłony, Akcja). Degradation (every 5 absorbed damage) is unchanged.
### Changed
- Money converter: NBP rates come from `src/logic/nbp.ts` – one range request per currency (first table from 1 June of the campaign year), latest published rate for future years or when no table is found, 4 s timeout, offline fallback EUR = `TRADE.eurRate` (4.36), USD = 4.00.
## [1.4.0] - 2026-10-20
Vault sync ("Foundry Sync Brief 2026-10-20").
### Changed
- Magic power levels III/IV: daily mana swapped to 24 / 21.
- Wyczerpanie Many: casting beyond the daily pool is allowed; the shortfall is borrowed (cumulative counter `manaBorrowed`, reset by Długi Odpoczynek) and a N d6 tier roll is posted.
- Over-limit overcast failure now rolls Kara za Nieudolność (N d6, tier 5-8 drains the pool by the base cost).
- Odetchnięcie limited to twice between Długie Odpoczynki (`breathersTaken`); Długi Odpoczynek resets counters.
- Oszołomiony applied automatically when one hit costs ≥ ½ max Żywotność; Koncentracja test 4:⌈⅓ lost⌉ reported; Oszołomiony/Przerażony expire at the end of the character's next turn.
- Przewrócony and Przerażony condition definitions rewritten; Magiczny Pocisk deals at least 1 after Pancerz.
- Armor-condition damage reduction rounds up.
- "Reakcja" relabelled "Akcja Natychmiastowa" (per-round counter `immediateActionsUsed` reset each round).
- Spell data recompiled from the vault (new costs, Aegis of Zeus slug `aegis-zeus`); spell `difficulty.successes` max raised to 30 (superspells); new `thaumaturgy` school.
### Added
- `src/logic/rules.ts`: pure helpers for the new rules (+ tests), `absorbBlood`, `drinkManaElixir`, trade constants.
- Migration 1.4.0.
## [Unreleased]
### Changed ### Changed
- **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.
+12 -12
View File
@@ -7,7 +7,7 @@
}, },
"Powietrzna Fala": { "Powietrzna Fala": {
"name": "Air Wave", "name": "Air Wave",
"description": "Mag wyrzuca z siebie potężną falę wiatru.\r\n\r\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." "description": "Mag wyrzuca z siebie potężną falę wiatru.\r\n\r\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 Przewrócony. Niezależnie od wyniku testu każda osoba w obszarze działania zaklęcia otrzymuje tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga."
}, },
"Ożywienie Szkieletu": { "Ożywienie Szkieletu": {
"name": "Animate Skeleton", "name": "Animate Skeleton",
@@ -27,11 +27,11 @@
}, },
"Woal Snów": { "Woal Snów": {
"name": "Dream Veil", "name": "Dream Veil",
"description": "Woal Snów przekształca obszar o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) w fantasmagoryczny świat snów i koszmarów.\r\n\r\nWszyscy przeciwnicy w obszarze działania zaklęcia muszą wykonać Test na Soul (Determinacja) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów na początku swojej tury lub po wejściu do obszaru.\r\n\r\nNiepowodzenie skutkuje otrzymaniem tylu obrażeń ignorujących Pancerz, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga rzucającego to zaklęcie, a ponadto Postać staje się Przerażona i wszystkie Testy związane z Umiejętnością Spostrzegawczość wymagają dwóch Sukcesów więcej.\r\n\r\nW przypadku zdania Testu Postać tak czy inaczej ma karę do Testów związanych z Umiejętnością Spostrzegawczość." "description": "Woal Snów przekształca obszar o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) w fantasmagoryczny świat snów i koszmarów.\r\n\r\nWszyscy przeciwnicy w obszarze działania zaklęcia muszą wykonać Test na Soul (Determinacja) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów na początku swojej tury lub po wejściu do obszaru.\r\n\r\nNiepowodzenie skutkuje otrzymaniem tylu obrażeń ignorujących Pancerz, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga rzucającego to zaklęcie, a ponadto Postać staje się [Przerażona](../../../rules/00.%20Podręcznik%20Gry/Aneks%20A%20-%20Stany%20Postaci/Przerażony.md) i wszystkie Testy związane z Umiejętnością Spostrzegawczość wymagają dwóch Sukcesów więcej.\r\n\r\nW przypadku zdania Testu Postać tak czy inaczej ma karę do Testów związanych z Umiejętnością Spostrzegawczość."
}, },
"Trzęsienie Ziemi": { "Trzęsienie Ziemi": {
"name": "Earthquake", "name": "Earthquake",
"description": "W wybranym przez maga obszarze ziemia zaczyna się silnie trząść.\r\n\r\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." "description": "W wybranym przez maga obszarze ziemia zaczyna się silnie trząść.\r\n\r\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 Przewrócona 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."
}, },
"Eteryczny Przewodnik": { "Eteryczny Przewodnik": {
"name": "Ether Guide", "name": "Ether Guide",
@@ -63,11 +63,11 @@
}, },
"Tarcza Domu": { "Tarcza Domu": {
"name": "House Shield", "name": "House Shield",
"description": "Mag otacza wybrany cel gęstą, wielowarstwową osłoną, znacznie mocniejszą niż standardowa Magiczna Tarcza.\r\n\r\nTarcza ma wytrzymałość równą wartości Umiejętności Zdolności Magiczne maga + 3. Gdy cel ma otrzymać obrażenia, są one zmniejszane maksymalnie o obecną wytrzymałość Tarczy, która spada o tyle, ile obrażeń pochłonęła. Gdy wytrzymałość spadnie do 0, zaklęcie przestaje działać.\r\n\r\nDopóki cel jest chroniony tą Tarczą, następne zaklęcie, które rzuci do końca swojej najbliższej Tury, otrzymuje dodatkową kość do puli Testu.\r\n\r\nZa dodatkowe 2 punkty Many, mag może rzucić to zaklęcie na samego siebie zamiast na inny cel." "description": "Mag otacza wybrany cel gęstą, wielowarstwową osłoną, znacznie mocniejszą niż standardowa Magiczna Tarcza.\n\nTarcza ma wytrzymałość równą wartości Umiejętności Zdolności Magiczne maga + 3. Gdy cel ma otrzymać obrażenia, są one zmniejszane maksymalnie o obecną wytrzymałość Tarczy, która spada o tyle, ile obrażeń pochłonęła. Gdy wytrzymałość spadnie do 0, zaklęcie przestaje działać.\n\nDopóki cel jest chroniony tą Tarczą, następne zaklęcie, które rzuci do końca swojej najbliższej Tury, otrzymuje dodatkową kość do puli Testu.\n\nZa dodatkowe 2 punkty Many, mag może rzucić to zaklęcie na samego siebie zamiast na inny cel."
}, },
"Lodowa Podłoga": { "Lodowa Podłoga": {
"name": "Ice Floor", "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." "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 Przewrócona."
}, },
"Podpalenie": { "Podpalenie": {
"name": "Ignition", "name": "Ignition",
@@ -95,7 +95,7 @@
}, },
"Magnetyzm": { "Magnetyzm": {
"name": "Magnetism", "name": "Magnetism",
"description": "Wykorzystujesz swoje zdolności i wiedzę dotyczącą przemian metali w celu wytworzenia pola magnetycznego o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) centrowanego na Tobie.\r\n\r\nGdy rzucisz to zaklęcie, wszystkie istoty trzymające metalową broń lub noszący metalowy ekwipunek muszą 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 wszelkie metalowe przedmioty są wyrywane z rąk tej istoty i upadają 1 m od Ciebie.\r\n\r\nPonadto, jeśli dana istota nosi metalową zbroję i nie zda tego Testu, zostaje Powalona oraz przesunięta w Twoją stronę o 2 m (do 1 m od Ciebie).\r\n\r\nPonadto wszystkie istoty w Twoim pobliżu otrzymują -1 kość do rzutów na Walkę Wręcz oraz Umiejętności Strzeleckie.\r\n\r\n---" "description": "Wykorzystujesz swoje zdolności i wiedzę dotyczącą przemian metali w celu wytworzenia pola magnetycznego o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) centrowanego na Tobie.\r\n\r\nGdy rzucisz to zaklęcie, wszystkie istoty trzymające metalową broń lub noszący metalowy ekwipunek muszą 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 wszelkie metalowe przedmioty są wyrywane z rąk tej istoty i upadają 1 m od Ciebie.\r\n\r\nPonadto, jeśli dana istota nosi metalową zbroję i nie zda tego Testu, zostaje Przewrócona oraz przesunięta w Twoją stronę o 2 m (do 1 m od Ciebie).\r\n\r\nPonadto do końca trwania zaklęcia wszystkie istoty w Twoim pobliżu otrzymują -1 kość do rzutów na Walkę Wręcz oraz Umiejętności Strzeleckie.\r\n\r\n---"
}, },
"Labirynt Umysłu": { "Labirynt Umysłu": {
"name": "Mind Labyrinth", "name": "Mind Labyrinth",
@@ -119,7 +119,7 @@
}, },
"Osłona Świty": { "Osłona Świty": {
"name": "Retinue Guard", "name": "Retinue Guard",
"description": "Mag rozdziela swoją moc pomiędzy do czterech celów w zasięgu, otaczając każdego z nich osobną, słabszą Tarczą.\r\n\r\nKażda z Tarcz ma niezależną wytrzymałość równą połowie wartości Umiejętności Zdolności Magiczne maga (zaokrąglonej w górę) + 1. Gdy dany cel ma otrzymać obrażenia, są one zmniejszane maksymalnie o wytrzymałość jego własnej Tarczy, która spada o tyle, ile obrażeń pochłonęła. Gdy wytrzymałość danej Tarczy spadnie do 0, znika tylko ta jedna Tarcza - pozostałe działają nadal.\r\n\r\nMag może objąć tym zaklęciem samego siebie jako jeden z celów bez dodatkowego kosztu." "description": "Mag rozdziela swoją moc pomiędzy do czterech celów w zasięgu, otaczając każdego z nich osobną, słabszą Tarczą.\n\nKażda z Tarcz ma niezależną wytrzymałość równą połowie wartości Umiejętności Zdolności Magiczne maga (zaokrąglonej w górę) + 1. Gdy dany cel ma otrzymać obrażenia, są one zmniejszane maksymalnie o wytrzymałość jego własnej Tarczy, która spada o tyle, ile obrażeń pochłonęła. Gdy wytrzymałość danej Tarczy spadnie do 0, znika tylko ta jedna Tarcza - pozostałe działają nadal.\n\nMag może objąć tym zaklęciem samego siebie jako jeden z celów bez dodatkowego kosztu."
}, },
"Rozsypanie": { "Rozsypanie": {
"name": "Shatter", "name": "Shatter",
@@ -127,7 +127,7 @@
}, },
"Bańka Ochronna": { "Bańka Ochronna": {
"name": "Shield Bubble", "name": "Shield Bubble",
"description": "Mag wyczarowuje kopulastą osłonę o promieniu 4 m (mechanicznie kwadrat o boku 10 m), obejmującą wszystkich sojuszników wewnątrz.\r\n\r\nBańka ma jedną, wspólną wytrzymałość równą wartości Umiejętności Zdolności Magiczne maga + 5. Każdy atak wymierzony w istotę osłoniętą Bańką, który przychodzi z zewnątrz, jest zmniejszany o aktualną wytrzymałość Bańki, która spada o tyle, ile obrażeń pochłonęła - dopóki nie spadnie do 0, po czym zaklęcie przestaje działać. Istoty wewnątrz Bańki mogą swobodnie ją opuszczać i wracać.\r\n\r\nDopóki Bańka trwa, każdy sojusznik wewnątrz niej, który rzuci zaklęcie, otrzymuje dodatkową kość do puli tego Testu.\r\n\r\nZa dodatkowe 3 punkty Many, mag może umieścić środek Bańki na sobie zamiast w innym punkcie w zasięgu." "description": "Mag wyczarowuje kopulastą osłonę o promieniu 4 m (mechanicznie kwadrat o boku 10 m), obejmującą wszystkich sojuszników wewnątrz.\n\nBańka ma jedną, wspólną wytrzymałość równą wartości Umiejętności Zdolności Magiczne maga + 3. Każdy atak wymierzony w istotę osłoniętą Bańką, który przychodzi z zewnątrz, jest zmniejszany o aktualną wytrzymałość Bańki, która spada o tyle, ile obrażeń pochłonęła - dopóki nie spadnie do 0, po czym zaklęcie przestaje działać. Istoty wewnątrz Bańki mogą swobodnie ją opuszczać i wracać.\n\nDopóki Bańka trwa, każdy sojusznik wewnątrz niej, który rzuci zaklęcie, otrzymuje dodatkową kość do puli tego Testu.\n\nZa dodatkowe 3 punkty Many, mag może umieścić środek Bańki na sobie zamiast w innym punkcie w zasięgu."
}, },
"Ukojenie": { "Ukojenie": {
"name": "Solace", "name": "Solace",
@@ -151,7 +151,7 @@
}, },
"Szybki jak Wiatr": { "Szybki jak Wiatr": {
"name": "Wind Swift", "name": "Wind Swift",
"description": "Mag splata wiatr, aby wspomóc się przy poruszaniu się.\r\n\r\nPrędkość maga zwiększa się dwukrotnie, a ponadto mag nie może zostać Powalony.\r\n\r\n---" "description": "Mag splata wiatr, aby wspomóc się przy poruszaniu się.\r\n\r\nPrędkość maga zwiększa się dwukrotnie, a ponadto mag nie może zostać Przewrócony.\r\n\r\n---"
}, },
"Alchemia": { "Alchemia": {
"name": "Alchemy" "name": "Alchemy"
@@ -162,12 +162,12 @@
"Magia Ognia": { "Magia Ognia": {
"name": "Fire Magic" "name": "Fire Magic"
}, },
"Alchemia - Transmutacja": {
"name": "Alchemy - Transmutation"
},
"Magia Żywiołów": { "Magia Żywiołów": {
"name": "Elemental Magic" "name": "Elemental Magic"
}, },
"Transmutacja": {
"name": "Transmutation"
},
"Magia Powietrza": { "Magia Powietrza": {
"name": "Air Magic" "name": "Air Magic"
}, },
+3 -3
View File
@@ -7,11 +7,11 @@
}, },
"Klątwa Szkarłatu": { "Klątwa Szkarłatu": {
"name": "Crimson Curse", "name": "Crimson Curse",
"description": "Cel zostaje spowity ciemną mgłą, która, gdy cel spróbuje rzucić zaklęcie, wchłonie w siebie część użytej magii i odda ją magowi, który chce rzucić zaklęcie.\r\n\r\nGdy cel tego zaklęcia rzuci z powodzeniem zaklęcie, musi wykonać udany Test na Determinację (Soul) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać staje się Nieprzytomna. Niezależnie od wyniku testu cel otrzymuje tyle punktów obrażeń, ile wynosi suma wartości Umiejętności Zdolności Magiczne maga i celu." "description": "Cel zostaje spowity ciemną mgłą, która, gdy cel spróbuje rzucić zaklęcie, wchłonie w siebie część użytej magii i odda ją magowi, który chce rzucić zaklęcie.\r\n\r\nGdy cel tego zaklęcia rzuci z powodzeniem zaklęcie, musi wykonać udany Test na Determinację (Soul) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać staje się Nieprzytomna i otrzymuje tyle punktów obrażeń, ile wynosi suma wartości Umiejętności Zdolności Magiczne maga i celu. Jeżeli cel zda Test, otrzymuje jedynie połowę tych obrażeń (zaokrągloną w górę) i nie traci przytomności."
}, },
"Szkarłatny Sztylet": { "Szkarłatny Sztylet": {
"name": "Crimson Dagger", "name": "Crimson Dagger",
"description": "W dłoni maga materializuje się zakrzywiony szkarłatny sztylet utkany z czerwonej energii, lub wybrana trzymana broń biała zostaje pokryta szkarłatną aurą. Broń zadaje dodatkowe 2 punkty obrażeń magicznych\r\n\r\nJeśli mag zabije przeciwnika bronią naznaczoną tym zaklęciem, może natychmiast wykonać Akcję Natychmiastową, aby rzucić dowolne znane zaklęcie Magii Szkarłatu bez wydawania Many. Efekt ten może być aktywowany wyłącznie raz na rundę.\r\n\r\nSzkarłatny Sztylet to jedno z pierwszych zaklęć, które poznają młodsi członkowie Kultu. Broń ta żywi się krwią wrogów, przekazując część ich siły życiowej swojemu władcy. Starożytne kroniki Kultu opisują, jak Pierwszy Patriarcha podczas Wielkiej Wojny z Otchłanią używał podobnego zaklęcia, by jego wojownicy mogli walczyć bez przerwy, czerpiąc moc z pokonanych wrogów." "description": "W dłoni maga materializuje się zakrzywiony szkarłatny sztylet utkany z czerwonej energii, lub wybrana trzymana broń biała zostaje pokryta szkarłatną aurą. Broń zadaje dodatkowe 2 punkty obrażeń magicznych.\r\n\r\nJeśli mag zabije przeciwnika bronią naznaczoną tym zaklęciem, może natychmiast wykonać Akcję Natychmiastową, aby rzucić bez wydawania Many dowolne znane zaklęcie Magii Szkarłatu, którego bazowy koszt many nie przekracza 3. Zaklęcia rzuconego w ten sposób nie można nadczarować. Efekt ten może być aktywowany wyłącznie raz na rundę.\r\n\r\nSzkarłatny Sztylet to jedno z pierwszych zaklęć, które poznają młodsi członkowie Kultu. Broń ta żywi się krwią wrogów, przekazując część ich siły życiowej swojemu władcy. Starożytne kroniki Kultu opisują, jak Pierwszy Patriarcha podczas Wielkiej Wojny z Otchłanią używał podobnego zaklęcia, by jego wojownicy mogli walczyć bez przerwy, czerpiąc moc z pokonanych wrogów."
}, },
"Szkarłatne Płomienie": { "Szkarłatne Płomienie": {
"name": "Crimson Flames", "name": "Crimson Flames",
@@ -19,7 +19,7 @@
}, },
"Szkarłatna Sprawiedliwość": { "Szkarłatna Sprawiedliwość": {
"name": "Crimson Justice", "name": "Crimson Justice",
"description": "Na wybrany cel spada z nieba (bądź sklepienia) szkarłatna błyskawica.\r\n\r\nCel otrzymuje obrażenia równe dwukrotności wartości Umiejętności Zdolności maga rzucającego to zaklęcie, a ponadto wszystkie osoby w promieniu 1 m od celu (włącznie z celem) muszą wykonać udany Test na Refleks (Body) 4:S, gdzie S to liczba wyrzuconych przez maga Sukcesów. W wypadku niepowodzenia postać zostaje Ogłuszona i Oślepiona." "description": "Na wybrany cel spada z nieba (bądź sklepienia) szkarłatna błyskawica.\r\n\r\nCel otrzymuje obrażenia równe dwukrotności wartości Umiejętności Zdolności Magiczne maga rzucającego to zaklęcie, a ponadto wszystkie osoby w promieniu 1 m od celu (włącznie z celem) muszą wykonać udany Test na Refleks (Body) 4:S, gdzie S to liczba wyrzuconych przez maga Sukcesów. W wypadku niepowodzenia postać zostaje Ogłuszona i Oślepiona."
} }
} }
} }
+2 -2
View File
@@ -11,7 +11,7 @@
}, },
"Tajemniczy Strumień": { "Tajemniczy Strumień": {
"name": "Mysterious Stream", "name": "Mysterious Stream",
"description": "Z ręki maga wystrzeliwuje błyskawica utkana z mroczno-fioletowej energii.\r\n\r\nTajemniczy Strumień zadaje celowi zadaje tyle punktów obrażeń, ile mag ma punktów w Umiejętności Zdolności Magiczne. Każde dwa (zamiast pięciu) punkty tych obrażeń wyłączają jeden punkt Runicznego Pancerza celu, a przeciwko Magicznej Osłonie celu (np. Magicznej Tarczy) te same obrażenia liczą się podwójnie.\r\n\r\nJeżeli cel nie posiadał magicznej osłony, musi on wykonać udany Test na Determinacja (Soul) 6:S, gdzie liczba wymaganych Sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. Jeżeli trafienie przełamało magiczną osłonę celu, wymagana liczba Sukcesów wynosi zamiast tego 2×S - poświata pękającej osłony rozdziera umysł celu na Otchłań. W obu wypadkach, w razie niepowodzenia testu, postać otrzymuje 1k3 Punktów Obłędu. Jeżeli cel posiadał magiczną osłonę, która nie została przełamana, ten efekt nie występuje." "description": "Z ręki maga wystrzeliwuje błyskawica utkana z mroczno-fioletowej energii.\r\n\r\nTajemniczy Strumień zadaje celowi tyle punktów obrażeń, ile mag ma punktów w Umiejętności Zdolności Magiczne. Każde dwa (zamiast pięciu) punkty tych obrażeń wyłączają jeden punkt Runicznego Pancerza celu, a przeciwko Magicznej Osłonie celu (np. Magicznej Tarczy) te same obrażenia liczą się podwójnie.\r\n\r\nJeżeli cel nie posiadał magicznej osłony, musi on wykonać udany Test na Determinacja (Soul) 6:S, gdzie liczba wymaganych Sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. Jeżeli trafienie przełamało magiczną osłonę celu, wymagana liczba Sukcesów wynosi zamiast tego 2×S - poświata pękającej osłony rozdziera umysł celu na Otchłań. W obu wypadkach, w razie niepowodzenia testu, postać otrzymuje 1k3 Punktów Obłędu. Jeżeli cel posiadał magiczną osłonę, która nie została przełamana, ten efekt nie występuje."
}, },
"Neutralizacja": { "Neutralizacja": {
"name": "Neutralization", "name": "Neutralization",
@@ -19,7 +19,7 @@
}, },
"Pierwotna Kula": { "Pierwotna Kula": {
"name": "Primal Orb", "name": "Primal Orb",
"description": "Mag wyczarowywuje kulę pierwotnej energii, którą następnie ciska w wybranego przeciwnika.\r\n\r\nZaklęcie zadaje celowi zadaje tyle punktów obrażeń, ile mag wynosi suma wartości Umiejętności Zdolności Magiczne oraz rzutu jedną kością sześciościenną (1k6).\r\n\r\nPierwotna Kula porusza się bardzo chaotycznie. Jeżeli w promieniu 2 m od celu, ale w zasięgu zaklęcia, jest jeszcze co najmniej jedna wroga Istota, należy wykonać rzut kością sześciościenną. Jeżeli wynik będzie niższy niż 3, to kula zamiast w wybrany cel trafia w najbliższą do celu Istotę." "description": "Mag wyczarowywuje kulę pierwotnej energii, którą następnie ciska w wybranego przeciwnika.\r\n\r\nZaklęcie zadaje celowi tyle punktów obrażeń, ile wynosi suma wartości Umiejętności Zdolności Magiczne oraz rzutu jedną kością sześciościenną (1k6).\r\n\r\nPierwotna Kula porusza się bardzo chaotycznie. Jeżeli w promieniu 2 m od celu, ale w zasięgu zaklęcia, jest jeszcze co najmniej jedna wroga Istota, należy wykonać rzut kością sześciościenną. Jeżeli wynik będzie niższy niż 3, to kula zamiast w wybrany cel trafia w najbliższą do celu Istotę."
}, },
"Przywołanie Istoty z Otchłani": { "Przywołanie Istoty z Otchłani": {
"name": "Summon Eldritch Entity", "name": "Summon Eldritch Entity",
+6 -6
View File
@@ -19,15 +19,15 @@
}, },
"Zagłada": { "Zagłada": {
"name": "Doom", "name": "Doom",
"description": "**Wymagania:** Mistrzostwo (dowolna Dziedzina), co najmniej 3 dodatkowych magów biorących udział w rytuale (wszyscy muszą znać Zagładę) - ponadto żaden mag biorący udział w rytuale nie może być Źródłem Mocy\r\n\r\nZagłada jest ostatecznym superzaklęciem unicestwienia, stworzonym jako rytuał, który nie tylko zabija, ale też wymazuje wszelką nadzieję na odwrócenie skutków. W przeciwieństwie do Destrukcji, która „tylko” eksploduje nagromadzoną manę, Zagłada celowo splata ją z samą strukturą życia istot w zasięgu.\r\n​\r\nZaklęcie wymaga wielogodzinnego rytuału, podczas którego uczestniczący magowie tworzą skomplikowaną sieć połączeń magicznych, zakończoną spięciem w głównym magu. W momencie zakończenia rytuału cała zgromadzona moc zostaje uwolniona w postaci bezgłośnej, szkarłatno‑czarnej fali, która w jednej chwili „wymazuje” życie w obrębie sfery.\r\n\r\nPo udanym rzuceniu zaklęcia emituje ono czerwoną falę światła oraz dźwięk dzwonu, który słyszą w głowie wszystkie istoty w zasięgu zaklęcia, a następnie wszystkie istoty żywe znajdujące się w sferze o średnicy 50 m, wycentrowanej na głównym magu, giną natychmiast, ignorując Pancerz, magiczne osłony, odporności, talenty, Aegis i inne superzaklęcia ochronne. Nie wykonuje się żadnych testów obronnych; śmierć następuje automatycznie. Jedynymi wyjątkami są główny mag prowadzący rytuał oraz istoty, których główny mag świadomie lub podświadomie nie chce zabić.\r\n\r\nW praktyce oznacza to, że MG, na podstawie relacji i emocji głównego maga, ustala listę postaci, które są instynktownie oszczędzane (np. najbliżsi sojusznicy, ukochane osoby). Każda z tych istot jest traktowana jako indywidualny wyjątek: fala Zagłady sprawdza je osobno i nie zadaje im żadnych obrażeń, choć wszystko wokół może zostać unicestwione - nie powstaje żadna „strefa ochronna”, w której inni mogliby się ukryć, ani nie dochodzi do jakiegokolwiek „oczyszczania” z chorób czy pasożytów.\r\n\r\nZagłada ma jednak straszliwą cenę: wszyscy pozostali magowie uczestniczący w rytuale giną, niezależnie od tego, gdzie się znajdują. Ich siły życiowe są świadomie „wypalane” w ramach zaklęcia – śmierć współrzucających nie jest efektem, którego można uniknąć zasięgiem czy osłoną, lecz integralnym kosztem rytuału.\r\n\r\nIstoty zabite przez Zagładę nie mogą zostać wskrzeszone żadną znaną magią - nie działają na nie Reanimacja, nekromancja, superzaklęcia ani boskie modlitwy wskrzeszające. Mechanicznie traktuje się je jak cele Prawdziwej Dezintegracji, z tą różnicą, że choć ciała mogą pozostać (lub nie), dusze są „wymazane” z obiegu – powrót do życia wymagałby wyłącznie wyjątkowej, fabularnej interwencji Mistrza Gry.\r\n​\r\nW miejscu rzucenia Zagłady pozostaje obszar magicznie skażony: choć brak tu „odpadów alchemicznych” jak przy Destrukcji, sama tkanka rzeczywistości jest nadpalona. MG może zdecydować, że każda istota przebywająca dłużej w tym obszarze musi wykonywać okresowe testy Duszy, ryzykując trwałe urazy psychiczne, klątwy lub inne długofalowe konsekwencje - nie jest to jednak integralna część efektu zaklęcia, a raczej fabularne piętno użycia tak nienaturalnej mocy.\r\n\r\n---" "description": "**Wymagania:** Mistrzostwo (dowolna Dziedzina), co najmniej 3 dodatkowych magów biorących udział w rytuale (wszyscy muszą znać Zagładę) - ponadto żaden mag biorący udział w rytuale nie może być Źródłem Mocy\r\n\r\nZagłada jest ostatecznym superzaklęciem unicestwienia, stworzonym jako rytuał, który nie tylko zabija, ale też wymazuje wszelką nadzieję na odwrócenie skutków. W przeciwieństwie do Destrukcji, która „tylko” eksploduje nagromadzoną manę, Zagłada celowo splata ją z samą strukturą życia istot w zasięgu.\r\n​\r\nZaklęcie wymaga wielogodzinnego rytuału, podczas którego uczestniczący magowie tworzą skomplikowaną sieć połączeń magicznych, zakończoną spięciem w głównym magu. W momencie zakończenia rytuału cała zgromadzona moc zostaje uwolniona w postaci bezgłośnej, szkarłatno-czarnej fali, która w jednej chwili „wymazuje” życie w obrębie sfery.\r\n\r\nPo udanym rzuceniu zaklęcia emituje ono czerwoną falę światła oraz dźwięk dzwonu, który słyszą w głowie wszystkie istoty w zasięgu zaklęcia, a następnie wszystkie istoty żywe znajdujące się w sferze o średnicy 50 m, wycentrowanej na głównym magu, giną natychmiast, ignorując Pancerz, magiczne osłony, odporności, talenty, Aegis i inne superzaklęcia ochronne. Nie wykonuje się żadnych testów obronnych; śmierć następuje automatycznie. Jedynymi wyjątkami są główny mag prowadzący rytuał oraz istoty, których główny mag świadomie lub podświadomie nie chce zabić.\r\n\r\nW praktyce oznacza to, że MG, na podstawie relacji i emocji głównego maga, ustala listę postaci, które są instynktownie oszczędzane (np. najbliżsi sojusznicy, ukochane osoby). Każda z tych istot jest traktowana jako indywidualny wyjątek: fala Zagłady sprawdza je osobno i nie zadaje im żadnych obrażeń, choć wszystko wokół może zostać unicestwione - nie powstaje żadna „strefa ochronna”, w której inni mogliby się ukryć, ani nie dochodzi do jakiegokolwiek „oczyszczania” z chorób czy pasożytów.\r\n\r\nZagłada ma jednak straszliwą cenę: wszyscy pozostali magowie uczestniczący w rytuale giną, niezależnie od tego, gdzie się znajdują. Ich siły życiowe są świadomie „wypalane” w ramach zaklęcia - śmierć współrzucających nie jest efektem, którego można uniknąć zasięgiem czy osłoną, lecz integralnym kosztem rytuału.\r\n\r\nIstoty zabite przez Zagładę nie mogą zostać wskrzeszone żadną znaną magią - nie działają na nie Reanimacja, nekromancja, superzaklęcia ani boskie modlitwy wskrzeszające. Mechanicznie traktuje się je jak cele Prawdziwej Dezintegracji, z tą różnicą, że choć ciała mogą pozostać (lub nie), dusze są „wymazane” z obiegu - powrót do życia wymagałby wyłącznie wyjątkowej, fabularnej interwencji Mistrza Gry.\r\n​\r\nW miejscu rzucenia Zagłady pozostaje obszar magicznie skażony: choć brak tu „odpadów alchemicznych” jak przy Destrukcji, sama tkanka rzeczywistości jest nadpalona. MG może zdecydować, że każda istota przebywająca dłużej w tym obszarze musi wykonywać okresowe testy Duszy, ryzykując trwałe urazy psychiczne, klątwy lub inne długofalowe konsekwencje - nie jest to jednak integralna część efektu zaklęcia, a raczej fabularne piętno użycia tak nienaturalnej mocy.\r\n\r\n---"
}, },
"Fala Energii": { "Fala Energii": {
"name": "Energy Wave", "name": "Energy Wave",
"description": "Mag wyrzuca z siebie potężną falę półwidzialnej energii.\r\n\r\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 cel zostaje Oszołomiony. 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. Każde dwa (zamiast pięciu) punkty tych obrażeń wyłączają jeden punkt Runicznego Pancerza każdego trafionego celu z osobna, a przeciwko Magicznej Osłonie (np. Magicznej Tarczy) tych celów te same obrażenia liczą się podwójnie." "description": "Mag wyrzuca z siebie potężną falę półwidzialnej energii.\r\n\r\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 cel zostaje Oszołomiony. Niezależnie od wyniku testu każda osoba w obszarze działania zaklęcia otrzymuje tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga. Każde dwa (zamiast pięciu) punkty tych obrażeń wyłączają jeden punkt Runicznego Pancerza każdego trafionego celu z osobna, a przeciwko Magicznej Osłonie (np. Magicznej Tarczy) tych celów te same obrażenia liczą się podwójnie."
}, },
"Przebicie Eteru": { "Przebicie Eteru": {
"name": "Ether Pierce", "name": "Ether Pierce",
"description": "Mag skupia swoją siłę, naginając rzeczywistość wokół celu, raniąc go pomimo osłon.\r\n\r\nCel otrzymuje tyle punktów Obrażeń ignorujących Pancerz, magiczne osłony i wszystkie odporności, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga.\r\n\r\nKażde dwa (zamiast pięciu) punkty obrażeń wyłączają jeden punkt Runicznego Pancerza, a przeciwko Magicznej Osłonie (np. Magicznym Tarczom) te same obrażenia liczą się podwójnie." "description": "Mag skupia swoją siłę, naginając rzeczywistość wokół celu, raniąc go pomimo osłon.\r\n\r\nCel otrzymuje tyle punktów Obrażeń ignorujących Pancerz oraz wszystkie odporności, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga.\r\n\r\nKażde dwa (zamiast pięciu) punkty obrażeń wyłączają jeden punkt Runicznego Pancerza, a przeciwko Magicznej Osłonie (np. Magicznym Tarczom) te same obrażenia liczą się podwójnie."
}, },
"Poblask": { "Poblask": {
"name": "Gleam", "name": "Gleam",
@@ -39,15 +39,15 @@
}, },
"Magiczny Pocisk": { "Magiczny Pocisk": {
"name": "Magic Missile", "name": "Magic Missile",
"description": "Mag wyczarowuje pocisk, którego wygląd jest zależny od wybranej przez siebie Dziedziny Magii.\r\n\r\nPocisk ten zadaje tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga." "description": "Mag wyczarowuje pocisk, którego wygląd jest zależny od wybranej przez siebie Dziedziny Magii.\n\nPocisk ten zadaje tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga.\n\nZwykły Pancerz nie może obniżyć obrażeń Magicznego Pocisku poniżej 1 (Runiczny Pancerz i Magiczna Osłona działają normalnie)."
}, },
"Magiczna Plaga": { "Magiczna Plaga": {
"name": "Magic Plague", "name": "Magic Plague",
"description": "**Wymagania:** Mistrzostwo (Botanika, Magic Wiedźm, Alchemia (Warzenie Eliksirów) lub Magic Otchłani)\r\n\r\nZakazana Wiedza nie bez powodu jest nazywana w ten sposób. Są po prostu rzeczy, które nigdy nie powinny ujrzeć światła dziennego, rzeczy, które sprawiają, że Jeźdźcy Apokalipsy zdają się być dziećmi w przebraniach - jednym z zaklęć powstałych dzięki tej wiedzy jest superzaklęcie Magicznej Plagi.\r\n\r\nW trakcie nowiu spotkała się wiedźma, botanik i alchemik, cała trójka studiująca Otchłań. Chcąc zasiać zniszczenie w mieście, które ich wygnało, przez całą noc opracowywali zemstę - coś, co zniszczy chlubę tego miasta, czyli magiczną akademię. Stworzyli zarazę, która bardzo szybko się rozprzestrzeniła, zabijając najpierw wszystkich uczniów, następnie pozostałych magów, a na końcu ich samych.\r\n\r\nZaklęcie uwalnia zakaźną plagę magiczną, która rozprzestrzenia się w promieniu 10 km od punktu rytuału. Plaga atakuje zdolności magiczne - dotknięte nią istoty tracą 1 punkt Magii dziennie, zaś Mana odnawia się dopiero po Krótkim Odpoczynku, nie na koniec tury. Nieleczone chore istoty umierają, gdy ich Magic spadnie do 0. Plaga przenosi się przez kontakt z zakażonymi istotami.\r\n\r\nIstoty niemagiczne i niemagowie są odporni na chorobę, jednak dalej mogą być jej nosicielami.\r\n\r\nIstoty magiczne, np. anioły, elfy czy demony są na nią szczególnie wrażliwe.\r\n\r\nLeczenie wymaga specjalnego antidotum znanego tylko rzucającym. Każdy uczestnik staje się naturalnym nosicielem, i choć zna antidotum, istnieje ok. 70% szans na to, że antidotum w przypadku rzucającego to zaklęcie nie zadziała. W przeciwieństwie do następnych zakażonych uczestnicy rytuału tracą jeden punkt Magii tygodniowo.\r\n\r\n---" "description": "**Wymagania:** Mistrzostwo (Botanika, Magic Wiedźm, Alchemia (Warzenie Eliksirów) lub Magic Otchłani)\r\n\r\nZakazana Wiedza nie bez powodu jest nazywana w ten sposób. Są po prostu rzeczy, które nigdy nie powinny ujrzeć światła dziennego, rzeczy, które sprawiają, że Jeźdźcy Apokalipsy zdają się być dziećmi w przebraniach - jednym z zaklęć powstałych dzięki tej wiedzy jest superzaklęcie Magicznej Plagi.\r\n\r\nW trakcie nowiu spotkała się wiedźma, botanik i alchemik, cała trójka studiująca Otchłań. Chcąc zasiać zniszczenie w mieście, które ich wygnało, przez całą noc opracowywali zemstę - coś, co zniszczy chlubę tego miasta, czyli magiczną akademię. Stworzyli zarazę, która bardzo szybko się rozprzestrzeniła, zabijając najpierw wszystkich uczniów, następnie pozostałych magów, a na końcu ich samych.\r\n\r\nZaklęcie uwalnia zakaźną plagę magiczną, która rozprzestrzenia się w promieniu 10 km od punktu rytuału. Plaga atakuje zdolności magiczne - dotknięte nią istoty tracą 1 punkt Magii dziennie, a ich Mana nie odnawia się podczas Odetchnięcia ani Krótkiego Odpoczynku - przywraca ją wyłącznie Długi Odpoczynek, i to tylko do połowy dziennej puli. Nieleczone chore istoty umierają, gdy ich Magic spadnie do 0. Plaga przenosi się przez kontakt z zakażonymi istotami.\r\n\r\nIstoty niemagiczne i niemagowie są odporni na chorobę, jednak dalej mogą być jej nosicielami.\r\n\r\nIstoty magiczne, np. anioły, elfy czy demony są na nią szczególnie wrażliwe.\r\n\r\nLeczenie wymaga specjalnego antidotum znanego tylko rzucającym. Każdy uczestnik staje się naturalnym nosicielem, i choć zna antidotum, istnieje ok. 70% szans na to, że antidotum w przypadku rzucającego to zaklęcie nie zadziała. W przeciwieństwie do następnych zakażonych uczestnicy rytuału tracą jeden punkt Magii tygodniowo.\r\n\r\n---"
}, },
"Magiczna Tarcza": { "Magiczna Tarcza": {
"name": "Magic Shield", "name": "Magic Shield",
"description": "Mag wyczarowuje magiczną osłonę, która chroni wybrany przez niego cel przed atakami.\r\n\r\nMagiczna Tarcza ma wytrzymałość na ataki równą połowie wartości Umiejętności Zdolności Magiczne + 1. Gdy cel ma otrzymać obrażenia, to są one zmniejszane maksymalnie o obecną wytrzymałość Magicznej Tarczy. Każdy atak zmniejsza wytrzymałość Magicznej Tarczy o ilość obrażeń, które zadaje. Jeżeli wytrzymałość Magicznej Tarczy będzie równa 0, zaklęcie przestaje działać." "description": "Mag wyczarowuje magiczną osłonę, która chroni wybrany przez niego cel przed atakami.\r\n\r\nMagiczna Tarcza ma wytrzymałość na ataki równą zaokrąglonej w górę połowie wartości Umiejętności Zdolności Magiczne + 1. Gdy cel ma otrzymać obrażenia, to są one zmniejszane maksymalnie o obecną wytrzymałość Magicznej Tarczy. Każdy atak zmniejsza wytrzymałość Magicznej Tarczy o ilość obrażeń, które zadaje. Jeżeli wytrzymałość Magicznej Tarczy będzie równa 0, zaklęcie przestaje działać."
}, },
"Szybka Myśl": { "Szybka Myśl": {
"name": "Quick Thought", "name": "Quick Thought",
+4 -4
View File
@@ -6,7 +6,7 @@
"description": "Dzięki doświadczonej dłoni Demeter-Ceres wskazane uprawy błyskawicznie dojrzewają. Rośliny można zebrać od razu - zaklęcie zużywa potencjał plonu “z przyszłości”, co powoduje, że dane rośliny nie dadzą już w danym roku owoców.\r\n\r\nKażde kolejne siewy będą mniej obfite, chyba że pole zostanie ponownie poświęcone.\r\n\r\nTo zaklęcie może zostać rzucone tylko raz na dzień, raz w sezonie na konkretny obszar." "description": "Dzięki doświadczonej dłoni Demeter-Ceres wskazane uprawy błyskawicznie dojrzewają. Rośliny można zebrać od razu - zaklęcie zużywa potencjał plonu “z przyszłości”, co powoduje, że dane rośliny nie dadzą już w danym roku owoców.\r\n\r\nKażde kolejne siewy będą mniej obfite, chyba że pole zostanie ponownie poświęcone.\r\n\r\nTo zaklęcie może zostać rzucone tylko raz na dzień, raz w sezonie na konkretny obszar."
}, },
"Egida": { "Egida": {
"name": "Aegis", "name": "Aegis Zeus",
"description": "Zeus-Jowisz otacza wybrany cel swoją ochroną.\r\n\r\nCel otrzymuje +1 Pancerza, a ponadto wszystkie ataki kierowanie w cel są trudniejsze o jeden stopień." "description": "Zeus-Jowisz otacza wybrany cel swoją ochroną.\r\n\r\nCel otrzymuje +1 Pancerza, a ponadto wszystkie ataki kierowanie w cel są trudniejsze o jeden stopień."
}, },
"Tajemne Poznanie": { "Tajemne Poznanie": {
@@ -27,7 +27,7 @@
}, },
"Ostrze Dawida": { "Ostrze Dawida": {
"name": "Blade Of David", "name": "Blade Of David",
"description": "Dzięki świętej inkantacji, rzucający może zamienić (oddane dobrowolnie lub własne) pióro anioła w ostrze o tej samej długości (od sztyletu po krótki miecz).\r\n\r\nOstrze jest bronią Tnącą Magiczną zadającą S+1 obrażeń i utrzymuje się przez 3 tury. Po tym czasie ostrze traci swoje właściwości i zamienia się w pozbawione magii pióro. Takiego pióra nie można użyć ponownie jako składnika tego zaklęcia." "description": "Dzięki świętej inkantacji, rzucający może zamienić (oddane dobrowolnie lub własne) pióro anioła w ostrze o tej samej długości (od sztyletu po krótki miecz).\r\n\r\nOstrze jest bronią Tnącą Magiczną zadającą S+1 obrażeń i utrzymuje się przez 3 rundy. Po tym czasie ostrze traci swoje właściwości i zamienia się w pozbawione magii pióro. Takiego pióra nie można użyć ponownie jako składnika tego zaklęcia."
}, },
"Więź Serc": { "Więź Serc": {
"name": "Bond Of Hearts", "name": "Bond Of Hearts",
@@ -43,7 +43,7 @@
}, },
"Urok Niewinności": { "Urok Niewinności": {
"name": "Charm Of Innocence", "name": "Charm Of Innocence",
"description": "Cel wydaje się wszystkim w otoczeniu urzekający i nieszkodliwy – agresywne działania przeciwników wobec celu wymagają dwóch Sukcesów więcej, a postronne osoby przyjmują wobec niego postawę przyjazną." "description": "Cel wydaje się wszystkim w otoczeniu urzekający i nieszkodliwy - agresywne działania przeciwników wobec celu wymagają dwóch Sukcesów więcej, a postronne osoby przyjmują wobec niego postawę przyjazną."
}, },
"Strzała Kupidyna": { "Strzała Kupidyna": {
"name": "Cupids Arrow", "name": "Cupids Arrow",
@@ -115,7 +115,7 @@
}, },
"Kradzież Many": { "Kradzież Many": {
"name": "Mana Drain", "name": "Mana Drain",
"description": "Hekate spogląda krzywym wzrokiem na wskazanego przez swojego wyznawcę niewiernego, przekazując część mocy magicznej celu magowi rzucającego to zaklęcie.\r\n\r\nDo końca trwania zaklęcia, mag rzucający to zaklęcie zwiększa swoją Manę o 2 oraz Maksymalny Koszt Zaklęcia o 1, zaś cel zmniejsza swój Maksymalny Koszt Zaklęcia o 2." "description": "Hekate spogląda krzywym wzrokiem na wskazanego przez swojego wyznawcę niewiernego, przekazując część mocy magicznej celu magowi rzucającego to zaklęcie.\r\n\r\nMag rzucający to zaklęcie natychmiast odzyskuje 1k6 punktów Many - jego Mana nie może jednak przekroczyć dziennej puli. Ponadto do końca trwania zaklęcia Maksymalny Koszt Zaklęcia maga zwiększa się o 1, zaś Maksymalny Koszt Zaklęcia celu zmniejsza się o 2."
}, },
"Modlitwa o Opiekę": { "Modlitwa o Opiekę": {
"name": "Prayer For Protection", "name": "Prayer For Protection",
+1 -1
View File
@@ -27,7 +27,7 @@
}, },
"Większe Wchłanianie Krwi": { "Większe Wchłanianie Krwi": {
"name": "Wieksze Wchlanianie Krwi", "name": "Wieksze Wchlanianie Krwi",
"description": "Ta Akcja zastępuje Akcję \"Wchłanianie Krwi\"\nKoszt: Akcja Natychmiastowa, min. 1 punkt Krwi\n\nMag wchłania w siebie zebraną energię życiową wrogów. Za każdy poświęcany 1 punkt Krwi, mag odzyskuje 5 punktów Żywotności.\n\nZamiast leczenia, mag za każde pięć punktów Krwi może odzyskać 1 punkt Zapału." "description": "Ta Akcja zastępuje Akcję \"Wchłanianie Krwi\"\nKoszt: Akcja Natychmiastowa, min. 1 punkt Krwi\n\nMag wchłania w siebie zebraną energię życiową wrogów. Za każdy poświęcany 1 punkt Krwi, mag odzyskuje 3 punkty Żywotności.\n\nZamiast leczenia, mag za każde pięć punktów Krwi może odzyskać 1 punkt Zapału."
}, },
"Wielka Ofiara": { "Wielka Ofiara": {
"name": "Wielka Ofiara", "name": "Wielka Ofiara",
+1 -1
View File
@@ -15,7 +15,7 @@
}, },
"Metamagia": { "Metamagia": {
"name": "Metamagic", "name": "Metamagic",
"description": "*Ten Talent można wykupić wyłącznie podczas tworzenia postaci lub jeżeli Postać staje się Magiem 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](Rozdwojone%20Zaklęcie.md)\n- [Niezniszczalne Zaklęcie](Niezniszczalne%20Zaklęcie.md)\n- [Rozproszenie Magii](Rozproszenie%20Magii.md)\n- [Błyskawiczne Czarostwo](Błyskawiczne%20Czarostwo.md)" "description": "*Ten Talent można wykupić wyłącznie podczas tworzenia postaci lub jeżeli Postać staje się Magiem 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 Metamagii, 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](Rozdwojone%20Zaklęcie.md)\n- [Niezniszczalne Zaklęcie](Niezniszczalne%20Zaklęcie.md)\n- [Rozproszenie Magii](Rozproszenie%20Magii.md)\n- [Błyskawiczne Czarostwo](Błyskawiczne%20Czarostwo.md)"
}, },
"Niezniszczalne Zaklęcie": { "Niezniszczalne Zaklęcie": {
"name": "Niezniszczalne Zaklecie", "name": "Niezniszczalne Zaklecie",
+8 -8
View File
@@ -7,7 +7,7 @@
}, },
"Dawniej Cały, Wkrótce Cały": { "Dawniej Cały, Wkrótce Cały": {
"name": "Dawniej Caly Wkrotce Caly", "name": "Dawniej Caly Wkrotce Caly",
"description": "Wielki Taumaturg utracił ciało, a jego dusza została rozdarta na strzępy - jednak dzięki działaniom Szkarłatnego Kultu z dnia na dzień odzyskuje coraz więcej swoich mocy.\n\nWszystkie zaklęcia z Magii Szkarłatnej wymagają dwóch sukcesów więcej, ale Postać może rzucić jedno dowolne znane zaklęcie w dowolnym momencie rundy." "description": "Wielki Taumaturg utracił ciało, a jego dusza została rozdarta na strzępy - jednak dzięki działaniom Szkarłatnego Kultu z dnia na dzień odzyskuje coraz więcej swoich mocy.\n\nWszystkie zaklęcia z Magii Szkarłatnej wymagają dwóch sukcesów więcej, ale Postać może rzucić jedno dowolne znane zaklęcie w dowolnym momencie rundy (jako Akcję Natychmiastową)."
}, },
"Głos Rozkazu": { "Głos Rozkazu": {
"name": "Glos Rozkazu", "name": "Glos Rozkazu",
@@ -15,7 +15,7 @@
}, },
"Księżniczka Piekieł": { "Księżniczka Piekieł": {
"name": "Ksiezniczka Piekiel", "name": "Ksiezniczka Piekiel",
"description": "Tytuł \"księżniczka demonów\" nie jest u Narcyzy kurtuazją - to uznany przez inne demony stopień Autorytetu, który nosi od urodzenia, niezależnie od tego, ile ze swojej mocy akurat ukrywa pod studenckimi szatami.\n\nTa Postać posiada Autorytet na poziomie IV (patrz Stan: [Autorytet](../../00.%20Podręcznik%20Gry/Aneks%20A%20-%20Stany%20Postaci/Autorytet.md)) oraz odnawia dodatkowy 1 punkt Zapału na początku każdej swojej Tury, ponad standardową regenerację." "description": "Tytuł \"księżniczka demonów\" nie jest u Narcyzy kurtuazją - to uznany przez inne demony stopień Autorytetu, który nosi od urodzenia, niezależnie od tego, ile ze swojej mocy akurat ukrywa pod studenckimi szatami.\n\nTa Postać posiada Autorytet na poziomie IV (patrz Stan: [Autorytet](../../00.%20Podręcznik%20Gry/Aneks%20A%20-%20Stany%20Postaci/Autorytet.md)) oraz odnawia dodatkowy 1 punkt Zapału na początku każdej swojej Tury, ponad standardową regenerację.\n\nWszystko to ma swoją cenę: klątwa Pana Demonów ciągle wysysa z Narcyzy siły. Dodatkowy punkt Zapału z tego Talentu nie odnawia się na początku Tury, jeżeli w poprzedniej Turze użyła [Rozkazu Księżniczki](Rozkaz%20Księżniczki.md). Poza tym na początku każdej swojej Tury, w której jakikolwiek jej Rozkaz wciąż obowiązuje, traci 1 punkt Żywotności, którego nie obniża żaden Pancerz, Runiczny Pancerz ani Magiczna Osłona."
}, },
"Mackowate Więzy Otchłani": { "Mackowate Więzy Otchłani": {
"name": "Mackowate Wiezy Otchlani", "name": "Mackowate Wiezy Otchlani",
@@ -39,7 +39,7 @@
}, },
"Oko, Które Widziało Wszystko": { "Oko, Które Widziało Wszystko": {
"name": "Oko Ktore Widzialo Wszystko", "name": "Oko Ktore Widzialo Wszystko",
"description": "Wieki spędzone jako naczynie Gilgamesza nauczyły Pelagiusa Caudexa jednej rzeczy: w walce niemal nie istnieje ruch, cios czy zaklęcie, którego nie widziałby już wcześniej, w innym ciele, w innym starciu, wieki temu.\n\nRaz na rundę, gdy Postać jest celem Ataku, Testu Przeciwstawnego lub zaklęcia, może zadeklarować Unik lub Parowanie bez poświęcania na to Akcji ani Akcji Natychmiastowej, a do wykonywanego w ten sposób Testu dodaje kości w liczbie równej wartości Umiejętności Intuicja.\n\nJeżeli Postać w ten sposób unika lub paruje Atak, którego wcześniej (w tym starciu) już raz uniknęła lub sparowała w identyczny sposób, to zamiast Testu automatycznie odnosi sukces równy liczbie wymaganych Sukcesów - Pelagius po prostu przestaje dawać się zaskakiwać tym samym sztuczkom dwa razy." "description": "Wieki spędzone jako naczynie Gilgamesza nauczyły Pelagiusa Caudexa jednej rzeczy: w walce niemal nie istnieje ruch, cios czy zaklęcie, którego nie widziałby już wcześniej, w innym ciele, w innym starciu, wieki temu.\n\nRaz na rundę, gdy Postać jest celem Ataku, Testu Przeciwstawnego lub zaklęcia, może zadeklarować Unik lub Parowanie (jako Akcję Natychmiastową, zgodnie z zasadami Tury), a do wykonywanego w ten sposób Testu dodaje kości w liczbie równej wartości Umiejętności Intuicja.\n\nJeżeli Postać w ten sposób unika lub paruje Atak, którego wcześniej (w tym starciu) już raz uniknęła lub sparowała w identyczny sposób, to zamiast Testu automatycznie odnosi sukces równy liczbie wymaganych Sukcesów - Pelagius po prostu przestaje dawać się zaskakiwać tym samym sztuczkom dwa razy."
}, },
"Otoczony": { "Otoczony": {
"name": "Otoczony", "name": "Otoczony",
@@ -55,7 +55,7 @@
}, },
"Pierwszy Patriarcha": { "Pierwszy Patriarcha": {
"name": "Pierwszy Patriarcha", "name": "Pierwszy Patriarcha",
"description": "Szkarłatny Kult jest wypaczeniem nauk Wielkiego Taumaturga, jednak to dzięki niemu ten potężny mag odzyskał na tyle sił, aby być w stanie wpływać na losy świata, zyskując przy okazji nowe zdolności wynikające bezpośrednio ze Szkarłatnej Magii - będącej Dziedziną, która przekształciła jego nauki w narzędzie wojny.\n\nZa każdego aktywnie walczącego z Pierwszym Patriarchą Maga Otchłani, Pierwszy Patriarcha może rzucić dodatkowe zaklęcie za darmo. Ponadto Postać odzyskuje dodatkowy punkt Zapału na początku swojej Tury." "description": "Szkarłatny Kult jest wypaczeniem nauk Wielkiego Taumaturga, jednak to dzięki niemu ten potężny mag odzyskał na tyle sił, aby być w stanie wpływać na losy świata, zyskując przy okazji nowe zdolności wynikające bezpośrednio ze Szkarłatnej Magii - będącej Dziedziną, która przekształciła jego nauki w narzędzie wojny.\n\nZa każdego aktywnie walczącego z Pierwszym Patriarchą Maga Otchłani, Pierwszy Patriarcha może rzucić dodatkowe zaklęcie za darmo. Ponadto Postać odzyskuje dodatkowy punkt Zapału na początku swojej Tury.\n\n> [!note] Świadomy wyjątek\n> Pierwszy Patriarcha jest w praktyce pomniejszym bóstwem, więc ten Talent celowo łamie ogólne zasady (limit darmowych akcji, regenerację Zapału). Istoty na podobnym poziomie mocy mogą mieć zdolności wykraczające poza zasady śmiertelników - zawsze opisane wprost w ich Talentach."
}, },
"Pochłonięcie": { "Pochłonięcie": {
"name": "Pochloniecie", "name": "Pochloniecie",
@@ -63,7 +63,7 @@
}, },
"Pożeranie Zaklęć": { "Pożeranie Zaklęć": {
"name": "Pozeranie Zaklec", "name": "Pozeranie Zaklec",
"description": "Gdy zaklęcie trafia w tę Postać, nie zawsze robi to, co powinno - czasem po prostu znika, wchłonięte, zanim zdąży zadziałać.\n\nGdy ta Postać staje się celem zaklęcia, może, jako Reakcję, spróbować je pochłonąć zamiast pozwolić mu zadziałać. Jeśli forma tej Postaci nie ma określonej inaczej szansy powodzenia (patrz jej karta), wymaga to Testu Magic (Zdolności Magiczne) 4:1. W przypadku sukcesu zaklęcie nie ma żadnego efektu, a ta Postać wybiera jedno z poniższych:\n\n- **Kontratak** - natychmiast wykonuje darmowy Atak przeciwko rzucającemu, korzystając z zasad swojego podstawowego Ataku (bez dodatkowego kosztu Akcji).\n- **Efekt** - zamiast atakować, nakłada na rzucającego jedną dowolną właściwość z [Zamiaru](Zamiar.md) (np. wymuszony Test Obłędu, zniszczenie punktu Runicznego Pancerza czy Magicznej Osłony) bez wykonywania Ataku ani Testu - pochłonięta magia po prostu obraca się przeciwko rzucającemu.\n\n> [!gm]+ Tylko dla MG\n> W formie kostki śluzu ta Postać pochłania zaklęcia niemal odruchowo - Test wykonuje automatycznie z sukcesem, bez rzutu, chyba że zaklęcie jest wyjątkowo potężne lub fabularnie ważne, by przeszło." "description": "Gdy zaklęcie trafia w tę Postać, nie zawsze robi to, co powinno - czasem po prostu znika, wchłonięte, zanim zdąży zadziałać.\n\nGdy ta Postać staje się celem zaklęcia, może, jako Akcję Natychmiastową, spróbować je pochłonąć zamiast pozwolić mu zadziałać. Jeśli forma tej Postaci nie ma określonej inaczej szansy powodzenia (patrz jej karta), wymaga to Testu Magic (Zdolności Magiczne) 4:1. W przypadku sukcesu zaklęcie nie ma żadnego efektu, a ta Postać wybiera jedno z poniższych:\n\n- **Kontratak** - natychmiast wykonuje darmowy Atak przeciwko rzucającemu, korzystając z zasad swojego podstawowego Ataku (bez dodatkowego kosztu Akcji).\n- **Efekt** - zamiast atakować, nakłada na rzucającego jedną dowolną właściwość z [Zamiaru](Zamiar.md) (np. wymuszony Test Obłędu, zniszczenie punktu Runicznego Pancerza czy Magicznej Osłony) bez wykonywania Ataku ani Testu - pochłonięta magia po prostu obraca się przeciwko rzucającemu.\n\n> [!gm]+ Tylko dla MG\n> W formie kostki śluzu ta Postać pochłania zaklęcia niemal odruchowo - Test wykonuje automatycznie z sukcesem, bez rzutu, chyba że zaklęcie jest wyjątkowo potężne lub fabularnie ważne, by przeszło."
}, },
"Przywilej Baby Jagi": { "Przywilej Baby Jagi": {
"name": "Przywilej Baby Jagi", "name": "Przywilej Baby Jagi",
@@ -71,11 +71,11 @@
}, },
"Rozkaz Księżniczki": { "Rozkaz Księżniczki": {
"name": "Rozkaz Ksiezniczki", "name": "Rozkaz Ksiezniczki",
"description": "Narcyzie nie trzeba niczego wymuszać siłą - wystarczy, że każe.\n\nJako Akcję Natychmiastową, kosztem 1 punktu Zapału, ta Postać wybiera jeden cel w zasięgu 15 m, który jest w stanie zrozumieć jej polecenia, i wykonuje Test Soul (Przekonywanie) 4:S. Rozstrzygnięcie następuje zgodnie ze Stanem [Autorytet](../../00.%20Podręcznik%20Gry/Aneks%20A%20-%20Stany%20Postaci/Autorytet.md): jeżeli Autorytet celu jest o dwa stopnie niższy niż Autorytet tej Postaci (zwykle IV), cel nie jest w stanie przeciwstawić się efektowi; w innym wypadku cel wykonuje Test o Trudności wynikającej z różnicy wartości Atrybutu Soul pomiędzy nim a tą Postacią (patrz tabela w Stanie Autorytet), gdzie wymagana liczba Sukcesów (S) jest równa liczbie Sukcesów wyrzuconych przez tę Postać w powyższym Teście.\n\nW wypadku niepowodzenia cel musi wykonywać rozkazy tej Postaci aż do końca jej następnej Tury - łącznie z rozkazami wymierzonymi w jego sojuszników, o ile nie są one wprost samobójcze.\n\nPonieważ jest to Akcja Natychmiastowa, nie koliduje ze zwykłą Akcją tej Postaci (Atakiem, rzuceniem zaklęcia czy ruchem) - w ramach limitu Akcji Natychmiastowych przysługującego jej w danej rundzie zgodnie ze standardowymi zasadami." "description": "Narcyzie nie trzeba niczego wymuszać siłą - wystarczy, że każe.\n\nJako Akcję Natychmiastową, kosztem 1 punktu Zapału, ta Postać wybiera jeden cel w zasięgu 15 m, który jest w stanie zrozumieć jej polecenia, i wykonuje Test Soul (Przekonywanie) 4:S. Rozstrzygnięcie następuje zgodnie ze Stanem [Autorytet](../../00.%20Podręcznik%20Gry/Aneks%20A%20-%20Stany%20Postaci/Autorytet.md): jeżeli Autorytet celu jest o dwa stopnie niższy niż Autorytet tej Postaci (zwykle IV), cel nie jest w stanie przeciwstawić się efektowi; w innym wypadku cel wykonuje Test o Trudności wynikającej z różnicy wartości Atrybutu Soul pomiędzy nim a tą Postacią (patrz tabela w Stanie Autorytet), gdzie wymagana liczba Sukcesów (S) jest równa liczbie Sukcesów wyrzuconych przez tę Postać w powyższym Teście.\n\nW wypadku niepowodzenia cel musi wykonywać rozkazy tej Postaci aż do końca jej następnej Tury - łącznie z rozkazami wymierzonymi w jego sojuszników, o ile nie są one wprost samobójcze.\n\nPonieważ jest to Akcja Natychmiastowa, nie koliduje ze zwykłą Akcją tej Postaci (Atakiem, rzuceniem zaklęcia czy ruchem). Pierwsze użycie w danej rundzie rozlicza się w ramach standardowego limitu Akcji Natychmiastowych przysługującego jej w tej rundzie. Każde kolejne użycie w tej samej rundzie to wciąż Akcja Natychmiastowa, ale nie zużywa już tego limitu - jej jedynym kosztem, tak jak za pierwszym razem, pozostaje 1 punkt Zapału."
}, },
"Skarbiec Królów": { "Skarbiec Królów": {
"name": "Skarbiec Krolow", "name": "Skarbiec Krolow",
"description": "Nikt na Ziemi nie wie, ile artefaktów naprawdę spoczywa w Bramie Babilonu - a Pelagius Caudex nie ma zamiaru nikomu tego zdradzać. Wystarczy, że uchyli jej wrota, a dziesiątki złotych kręgów rozkwitają w powietrzu wokół niego, każdy gotowy wypluć z siebie inny wytwór tysięcy lat rzemiosła.\n\nJako Akcję, Postać może otworzyć liczbę Złotych Kręgów równą wartości Umiejętności Rzemiosło - każdy Krąg wykonuje osobny Atak Dystansowy (Magic (Umiejętności Strzeleckie)) S+3 Punktów Obrażeń, zasięg 15 m, wymierzony w dowolne, niezależnie wybrane cele w zasięgu. Pula kości Ataku dzieli się między wszystkie otwarte Kręgi tak, jak przy walce oburącz z wieloma przeciwnikami.\n\nRaz na starcie Postać może poświęcić 2 punkty Zapału, aby jeden z Kręgów przywołał broń na tyle potężną, że Test Ataku wykonywany przez ten Krąg otrzymuje dodatkowe 2 kości, a trafienie zadaje dodatkowo 1k6 Obrażeń ignorujących Pancerz.\n\nPelagius niemal nigdy nie sięga po broń ze Skarbca osobiście - woli, by to jego przeciwnicy musieli zmierzyć się z całym jego arsenałem naraz, zamiast z człowiekiem, który akurat trzyma w rękach tylko jeden miecz." "description": "Nikt na Ziemi nie wie, ile artefaktów naprawdę spoczywa w Bramie Babilonu - a Pelagius Caudex nie ma zamiaru nikomu tego zdradzać. Wystarczy, że uchyli jej wrota, a dziesiątki złotych kręgów rozkwitają w powietrzu wokół niego, każdy gotowy wypluć z siebie inny wytwór tysięcy lat rzemiosła.\n\nJako Akcję, Postać może otworzyć liczbę Złotych Kręgów równą wartości Umiejętności Rzemiosło - każdy Krąg wykonuje osobny Atak Dystansowy (Magic (Umiejętności Strzeleckie)) S+3 Punktów Obrażeń, zasięg 15 m, wymierzony w dowolne, niezależnie wybrane cele w zasięgu. Pula kości Ataku dzieli się między wszystkie otwarte Kręgi tak, jak przy walce oburącz z wieloma przeciwnikami.\n\nRaz na starcie Postać może poświęcić 2 punkty Zapału, aby jeden z Kręgów przywołał broń na tyle potężną, że Test Ataku wykonywany przez ten Krąg otrzymuje dodatkowe 2 kości, a trafienie zadaje dodatkowo 1k6 Obrażeń ignorujących Pancerz.\n\n> [!info] Artefakty noszone na stałe\n> Poza wytworami, które Pelagius wyrzuca w przeciwników, Brama Babilonu zawiera przedmioty, które zakłada na siebie, zanim zacznie się jakiekolwiek starcie. Każdy z nich daje jedną, ściśle określoną przewagę:\n> - **Diadem Szamasza** - pełna odporność na efekty wpływające na umysł (Zauroczenie, rozkazy i Autorytet, Przerażenie, Złowróżbny cień). To dlatego sztuczki Narcyzy nie mają na niego wpływu.\n> - **Pieczęć Enlila** - +3 do Zdolności Magicznych.\n> - **Sandały Etany** - Szybkość 15 m i ignorowanie Terenu Trudnego.\n>\n> Arsenał jest dużo większy, niż Pelagius kiedykolwiek pokazał, więc MG może dodać Pelagiusowi kolejne przedmioty z Bramy, o ile każdy z nich daje wyłącznie jedną przewagę i da się go zdjąć lub zniszczyć.\n\nPelagius niemal nigdy nie sięga po broń ze Skarbca osobiście - woli, by to jego przeciwnicy musieli zmierzyć się z całym jego arsenałem naraz, zamiast z człowiekiem, który akurat trzyma w rękach tylko jeden miecz."
}, },
"Skaza z Xivell": { "Skaza z Xivell": {
"name": "Skaza Z Xivell", "name": "Skaza Z Xivell",
@@ -123,7 +123,7 @@
}, },
"Zapora Posłańca": { "Zapora Posłańca": {
"name": "Zapora Poslanca", "name": "Zapora Poslanca",
"description": "Coś w Dianie - to samo coś, co pozwoliło jej niegdyś przeżyć ponad pół godziny w środku rozpadającego się rytuału, który powinien był ją zabić - wciąż czuwa, gdy zbliża się cudza magia.\n\nJako Akcję Natychmiastową, gdy Postać lub sojusznik w promieniu 10 m ma zostać trafiony zaklęciem, Postać może interweniować na dwa sposoby (do wyboru):\n- **Osłona:** cel otrzymuje punkty Magicznej Osłony w liczbie równej wartości Atrybutu Magic Postaci.\n- **Rozproszenie:** Postać wykonuje Test Magic (Zdolności Magiczne) T:5, gdzie liczba wymaganych Sukcesów jest o 1 większa niż liczba Sukcesów rzucającego zaklęcie - w przypadku powodzenia zaklęcie zostaje rozplecione, tak jak przy Talencie Rozplatanie Zaklęć.\n\nTen Talent nie ma ograniczenia użyć na rundę ani na starcie - jego jedynym kosztem jest Akcja Natychmiastowa, których Postać wciąż może wykonać tylko ograniczoną liczbę na rundę zgodnie z normalnymi zasadami." "description": "Coś w Dianie - to samo coś, co pozwoliło jej niegdyś przeżyć ponad pół godziny w środku rozpadającego się rytuału, który powinien był ją zabić - wciąż czuwa, gdy zbliża się zagrożenie.\n\nJako Akcję Natychmiastową, gdy Postać lub sojusznik w promieniu 10 m ma zostać trafiony zaklęciem, Postać może interweniować na dwa sposoby (do wyboru):\n- **Osłona:** cel otrzymuje punkty Magicznej Osłony w liczbie równej wartości Atrybutu Magic Postaci.\n- **Rozproszenie:** Postać wykonuje Test Magic (Zdolności Magiczne) T:5, gdzie liczba wymaganych Sukcesów jest ustalana jak przy Talencie Rozplatanie Zaklęć (Zasada Potężniejszych Zaklęć) - w przypadku powodzenia zaklęcie zostaje rozplecione, tak jak przy Talencie Rozplatanie Zaklęć.\n\nTen Talent nie ma ograniczenia użyć na starcie. Raz na rundę Postać może użyć go za darmo, jako Akcję Natychmiastową w ramach standardowego limitu Akcji Natychmiastowych. Każde kolejne użycie w tej samej rundzie to wciąż Akcja Natychmiastowa, ale nie zużywa już tego limitu - kosztuje wtedy dodatkowo 1 punkt Zapału, tak jak kolejne próby rozplatania w Talencie [Rozplatanie Zaklęć](../../00.%20Podręcznik%20Gry/Rozdział%20IV%20-%20Talenty/Rozplatanie%20Zaklęć.md)."
} }
} }
} }
+11 -7
View File
@@ -23,7 +23,7 @@
}, },
"Arytmetyczna Koncentracja": { "Arytmetyczna Koncentracja": {
"name": "Arytmetyczna Koncentracja", "name": "Arytmetyczna Koncentracja",
"description": "Ilość kości w Testach na utrzymanie Koncentracji jest zwiększona o połowę wartości Atrybutu Mind." "description": "Ilość kości w Testach na utrzymanie Koncentracji jest zwiększona o zaokrągloną w górę połowę wartości Atrybutu Mind."
}, },
"Gadanina": { "Gadanina": {
"name": "Babble", "name": "Babble",
@@ -63,7 +63,7 @@
}, },
"Medyk Polowy": { "Medyk Polowy": {
"name": "Field Medic", "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." "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 Żywotności."
}, },
"Zakazana Wiedza (Dziedzina)": { "Zakazana Wiedza (Dziedzina)": {
"name": "Forbidden Knowledge", "name": "Forbidden Knowledge",
@@ -115,7 +115,7 @@
}, },
"Miłosierdzie": { "Miłosierdzie": {
"name": "Milosierdzie", "name": "Milosierdzie",
"description": "Efekty leczące i chroniące zaklęć Magii Sakralnej są o połowę mocniejsze." "description": "Efekty leczące i chroniące zaklęć Magii Sakralnej są o połowę mocniejsze (wartość po zwiększeniu zaokrąglamy w górę)."
}, },
"Nadzwyczajna Odporność": { "Nadzwyczajna Odporność": {
"name": "Nadzwyczajna Odpornosc", "name": "Nadzwyczajna Odpornosc",
@@ -135,11 +135,11 @@
}, },
"Oportunista": { "Oportunista": {
"name": "Opportunist", "name": "Opportunist",
"description": "Twoja zdolność do reagowania na wydarzenia w trakcie walki jest zadziwiająca.\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\nGdy przeciwnik odchodzi od Ciebie i masz możliwość zaatakowania go, możesz podjąć próbę ataku jako Akcja Natychmiastowa. Każde użycie zużywa Akcję Natychmiastową (patrz: [Tura](../Rozdział%20V%20-%20Walka%20i%20Magic/Tura.md)), więc liczba ataków w rundzie jest ograniczona wyłącznie liczbą Akcji Natychmiastowych, jakie możesz wykonać."
}, },
"Parowanie": { "Parowanie": {
"name": "Parry", "name": "Parry",
"description": "Zamiast unikać bądź kontrować ataków, raz na rundę Postać może sparować jeden atak wręcz. \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).\n\nPostać wykonuje Test Przeciwstawny (jak w przypadku Odpierania Ataku), jednak Test jest prostszy o jeden stopień, a ponadto ilość wymaganych Sukcesów należy zmniejszyć o zaokrągloną w górę połowę wartości Umiejętności Walka Wręcz atakowanego." "description": "Zamiast unikać bądź kontrować ataków, raz na rundę (jako Akcja Natychmiastowa) Postać może sparować jeden atak wręcz. \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).\n\nPostać wykonuje Test Przeciwstawny (jak w przypadku Odpierania Ataku), jednak Test jest prostszy o jeden Stopień. Parowanie się udaje, jeżeli Postać uzyska co najmniej tyle Sukcesów, ile atakujący, pomniejszone o zaokrągloną w górę połowę wartości swojej Umiejętności Walka Wręcz (czyli remis i niewielka przegrana również są sukcesem) - atak zostaje wtedy Sparowany i nie zadaje obrażeń. W przeciwnym razie atak trafia i zadaje obrażenia na zwykłych zasadach."
}, },
"Urok Osobisty": { "Urok Osobisty": {
"name": "Personal Charm", "name": "Personal Charm",
@@ -147,7 +147,7 @@
}, },
"Pierwotna Energia": { "Pierwotna Energia": {
"name": "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\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\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." "description": "Nie możesz rzucać ani rozplatać zaklęć. Twój Maksymalny Koszt Zaklęcia oraz Ilość Many zostają dwukrotnie zwiększone.\n\nW ramach Akcji [Pomoc](../Rozdział%20V%20-%20Walka%20i%20Magic/Pomoc.md) możesz pomóc wybranemu magowi w zasięgu 3 m (zamiast zwykłego 1 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\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": { "Podejrzliwość Wobec Zmian": {
"name": "Podejrzliwosc Wobec Zmian", "name": "Podejrzliwosc Wobec Zmian",
@@ -163,7 +163,11 @@
}, },
"Rozplatanie Zaklęć": { "Rozplatanie Zaklęć": {
"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\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\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ę dokonać próby rozplatania w ramach darmowej Akcji Natychmiastowej (o ile nie wykorzystał jej już w tej rundzie), jednak dane zaklęcie może być rozplatane wyłącznie raz.\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\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) wynika z [Zasady Potężniejszych Zaklęć](../../01.%20Księga%20Magii/Rozdział%20II%20-%20Teoria%20Rzucania%20Zaklęć/Zasada%20Potężniejszych%20Zaklęć.md) (2 + nadwyżka Sukcesów rzucającego ponad wymagane przez zaklęcie, plus bonusy z zaklęć Niezniszczalnych). Jeżeli Postać uzyska wymaganą liczbę Sukcesów, zaklęcie zostaje rozplecione."
},
"Sztuki Walki": {
"name": "Sztuki Walki",
"description": "Wieloletni trening sprawił, że Twoje ciało samo w sobie jest bronią.\n\nNie stosujesz kar do Ataków bez Broni (patrz: [Atak bez Broni](../Rozdział%20V%20-%20Walka%20i%20Magic/Atak%20bronią.md)). Twoje pięści, kopnięcia, uderzenia głowicą i ogonem liczą się jako broń biała o Obrażeniach równych 1: możesz nimi Odpierać ataki wręcz, a także korzystać z Talentów wymagających broni białej (np. Parowanie, Finta, Riposta).\n\nTen Talent obejmuje również walkę przedmiotami, które nie są bronią (taborety, narzędzia itp.; tarcza nadal nie jest bronią) - nie stosujesz wtedy kar do Ataków Improwizowanych.\n\nWykonując Powalenie lub Rozbrojenie otrzymujesz dwie dodatkowe kości do Testu Przeciwstawnego."
}, },
"Tajemna Technologia": { "Tajemna Technologia": {
"name": "Tajemna Technologia", "name": "Tajemna Technologia",
+25 -13
View File
@@ -105,7 +105,6 @@
"spell": { "spell": {
"label": "Spell", "label": "Spell",
"cast": "Cast spell", "cast": "Cast spell",
"circle": "Circle",
"school": "School", "school": "School",
"discipline": "Discipline", "discipline": "Discipline",
"difficulty": "Spell Threshold", "difficulty": "Spell Threshold",
@@ -187,7 +186,8 @@
"abyssAspects": "Abyss - Aspects", "abyssAspects": "Abyss - Aspects",
"abyssPrimal": "Abyss - Primal", "abyssPrimal": "Abyss - Primal",
"wildWitch": "Wild Witch", "wildWitch": "Wild Witch",
"eldritch": "Eldritch" "eldritch": "Eldritch",
"thaumaturgy": "Thaumaturgy"
}, },
"deity": { "deity": {
"common": "Common Prayers", "common": "Common Prayers",
@@ -200,14 +200,14 @@
"eros": "Eros" "eros": "Eros"
}, },
"sourceBook": { "sourceBook": {
"core-rules": "PG", "podrecznik-gry": "Podręcznik Gry",
"magic-book": "KM", "ksiega-magii": "Księga Magii",
"arcanum-sanguinis": "AS", "arcanum-sanguinis": "Arcanum Sanguinis",
"crimson-cult": "CS", "crimson-cult": "Szkarłatny Kult",
"abyss-curse": "KO", "klatwa-otchlani": "Klątwa Otchłani",
"humanity-guide": "PLM", "przewodnik": "Przewodnik",
"bestiary": "B", "bestiariusz": "Bestiariusz",
"gold-steel-magic": "ZSM" "zlote-stal-magia": "Złoto i Stal: Magia"
}, },
"aoeShape": { "aoeShape": {
"point": "Point", "point": "Point",
@@ -239,6 +239,13 @@
"physical": "Physical", "physical": "Physical",
"magical": "Magical", "magical": "Magical",
"environmental": "Environmental", "environmental": "Environmental",
"woundKinds": "Wound kind",
"woundKindsHint": "Several allowed. Only slashing and piercing wounds grant talent Krew (Respect for Life, Crimson Duelist). Empty = unspecified.",
"wound": {
"slashing": "Slashing",
"piercing": "Piercing",
"blunt": "Blunt"
},
"properties": "Properties", "properties": "Properties",
"damageReduction": "Damage Reduction", "damageReduction": "Damage Reduction",
"damageReductionBonus": "DR Bonus", "damageReductionBonus": "DR Bonus",
@@ -264,7 +271,7 @@
"type": "Type", "type": "Type",
"passive": "Passive", "passive": "Passive",
"active": "Active", "active": "Active",
"reaction": "Reaction", "reaction": "Immediate Action",
"freeAction": "Free Action", "freeAction": "Free Action",
"prerequisite": "Prerequisite", "prerequisite": "Prerequisite",
"description": "Description", "description": "Description",
@@ -324,7 +331,7 @@
"languages": "Languages", "languages": "Languages",
"attacks": "Attacks", "attacks": "Attacks",
"specialAbilities": "Special Abilities", "specialAbilities": "Special Abilities",
"reactions": "Reactions", "reactions": "Immediate Actions",
"legendaryActions": "Legendary Actions", "legendaryActions": "Legendary Actions",
"savingThrows": "Saving Throws", "savingThrows": "Saving Throws",
"damageResistances": "Damage Resistances", "damageResistances": "Damage Resistances",
@@ -356,6 +363,8 @@
}, },
"spellCast": { "spellCast": {
"title": "Casting spell", "title": "Casting spell",
"diceModifier": "Dice modifier",
"diceModifierHint": "+/- dice in the casting test (the pool never drops below 0)",
"manaSpent": "Mana spent", "manaSpent": "Mana spent",
"cannotBeUnraveled": "Cannot be unraveled", "cannotBeUnraveled": "Cannot be unraveled",
"zealSpent": "Zeal spent", "zealSpent": "Zeal spent",
@@ -366,6 +375,9 @@
"bypassNonCombatBlock": "Bypass non-combat block", "bypassNonCombatBlock": "Bypass non-combat block",
"cast": "Cast", "cast": "Cast",
"notEnoughMana": "Not enough mana.", "notEnoughMana": "Not enough mana.",
"borrowMana": "Not enough mana - the shortfall is borrowed (Mana Exhaustion).",
"manaExhaustion": "Mana Exhaustion",
"incompetencePenalty": "Penalty for Incompetence",
"notEnoughZeal": "Not enough zeal.", "notEnoughZeal": "Not enough zeal.",
"notEnoughBlood": "Not enough blood.", "notEnoughBlood": "Not enough blood.",
"raceLocked": "Spell requires race: {races}.", "raceLocked": "Spell requires race: {races}.",
@@ -451,7 +463,7 @@
"spend": "Spend", "spend": "Spend",
"attacks": "Attacks", "attacks": "Attacks",
"specialAbilities": "Special Abilities", "specialAbilities": "Special Abilities",
"reactions": "Reactions", "reactions": "Immediate Actions",
"legendaryActions": "Legendary Actions", "legendaryActions": "Legendary Actions",
"actionsTitle": "Quick Actions", "actionsTitle": "Quick Actions",
"customEquipment": "Custom Equipment", "customEquipment": "Custom Equipment",
+25 -13
View File
@@ -105,7 +105,6 @@
"spell": { "spell": {
"label": "Zaklęcie", "label": "Zaklęcie",
"cast": "Rzuć zaklęcie", "cast": "Rzuć zaklęcie",
"circle": "Krąg",
"school": "Szkoła", "school": "Szkoła",
"discipline": "Dziedzina", "discipline": "Dziedzina",
"difficulty": "Próg zaklęcia", "difficulty": "Próg zaklęcia",
@@ -187,7 +186,8 @@
"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" "eldritch": "Magia Otchłani",
"thaumaturgy": "Taumaturgia"
}, },
"deity": { "deity": {
"common": "Modlitwy Powszechne", "common": "Modlitwy Powszechne",
@@ -200,14 +200,14 @@
"eros": "Eros" "eros": "Eros"
}, },
"sourceBook": { "sourceBook": {
"core-rules": "PG", "podrecznik-gry": "Podręcznik Gry",
"magic-book": "KM", "ksiega-magii": "Księga Magii",
"arcanum-sanguinis": "AS", "arcanum-sanguinis": "Arcanum Sanguinis",
"crimson-cult": "CS", "crimson-cult": "Szkarłatny Kult",
"abyss-curse": "KO", "klatwa-otchlani": "Klątwa Otchłani",
"humanity-guide": "PLM", "przewodnik": "Przewodnik",
"bestiary": "B", "bestiariusz": "Bestiariusz",
"gold-steel-magic": "ZSM" "zlote-stal-magia": "Złoto i Stal: Magia"
}, },
"aoeShape": { "aoeShape": {
"point": "Punkt", "point": "Punkt",
@@ -259,6 +259,13 @@
"physical": "Fizyczne", "physical": "Fizyczne",
"magical": "Magiczne", "magical": "Magiczne",
"environmental": "Środowiskowe", "environmental": "Środowiskowe",
"woundKinds": "Rodzaj rany",
"woundKindsHint": "Można wybrać kilka. Tylko rany sieczne i kłute dają Krew z talentów (Szacunek do Życia, Krwisty Pojedynkowicz). Puste = nieokreślony.",
"wound": {
"slashing": "Sieczne",
"piercing": "Kłute",
"blunt": "Obuchowe"
},
"properties": "Właściwości", "properties": "Właściwości",
"damageReduction": "Redukcja Obrażeń", "damageReduction": "Redukcja Obrażeń",
"damageReductionBonus": "Premia do Redukcji", "damageReductionBonus": "Premia do Redukcji",
@@ -284,7 +291,7 @@
"type": "Typ", "type": "Typ",
"passive": "Pasywna", "passive": "Pasywna",
"active": "Aktywna", "active": "Aktywna",
"reaction": "Reakcja", "reaction": "Akcja Natychmiastowa",
"freeAction": "Akcja Darmowa", "freeAction": "Akcja Darmowa",
"prerequisite": "Wymóg", "prerequisite": "Wymóg",
"description": "Opis", "description": "Opis",
@@ -344,7 +351,7 @@
"languages": "Języki", "languages": "Języki",
"attacks": "Ataki", "attacks": "Ataki",
"specialAbilities": "Zdolności Specjalne", "specialAbilities": "Zdolności Specjalne",
"reactions": "Reakcje", "reactions": "Akcje Natychmiastowe",
"legendaryActions": "Akcje Legendarne", "legendaryActions": "Akcje Legendarne",
"savingThrows": "Rzuty Obronne", "savingThrows": "Rzuty Obronne",
"damageResistances": "Odporności na Obrażenia", "damageResistances": "Odporności na Obrażenia",
@@ -376,10 +383,15 @@
}, },
"spellCast": { "spellCast": {
"title": "Rzucanie zaklęcia", "title": "Rzucanie zaklęcia",
"diceModifier": "Modyfikator liczby kości",
"diceModifierHint": "+/- kości w Teście rzucania (liczba kości nie spadnie poniżej 0)",
"manaSpent": "Wydana mana", "manaSpent": "Wydana mana",
"cannotBeUnraveled": "Nie da się rozpleść", "cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć", "cast": "Rzuć",
"notEnoughMana": "Niewystarczająca mana.", "notEnoughMana": "Niewystarczająca mana.",
"borrowMana": "Brakuje many - różnica zostanie pożyczona (Wyczerpanie Many).",
"manaExhaustion": "Wyczerpanie Many",
"incompetencePenalty": "Kara za Nieudolność",
"notEnoughZeal": "Niewystarczający Zapał.", "notEnoughZeal": "Niewystarczający Zapał.",
"notEnoughBlood": "Niewystarczająca Krew.", "notEnoughBlood": "Niewystarczająca Krew.",
"raceLocked": "Zaklęcie wymaga rasy: {races}.", "raceLocked": "Zaklęcie wymaga rasy: {races}.",
@@ -454,7 +466,7 @@
"spend": "Wydaj", "spend": "Wydaj",
"attacks": "Ataki", "attacks": "Ataki",
"specialAbilities": "Zdolności Specjalne", "specialAbilities": "Zdolności Specjalne",
"reactions": "Reakcje", "reactions": "Akcje Natychmiastowe",
"legendaryActions": "Akcje Legendarne", "legendaryActions": "Akcje Legendarne",
"actionsTitle": "Szybkie Akcje", "actionsTitle": "Szybkie Akcje",
"customEquipment": "Własny ekwipunek", "customEquipment": "Własny ekwipunek",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hbm-rpg-v3-foundry", "name": "hbm-rpg-v3-foundry",
"version": "1.3.37", "version": "1.5.1",
"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",
+18 -2
View File
@@ -54,6 +54,22 @@ allDocs.push(...(await parseGear(ctx)));
console.log(`[build-packs] parsed ${allDocs.length} documents total`); console.log(`[build-packs] parsed ${allDocs.length} documents total`);
// 2b. signatureSpells = every spell belonging to the discipline (own school or
// listed in the spell's requirements.discipline).
// Spells with school `eldritch` belong to Abyss - Primal Magic.
const SCHOOL_TO_DISCIPLINE: Record<string, string> = { eldritch: 'abyssPrimal' };
for (const disc of allDocs) {
if (disc.subType !== 'discipline') continue;
const sys = disc.system as Record<string, any>;
sys.signatureSpells = allDocs
.filter((d) => {
if (d.subType !== 'spell') return false;
const sp = d.system as Record<string, any>;
return (SCHOOL_TO_DISCIPLINE[sp.school] ?? sp.school) === disc.id || (sp.requirements?.discipline ?? []).includes(disc.id);
})
.map((d) => d.name);
}
// 3. Group by pack and emit one JSON per doc into packs-src/<pack>/. // 3. Group by pack and emit one JSON per doc into packs-src/<pack>/.
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true }); if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
const byPack = new Map<string, ParsedDoc[]>(); const byPack = new Map<string, ParsedDoc[]>();
@@ -87,10 +103,10 @@ function getFolderHierarchy(doc: ParsedDoc): { name: string; parentName?: string
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ziemi', parentName: 'Magia Żywiołów' }]; return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ziemi', parentName: 'Magia Żywiołów' }];
} }
if (school === 'alchemyTransmutation') { if (school === 'alchemyTransmutation') {
return [{ name: 'Alchemia' }, { name: 'Alchemia - Transmutacja', parentName: 'Alchemia' }]; return [{ name: 'Alchemia' }, { name: 'Transmutacja', parentName: 'Alchemia' }];
} }
if (school === 'alchemyBrewing') { if (school === 'alchemyBrewing') {
return [{ name: 'Alchemia' }, { name: 'Alchemia - Warzenie Eliksirów', parentName: 'Alchemia' }]; return [{ name: 'Alchemia' }, { name: 'Warzenie Eliksirów', parentName: 'Alchemia' }];
} }
if (school === 'artifice') { if (school === 'artifice') {
return [{ name: 'Rzemiosło Artefaktów' }]; return [{ name: 'Rzemiosło Artefaktów' }];
+1
View File
@@ -1,3 +1,4 @@
// DEPRECATED: one-off extractor that writes the old nested spell frontmatter. The vault now uses the flat format read by scripts/parsers/spell-parser.ts - do not run this over ObsidianNotes/spells.
import { resolve, dirname } from 'node:path'; import { resolve, dirname } from 'node:path';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
+2
View File
@@ -146,6 +146,8 @@ const folderNameTranslations: Record<string, string> = {
"Magia Ziemi": "Earth Magic", "Magia Ziemi": "Earth Magic",
"Alchemia": "Alchemy", "Alchemia": "Alchemy",
"Alchemia - Transmutacja": "Alchemy - Transmutation", "Alchemia - Transmutacja": "Alchemy - Transmutation",
"Transmutacja": "Transmutation",
"Warzenie Eliksirów": "Brewing of Elixirs",
"Alchemia - Warzenie Eliksirów": "Alchemy - Brewing of Elixirs", "Alchemia - Warzenie Eliksirów": "Alchemy - Brewing of Elixirs",
"Rzemiosło Artefaktów": "Crafting of Artifacts", "Rzemiosło Artefaktów": "Crafting of Artifacts",
"Golemancja": "Golemancy", "Golemancja": "Golemancy",
+1 -1
View File
@@ -3,4 +3,4 @@ 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 (liczniki Odetchnięć i pożyczonej Many wyzerowane).`);
+1
View File
@@ -3,4 +3,5 @@ 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, 'breather'); const result = await game.hbm.rest(actor, 'breather');
if (result?.blocked) return ui.notifications.warn(`${actor.name}: limit Odetchnięć (2 między Długimi Odpoczynkami) wyczerpany.`);
ui.notifications.info(`${actor.name}: chwila wytchnienia - przywrócono ${result?.hpRestored ?? 0} PW i ${result?.manaRestored ?? 0} Many.`); ui.notifications.info(`${actor.name}: chwila wytchnienia - przywrócono ${result?.hpRestored ?? 0} PW i ${result?.manaRestored ?? 0} Many.`);
+1
View File
@@ -148,6 +148,7 @@ export const parseDisciplines: ParserFn = async (ctx: ParserContext): Promise<Pa
suggestedSkills: SUGGESTED_SKILLS[seed.id] ?? ['magicalAbilities'], suggestedSkills: SUGGESTED_SKILLS[seed.id] ?? ['magicalAbilities'],
passiveAbility: '', passiveAbility: '',
availableSpells: [], availableSpells: [],
signatureSpells: [],
description, description,
}, },
description, description,
+32 -1
View File
@@ -38,6 +38,27 @@ export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc
return docs; return docs;
}; };
type WoundKind = 'slashing' | 'piercing' | 'blunt';
/**
* Vault `wound` frontmatter → woundKinds. Accepts one value, a comma list ("piercing, slashing"),
* or a YAML list (flattened to a comma list by extractMarkdown). Unknown values are dropped,
* absent stays empty – never guessed.
*/
export function parseWoundKinds(raw: string | undefined): WoundKind[] {
const map: Record<string, WoundKind> = {
slashing: 'slashing', sieczne: 'slashing', sieczna: 'slashing',
piercing: 'piercing', kłute: 'piercing', kłuta: 'piercing', kluta: 'piercing', kłujące: 'piercing',
blunt: 'blunt', obuchowe: 'blunt', obuchowa: 'blunt',
};
const out: WoundKind[] = [];
for (const part of (raw ?? '').split(',')) {
const kind = map[part.trim().toLowerCase()];
if (kind && !out.includes(kind)) out.push(kind);
}
return out;
}
function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } { function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
const fm: Record<string, string> = {}; const fm: Record<string, string> = {};
let content = raw; let content = raw;
@@ -48,9 +69,17 @@ function extractMarkdown(raw: string): { frontmatter: Record<string, string>; co
const fmText = raw.slice(3, endIdx).trim(); const fmText = raw.slice(3, endIdx).trim();
content = raw.slice(endIdx + 3).trim(); content = raw.slice(endIdx + 3).trim();
const lines = fmText.split('\n'); const lines = fmText.split(/\r?\n/);
let listKey = '';
for (const line of lines) { for (const line of lines) {
// "key:" followed by " - item" lines → comma-joined value.
const item = line.match(/^\s+-\s+(.*)$/);
if (item && listKey) {
fm[listKey] = fm[listKey] ? `${fm[listKey]}, ${item[1].trim()}` : item[1].trim();
continue;
}
const colonIdx = line.indexOf(':'); const colonIdx = line.indexOf(':');
listKey = colonIdx > -1 && !line.slice(colonIdx + 1).trim() ? line.slice(0, colonIdx).trim() : '';
if (colonIdx > -1) { if (colonIdx > -1) {
const key = line.slice(0, colonIdx).trim(); const key = line.slice(0, colonIdx).trim();
let val = line.slice(colonIdx + 1).trim(); let val = line.slice(colonIdx + 1).trim();
@@ -91,6 +120,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
weapon: { weapon: {
damage: '', damage: '',
damageType: 'physical', damageType: 'physical',
woundKinds: [],
properties: [], properties: [],
}, },
armor: { armor: {
@@ -105,6 +135,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
if (category === 'weapon') { if (category === 'weapon') {
system.weapon.damage = fm.damage || fm.obrazenia || ''; system.weapon.damage = fm.damage || fm.obrazenia || '';
system.weapon.woundKinds = parseWoundKinds(fm.wound || fm.rana);
if (fm.traits || fm.cechy) { if (fm.traits || fm.cechy) {
system.weapon.properties = parseList(fm.traits || fm.cechy); system.weapon.properties = parseList(fm.traits || fm.cechy);
} }
+92 -43
View File
@@ -5,59 +5,109 @@ 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. */ /**
const SpellSystemSchema = z.object({ * Flat, human-editable vault frontmatter for a spell. Nothing is nested;
* optional keys can simply be left out. `toSystem()` expands it into the
* (partly nested) Foundry `SpellData` shape.
*/
const SpellNoteSchema = z.object({
castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']), castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']),
school: z.string(), school: z.string(),
deity: z.string().default(''), deity: z.string().default(''),
sourceBook: z.string(), sourceBook: z.string(),
manaCost: z.number().int().min(0), manaCost: z.number().int().min(0),
bloodCost: z.number().int().min(0).default(0), bloodCost: z.number().int().min(0).default(0),
threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1),
complexityLevel: z.number().int().min(0).default(0), complexityLevel: z.number().int().min(0).default(0),
isSuperspell: z.boolean().default(false), isSuperspell: z.boolean().default(false),
requiresGroupCast: z.boolean().default(false), requiresGroupCast: z.boolean().default(false),
minCasters: z.number().int().min(1).default(1), minCasters: z.number().int().min(1).default(1),
nonCombatOnly: z.boolean().default(false), nonCombatOnly: z.boolean().default(false),
damageBase: z.string().default(''), verbal: z.boolean().default(false),
damageType: z.string().default(''), somatic: z.boolean().default(false),
material: z.coerce.string().default(''),
symbols: z.array(z.string()).default([]),
requiresRace: z.array(z.string()).default([]),
requiresTalent: z.array(z.string()).default([]),
requiresDiscipline: z.array(z.string()).default([]),
castingTime: z.coerce.string().default('Instant'),
range: z.coerce.string().default(''),
targets: z.coerce.string().default(''),
duration: z.coerce.string().default(''),
damageBase: z.coerce.string().default(''),
damageType: z.string().default('magical'),
ignoresArmor: z.boolean().default(false), ignoresArmor: z.boolean().default(false),
statusEffects: z.array(z.string()).default([]), statusEffects: z.array(z.string()).default([]),
saveAttribute: z.string().default(''), saveAttribute: z.string().default(''),
saveSkill: z.string().default(''), saveSkill: z.string().default(''),
triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]), triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]),
components: z.object({ overcast: z.array(z.string()).default([]),
verbal: z.boolean().default(false),
somatic: z.boolean().default(false),
material: z.string().default(''),
symbols: z.array(z.string()).default([]),
}),
requirements: z.object({
race: z.array(z.string()).default([]),
talent: z.array(z.string()).default([]),
discipline: z.array(z.string()).default([]),
}),
difficulty: z.object({
threshold: z.number().int().min(1).max(6),
successes: z.number().int().min(1),
}),
range: z.object({
kind: z.string(),
value: z.number().optional(),
unit: z.string().optional(),
text: z.string().optional(),
}),
duration: z.string().default(''),
castingTime: z.string().default(''),
target: z.string().default(''),
areaOfEffect: z
.object({ shape: z.string(), x: z.number().optional(), y: z.number().optional(), unit: z.string().optional() })
.nullable()
.default(null),
overcastOptions: z
.array(z.object({ text: z.string(), repeatable: z.boolean().default(true), cost: z.number().int().default(0) }))
.default([]),
description: z.string().default(''),
}); });
type SpellNote = z.infer<typeof SpellNoteSchema>;
type Aoe = { shape: string; x: number; y: number; unit: string };
/** Derive the structured area of effect from the free-text `targets` line. */
export function deriveAreaOfEffect(targets: string): Aoe {
const t = targets.replace(/\s+/g, ' ');
let m = t.match(/kwadrat o boku (\d+)\s*m/i);
if (m) return { shape: 'square', x: Number(m[1]), y: Number(m[1]), unit: 'm' };
m = t.match(/prostokąt (\d+)\s*m\s*[x×]\s*(\d+)\s*m/i);
if (m) return { shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: 'm' };
m = t.match(/(?:sfera|kula) o średnicy (\d+)\s*m/i);
if (m) return { shape: 'sphere', x: Number(m[1]) / 2, y: 0, unit: 'm' };
m = t.match(/(?:sfera|kula|kopuła) o promieniu (\d+)\s*m/i);
if (m) return { shape: 'sphere', x: Number(m[1]), y: 0, unit: 'm' };
if (/^linia/i.test(t)) return { shape: 'line', x: 0, y: 0, unit: 'm' };
return { shape: 'point', x: 0, y: 0, unit: 'm' };
}
/** Casting time in whole rounds / minutes, for the sheet's numeric fields. */
export function deriveCastingTime(text: string): { rounds: number; minutes: number } {
let m = text.match(/(\d+)\s*godzin/i);
if (m) return { rounds: 0, minutes: Number(m[1]) * 60 };
m = text.match(/(\d+)\s*minut/i);
if (m) return { rounds: 0, minutes: Number(m[1]) };
m = text.match(/(\d+)\s*rund/i);
if (m) return { rounds: Number(m[1]), minutes: 0 };
return { rounds: 0, minutes: 0 };
}
function toSystem(n: SpellNote): Record<string, unknown> {
const time = deriveCastingTime(n.castingTime);
return {
school: n.school,
deity: n.deity,
sourceBook: n.sourceBook,
castingMode: n.castingMode,
manaCost: n.manaCost,
bloodCost: n.bloodCost,
difficulty: { threshold: n.threshold, successes: n.successes },
components: { verbal: n.verbal, somatic: n.somatic, material: n.material, symbols: n.symbols },
castingTime: n.castingTime,
castingTimeRounds: time.rounds,
castingTimeMinutes: time.minutes,
range: n.range,
targets: n.targets,
duration: n.duration,
areaOfEffect: deriveAreaOfEffect(n.targets),
damageBase: n.damageBase,
damageType: n.damageType || 'magical',
ignoresArmor: n.ignoresArmor,
statusEffects: n.statusEffects,
saveAttribute: n.saveAttribute,
saveSkill: n.saveSkill,
requirements: { race: n.requiresRace, talent: n.requiresTalent, discipline: n.requiresDiscipline },
isSuperspell: n.isSuperspell,
requiresGroupCast: n.requiresGroupCast,
minCasters: n.minCasters,
nonCombatOnly: n.nonCombatOnly,
overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })),
triggers: n.triggers,
complexityLevel: n.complexityLevel,
};
}
function getAllFiles(dirPath: string, arrayOfFiles: string[] = []) { function getAllFiles(dirPath: string, arrayOfFiles: string[] = []) {
const files = readdirSync(dirPath); const files = readdirSync(dirPath);
@@ -100,13 +150,12 @@ export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedD
continue; continue;
} }
const payload = { ...frontmatter, description: bodyRaw.trim() }; const body = bodyRaw.trim();
const nameMatch = body.match(/^#\s+(.+)$/m);
const nameMatch = payload.description.match(/^#\s+(.+)$/m);
const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md'); const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
payload.description = payload.description.replace(/^#\s+.+$/m, '').trim(); const description = body.replace(/^#\s+.+$/m, '').trim();
const parsed = SpellSystemSchema.safeParse(payload); const parsed = SpellNoteSchema.safeParse(frontmatter);
if (!parsed.success) { if (!parsed.success) {
if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`); if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`);
continue; continue;
@@ -129,8 +178,8 @@ export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedD
subType: 'spell', subType: 'spell',
pack, pack,
source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 }, source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 },
system: parsed.data, system: { ...toSystem(parsed.data), description },
description: payload.description, description,
}); });
} }
+16 -5
View File
@@ -91,12 +91,16 @@ export interface MagicPowerEntry {
manaPerDay: number; // daily mana pool ("Ilość Many") manaPerDay: number; // daily mana pool ("Ilość Many")
} }
declare const __SYSTEM_ID__: string;
/** System id; lives here (not in hbm.ts) so logic modules and tests don't pull in the sheets. */
export const SYSTEM_ID: string = typeof __SYSTEM_ID__ !== 'undefined' ? __SYSTEM_ID__ : 'hbm-rpg-v3';
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, manaPerDay: 60 }, { level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerDay: 60 },
{ level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerDay: 36 }, { level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerDay: 36 },
{ level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerDay: 30 }, { level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerDay: 30 },
{ level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerDay: 21 }, { level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerDay: 24 },
{ level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerDay: 24 }, { level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerDay: 21 },
{ level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerDay: 18 }, { level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerDay: 18 },
{ level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerDay: 18 }, { level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerDay: 18 },
{ level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerDay: 12 }, { level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerDay: 12 },
@@ -136,6 +140,8 @@ export const SPELL_SCHOOLS = [
// Forbidden / extra-academic // Forbidden / extra-academic
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch', 'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
'eldritch', 'eldritch',
// Malferian thaumaturgy (house-shield, retinue-guard, shield-bubble)
'thaumaturgy',
] as const; ] as const;
export type SpellSchool = (typeof SPELL_SCHOOLS)[number]; export type SpellSchool = (typeof SPELL_SCHOOLS)[number];
@@ -160,9 +166,9 @@ export const WITCH_SYMBOLS: readonly string[] = Object.freeze([
/** Source-book provenance for compendium content. */ /** Source-book provenance for compendium content. */
export const SOURCE_BOOKS = [ export const SOURCE_BOOKS = [
'core-rules', 'magic-book', 'arcanum-sanguinis', 'podrecznik-gry', 'ksiega-magii', 'arcanum-sanguinis',
'crimson-cult', 'abyss-curse', 'humanity-guide', 'crimson-cult', 'klatwa-otchlani', 'przewodnik',
'bestiary', 'gold-steel-magic', 'bestiariusz', 'zlote-stal-magia',
] as const; ] as const;
export type SourceBook = (typeof SOURCE_BOOKS)[number]; export type SourceBook = (typeof SOURCE_BOOKS)[number];
@@ -186,6 +192,11 @@ export type GearCategory = (typeof GEAR_CATEGORIES)[number];
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const; export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
export type DamageType = (typeof DAMAGE_TYPES)[number]; export type DamageType = (typeof DAMAGE_TYPES)[number];
/** Weapon wound kinds (sieczne / kłute / obuchowe). A weapon may have several; none = unspecified.
* Only slashing and piercing wounds feed the passive Krew talents. */
export const WOUND_KINDS = ['slashing', 'piercing', 'blunt'] as const;
export type WoundKind = (typeof WOUND_KINDS)[number];
/** Armor types */ /** Armor types */
export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const; export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const;
export type ArmorType = (typeof ARMOR_TYPES)[number]; export type ArmorType = (typeof ARMOR_TYPES)[number];
+21 -1
View File
@@ -72,6 +72,21 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
// 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 }),
// Mana borrowed beyond the daily pool since the last Długi Odpoczynek (Wyczerpanie Many).
manaBorrowed: makeIntField(0, { min: 0 }),
// Odetchnięcia taken since the last Długi Odpoczynek (max 2).
breathersTaken: makeIntField(0, { min: 0 }),
// Akcje Natychmiastowe used in the current round (1 free, Zapał buys more).
immediateActionsUsed: makeIntField(0, { min: 0 }),
// Blood-magic actions used this round (Samookaleczenie, Kradzież Życia, Złożenie w Ofierze, Wchłanianie Krwi).
bloodUsed: new f.SchemaField({
selfHarm: makeIntField(0, { min: 0 }),
lifeSteal: makeIntField(0, { min: 0 }),
sacrifice: makeIntField(0, { min: 0 }),
absorb: makeIntField(0, { min: 0 }),
}),
// Wielka Ofiara used today (once per day; reset by Długi Odpoczynek).
greatSacrificeUsed: makeIntField(0, { min: 0, max: 1 }),
// 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
// mental-condition table when reaching thresholds. // mental-condition table when reaching thresholds.
insanity: makeIntField(0, { min: 0 }), insanity: makeIntField(0, { min: 0 }),
@@ -143,6 +158,11 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
zeal: { value: number; max: number }; zeal: { value: number; max: number };
blood: { value: number; max: number }; blood: { value: number; max: number };
elixirTolerance: number; elixirTolerance: number;
manaBorrowed: number;
breathersTaken: number;
immediateActionsUsed: number;
bloodUsed: { selfHarm: number; lifeSteal: number; sacrifice: number; absorb: number };
greatSacrificeUsed: number;
insanity: number; insanity: number;
magicalArmor: { value: number; max: number; runicCounter: number }; magicalArmor: { value: number; max: number; runicCounter: number };
magicalShield: { value: number }; magicalShield: { value: number };
@@ -247,7 +267,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
totalArmorConditionMax += condMax; totalArmorConditionMax += condMax;
// Condition-scaled DR: broken armor (cond=0) gives 0 DR // Condition-scaled DR: broken armor (cond=0) gives 0 DR
if (condMax > 0) { if (condMax > 0) {
effectiveDR += Math.floor(baseDR * (cond / condMax)); effectiveDR += Math.ceil(baseDR * (cond / condMax));
} }
} }
} }
+2
View File
@@ -66,6 +66,8 @@ export class NpcData extends foundry.abstract.TypeDataModel {
bonus: makeIntField(0, { min: 0 }), bonus: makeIntField(0, { min: 0 }),
}), }),
speed: makeStringField('5m'), speed: makeStringField('5m'),
manaBorrowed: makeIntField(0, { min: 0 }),
immediateActionsUsed: makeIntField(0, { min: 0 }),
}), }),
skills: new f.SchemaField(skillEntries), skills: new f.SchemaField(skillEntries),
details: new f.SchemaField({ details: new f.SchemaField({
+1
View File
@@ -12,6 +12,7 @@ export class DisciplineData extends foundry.abstract.TypeDataModel {
}), }),
suggestedSkills: makeArrayField(new f.StringField({ blank: false })), suggestedSkills: makeArrayField(new f.StringField({ blank: false })),
passiveAbility: makeHtmlField(''), passiveAbility: makeHtmlField(''),
signatureSpells: makeArrayField(new f.StringField({ blank: false })),
availableSpells: makeArrayField(new f.StringField({ blank: false })), availableSpells: makeArrayField(new f.StringField({ blank: false })),
description: makeHtmlField(''), description: makeHtmlField(''),
}; };
+3 -1
View File
@@ -6,7 +6,7 @@ import {
makeArrayField, makeArrayField,
fields, fields,
} from './fields'; } from './fields';
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants'; import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES, WOUND_KINDS } from '../constants';
/** /**
* Unified Gear item - discriminated by `category`. * Unified Gear item - discriminated by `category`.
@@ -39,6 +39,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
choices: DAMAGE_TYPES as unknown as readonly string[], choices: DAMAGE_TYPES as unknown as readonly string[],
}), }),
properties: makeArrayField(new f.StringField({ blank: false })), properties: makeArrayField(new f.StringField({ blank: false })),
// Rodzaj rany: slashing (sieczne) / piercing (kłute) / blunt (obuchowe); several allowed, empty = unspecified.
woundKinds: makeArrayField(new f.StringField({ blank: false, choices: WOUND_KINDS as unknown as string[] })),
}), }),
armor: new f.SchemaField({ armor: new f.SchemaField({
+2 -3
View File
@@ -23,7 +23,6 @@ export class SpellData extends foundry.abstract.TypeDataModel {
const f = fields(); const f = fields();
return { return {
// Identification // Identification
circle: makeIntField(1, { min: 0, max: 9 }),
school: makeStringField('', { blank: true, choices: SPELL_SCHOOLS as unknown as readonly string[] }), school: makeStringField('', { blank: true, choices: SPELL_SCHOOLS as unknown as readonly string[] }),
discipline: makeStringField(''), discipline: makeStringField(''),
deity: makeStringField('', { blank: true, choices: SACRED_DEITIES as unknown as readonly string[] }), deity: makeStringField('', { blank: true, choices: SACRED_DEITIES as unknown as readonly string[] }),
@@ -37,7 +36,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
// Difficulty // Difficulty
difficulty: new f.SchemaField({ difficulty: new f.SchemaField({
threshold: makeIntField(TS_DEFAULT_THRESHOLD, { min: 2, max: 6 }), threshold: makeIntField(TS_DEFAULT_THRESHOLD, { min: 2, max: 6 }),
successes: makeIntField(TS_DEFAULT_REQUIRED, { min: 1, max: 10 }), successes: makeIntField(TS_DEFAULT_REQUIRED, { min: 1, max: 30 }),
}), }),
// Components // Components
@@ -100,7 +99,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
// Variable success summons (Przywołanie Istoty z Otchłani) // Variable success summons (Przywołanie Istoty z Otchłani)
variableSuccesses: makeArrayField(new f.SchemaField({ variableSuccesses: makeArrayField(new f.SchemaField({
label: makeStringField(''), label: makeStringField(''),
successes: makeIntField(1, { min: 1, max: 10 }), successes: makeIntField(1, { min: 1, max: 30 }),
})), })),
// Description // Description
+8
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>`);
} }
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.diceModifier')}</label>
<input type="number" name="diceModifier" value="0" step="1"/>
<small>${game.i18n.localize('HBM.spellCast.diceModifierHint')}</small>
</div>`);
if (mode !== 'sacred') { if (mode !== 'sacred') {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -162,6 +168,8 @@ 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);
const diceMod = Math.trunc(Number(fd.diceModifier) || 0);
if (diceMod !== 0) opts.diceModifier = diceMod;
if (fd.hekateMode) opts.hekateMode = 'witch'; if (fd.hekateMode) opts.hekateMode = 'witch';
if (fd.castAsPrayer) opts.castAsPrayer = true; if (fd.castAsPrayer) opts.castAsPrayer = true;
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true; if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
+4 -1
View File
@@ -30,10 +30,12 @@ import { castSpell } from './logic/spell-cast';
import * as bloodMagic from './logic/blood-magic'; import * as bloodMagic from './logic/blood-magic';
import * as abyssMagic from './logic/abyss-magic'; import * as abyssMagic from './logic/abyss-magic';
import * as brewing from './logic/brewing'; import * as brewing from './logic/brewing';
import * as runicArmor from './logic/runic-armor';
import { rest } from './logic/rest'; import { rest } from './logic/rest';
import * as trade from './logic/trade'; import * as trade from './logic/trade';
export const SYSTEM_ID = __SYSTEM_ID__; import { SYSTEM_ID } from './constants';
export { SYSTEM_ID };
Hooks.once('init', () => { Hooks.once('init', () => {
console.log(`${SYSTEM_ID} | Initialising Homebrew Magic: RPG v3 system`); console.log(`${SYSTEM_ID} | Initialising Homebrew Magic: RPG v3 system`);
@@ -136,6 +138,7 @@ Hooks.once('ready', () => {
bloodMagic, bloodMagic,
abyssMagic, abyssMagic,
brewing, brewing,
runicArmor,
rest, rest,
trade, trade,
}; };
+248 -49
View File
@@ -1,34 +1,62 @@
/** /**
* Magia Krwi - Blood Magic logic (Arcanum Sanguinis Ch. III). * Magia Krwi - Blood Magic logic (Arcanum Sanguinis).
* *
* Three primitives: * Krew sources and sinks follow the vault (Foundry Sync Brief §17):
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient. * - selfHarm Samookaleczenie (Akcja) / Większe Samookaleczenie (Akcja Natychmiastowa)
* - selfHarm(actor, hp) : convert hp → blood at 2:1 ratio (2 HP → 1 Blood). * - useLifeSteal / resolveLifeSteal Kradzież Życia / Większa Kradzież Życia (1 Zapał)
* - lifeStealOnDamage(...) : when actor inflicts damage with a blood spell, * - sacrifice / greatSacrifice Złożenie w Ofierze / Wielka Ofiara (1 Zapał)
* restore blood = floor(damage / 4) (capped at max). * - absorbBlood Wchłanianie Krwi / Większe Wchłanianie Krwi
* - gainKrewFromWeaponDamage passive gain (Szacunek do Życia, Krwisty Pojedynkowicz)
* - startOfFightKrew / endOfFightKrew Rytualista Krwi start pool, leftover Krew heals
* *
* Future hook: `Szacunek do Życia` talent triples self-harm cost (6 HP → 1 Blood). * Per-round limits live in `attributes.bloodUsed` (reset every round by logic/combat.ts);
* * Wielka Ofiara is once per day (`attributes.greatSacrificeUsed`, reset by Długi Odpoczynek).
* The cast pipeline (logic/spell-cast.ts) already deducts `bloodCost` directly; * The cast pipeline (logic/spell-cast.ts) deducts `bloodCost` directly.
* this module is the reusable API surface for UI dialogs and the damage hook.
*/ */
import { getMagicPowerEntry } from '../constants';
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
import {
bloodAbsorbHp,
bloodToZeal,
greatSacrificeResult,
krewFromLifeSteal,
krewFromSelfHarm,
krewFromWeaponHpLoss,
sacrificeResult,
startingKrew,
type KrewTalent,
} from './rules';
const SELF_HARM_RATIO = 2; // HP per 1 Blood const SELF_HARM_THRESHOLD = 2;
const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present const FLAG_SCOPE = 'hbm-rpg-v3';
const LIFE_STEAL_DIVISOR = 4; // damage / N → blood restored
interface ActorWithItems extends CastableActor { interface ActorWithItems extends CastableActor {
items?: Iterable<{ type: string; system?: { slug?: string } }>; items?: Iterable<{ type: string; system?: { slug?: string } }>;
} }
function hasRespectForLife(actor: ActorWithItems): boolean { export type BloodAction = 'selfHarm' | 'lifeSteal' | 'sacrifice' | 'absorb';
if (!actor.items) return false;
for (const it of actor.items) { /** Talent that decides passive Krew gain / starting pool, if any. */
export function krewTalent(actor: CastableActor): KrewTalent | null {
const items = (actor as ActorWithItems).items;
if (!items) return null;
let found: KrewTalent | null = null;
for (const it of items) {
if (it.type !== 'talent') continue; if (it.type !== 'talent') continue;
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug; const slug = (it as any).system?.slug ?? (it as any).flags?.[FLAG_SCOPE]?.slug;
if (slug === 'respect-for-life' || slug === 'szacunek-do-zycia') return true; if (slug === 'rytualista-krwi') return 'rytualista-krwi';
if (slug === 'krwisty-pojedynkowicz') found = 'krwisty-pojedynkowicz';
else if ((slug === 'respect-for-life' || slug === 'szacunek-do-zycia') && !found) found = 'respect-for-life';
}
return found;
}
function hasTalent(actor: CastableActor, ...slugs: string[]): boolean {
const items = (actor as ActorWithItems).items;
if (!items) return false;
for (const it of items) {
if (it.type === 'talent' && slugs.includes((it as any).system?.slug ?? (it as any).flags?.[FLAG_SCOPE]?.slug)) return true;
} }
return false; return false;
} }
@@ -52,38 +80,209 @@ export async function spendBlood(actor: CastableActor, amount: number): Promise<
return { ok: true, spent: amount, remaining: blood.value - amount }; return { ok: true, spent: amount, remaining: blood.value - amount };
} }
/** function used(actor: CastableActor, action: BloodAction): number {
* Self-Harm: convert health into blood. Returns blood gained. return Number((actor.system.attributes as any).bloodUsed?.[action]) || 0;
* Default ratio 2 HP → 1 Blood; with `Szacunek do Życia` talent → 6 HP → 1 Blood.
*/
export async function selfHarm(actor: CastableActor, hpSpent: number): Promise<{ ok: boolean; bloodGained: number; reason?: string }> {
if (hpSpent <= 0) return { ok: true, bloodGained: 0 };
const a = actor.system.attributes;
const blood = a.blood;
if (!blood) return { ok: false, bloodGained: 0, reason: 'no-blood-pool' };
const health = (a as any).health as { value: number; max: number } | undefined;
if (!health || health.value < hpSpent) {
return { ok: false, bloodGained: 0, reason: 'insufficient-health' };
}
const ratio = hasRespectForLife(actor as ActorWithItems) ? SELF_HARM_RATIO * RESPECT_FOR_LIFE_PENALTY : SELF_HARM_RATIO;
const gained = Math.floor(hpSpent / ratio);
if (gained <= 0) return { ok: false, bloodGained: 0, reason: 'ratio-too-low' };
const newBlood = Math.min(blood.max, blood.value + gained);
await actor.update({
'system.attributes.health.value': health.value - hpSpent,
'system.attributes.blood.value': newBlood,
});
return { ok: true, bloodGained: newBlood - blood.value };
} }
/** Life Steal: invoked from the damage-application hook when the source is a blood spell. */ function bump(action: BloodAction, actor: CastableActor): Record<string, number> {
export async function lifeStealOnDamage(actor: CastableActor, damageDealt: number): Promise<number> { return { [`system.attributes.bloodUsed.${action}`]: used(actor, action) + 1 };
if (damageDealt <= 0) return 0; }
const blood = actor.system.attributes.blood;
function addKrew(blood: { value: number; max: number }, amount: number): number {
return Math.max(0, Math.min(blood.max, blood.value + amount) - blood.value);
}
/** Magia dice + Walka Wręcz skill, threshold `threshold` – returns the Sukcesy. */
export async function rollMagicMelee(actor: CastableActor, threshold: number): Promise<number> {
const attrs = actor.system.attributes as any;
const magic = attrs.magic ?? {};
const dice = typeof magic.dicePool === 'number'
? magic.dicePool
: getMagicPowerEntry(magic.actual ?? magic.value ?? 0).dicePool;
const skill = (actor.system.skills as any)?.melee?.value ?? 0;
const { HbmTSRoll } = await import('../dice/ts-roll'); // lazy: needs Foundry's Roll at runtime only
const roll = HbmTSRoll.fromParams({ pool: dice + skill, threshold, required: 1, flavor: 'Magia (Walka Wręcz)' });
await roll.evaluate();
await roll.toMessage({ flavor: 'Magia (Walka Wręcz)' });
return roll.ts?.successes ?? 0;
}
type SuccessRoller = (actor: CastableActor, threshold: number) => Promise<number>;
export interface BloodActionResult {
ok: boolean;
successes?: number;
hpLost?: number;
hpGained?: number;
krewGained: number;
reason?: string;
}
const fail = (reason: string): BloodActionResult => ({ ok: false, krewGained: 0, reason });
/**
* Samookaleczenie (Akcja, once per round): Test Magia (Walka Wręcz) T=2; lose HP = Sukcesy,
* gain 1 Krew per HP. Większe Samookaleczenie (Krwisty Pojedynkowicz, Akcja Natychmiastowa): 2 Krew per HP.
*/
export async function selfHarm(
actor: CastableActor,
opts: { greater?: boolean; roller?: SuccessRoller } = {},
): Promise<BloodActionResult> {
const a = actor.system.attributes as any;
if (!a.blood) return fail('no-blood-pool');
if (opts.greater && !hasTalent(actor, 'wieksze-samookaleczenie', 'krwisty-pojedynkowicz')) return fail('missing-talent');
if (used(actor, 'selfHarm') >= 1) return fail('already-used');
const successes = await (opts.roller ?? rollMagicMelee)(actor, SELF_HARM_THRESHOLD);
const hpLost = Math.min(successes, Math.max(0, a.health?.value ?? 0));
const krewGained = addKrew(a.blood, krewFromSelfHarm(hpLost, !!opts.greater));
await actor.update({
'system.attributes.health.value': (a.health?.value ?? 0) - hpLost,
'system.attributes.blood.value': a.blood.value + krewGained,
...bump('selfHarm', actor),
});
return { ok: true, successes, hpLost, krewGained };
}
async function spendZeal(actor: CastableActor, amount: number): Promise<boolean> {
const zeal = (actor.system.attributes as any).zeal;
if (!zeal || zeal.value < amount) return false;
await actor.update({ 'system.attributes.zeal.value': zeal.value - amount });
return true;
}
/**
* Kradzież Życia: 1 Zapał, free Akcja Natychmiastowa (outside the normal limit), once per round
* (Większa Kradzież Życia: twice per round, two steps easier). Arms the next attack; call
* `resolveLifeSteal` with the Żywotność the target actually lost.
*/
export async function useLifeSteal(actor: CastableActor, opts: { greater?: boolean } = {}): Promise<{ ok: boolean; stepsEasier: number; reason?: string }> {
const limit = opts.greater ? 2 : 1;
if (!(actor.system.attributes as any).blood) return { ok: false, stepsEasier: 0, reason: 'no-blood-pool' };
if (opts.greater && !hasTalent(actor, 'wieksza-kradziez-zycia')) return { ok: false, stepsEasier: 0, reason: 'missing-talent' };
if (used(actor, 'lifeSteal') >= limit) return { ok: false, stepsEasier: 0, reason: 'already-used' };
if (!(await spendZeal(actor, 1))) return { ok: false, stepsEasier: 0, reason: 'insufficient-zeal' };
const stepsEasier = opts.greater ? 2 : 1;
await actor.update(bump('lifeSteal', actor));
await (actor as any).setFlag?.(FLAG_SCOPE, 'lifeSteal', { stepsEasier, greater: !!opts.greater });
return { ok: true, stepsEasier };
}
/** Kradzież Życia payout: 1 Krew (2 greater) per Żywotność the target actually lost. */
export async function resolveLifeSteal(actor: CastableActor, hpActuallyLost: number): Promise<number> {
const pending = (actor as any).getFlag?.(FLAG_SCOPE, 'lifeSteal') as { greater?: boolean } | undefined;
const blood = (actor.system.attributes as any).blood;
if (!pending || !blood) return 0;
const gained = addKrew(blood, krewFromLifeSteal(hpActuallyLost, !!pending.greater));
if (gained > 0) await actor.update({ 'system.attributes.blood.value': blood.value + gained });
await (actor as any).unsetFlag?.(FLAG_SCOPE, 'lifeSteal');
return gained;
}
/**
* Złożenie w Ofierze: 1 Zapał, free Akcja Natychmiastowa, once per round. Triggers when your attack
* kills a target with a slashing / piercing wound within 1 m. `successes` are the Sukcesy of the
* killing attack's Test: heal Sukcesy + Magia, gain Krew = Sukcesy.
*/
export async function sacrifice(actor: CastableActor, successes: number): Promise<BloodActionResult> {
const a = actor.system.attributes as any;
if (!a.blood) return fail('no-blood-pool');
if (used(actor, 'sacrifice') >= 1) return fail('already-used');
if (hasTalent(actor, 'wielka-ofiara', 'rytualista-krwi')) return fail('replaced-by-wielka-ofiara');
if (!(await spendZeal(actor, 1))) return fail('insufficient-zeal');
const res = sacrificeResult(successes, a.magic?.actual ?? a.magic?.value ?? 0);
const hpGained = Math.min(res.heal, Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
const krewGained = addKrew(a.blood, res.krew);
await actor.update({
'system.attributes.health.value': (a.health?.value ?? 0) + hpGained,
'system.attributes.blood.value': a.blood.value + krewGained,
...bump('sacrifice', actor),
});
return { ok: true, successes, hpGained, krewGained };
}
/**
* Wielka Ofiara (Rytualista Krwi; replaces Złożenie w Ofierze): 1 Zapał, once per day, target within 1 m
* is being attacked. Sukcesy above the target's highest Atrybut → the target loses all Żywotność
* (returned as `targetHpLost`, the caller applies it); otherwise the mage heals Sukcesy.
*/
export async function greatSacrifice(
actor: CastableActor,
successes: number,
target: { highestAttribute: number; health: number },
): Promise<BloodActionResult & { kills?: boolean; targetHpLost?: number }> {
const a = actor.system.attributes as any;
if (!a.blood) return fail('no-blood-pool');
if (!hasTalent(actor, 'wielka-ofiara', 'rytualista-krwi')) return fail('missing-talent');
if (Number(a.greatSacrificeUsed) >= 1) return fail('already-used');
if (!(await spendZeal(actor, 1))) return fail('insufficient-zeal');
const res = greatSacrificeResult(successes, target.highestAttribute, target.health);
const hpGained = Math.min(res.heal, Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
const krewGained = addKrew(a.blood, res.krew);
await actor.update({
'system.attributes.health.value': (a.health?.value ?? 0) + hpGained,
'system.attributes.blood.value': a.blood.value + krewGained,
'system.attributes.greatSacrificeUsed': 1,
});
return { ok: true, successes, hpGained, krewGained, kills: res.kills, targetHpLost: res.kills ? target.health : 0 };
}
/**
* Wchłanianie Krwi (Akcja, once per round, min. 1 Krew): 2 HP per Krew.
* Większe Wchłanianie Krwi (Akcja Natychmiastowa): 3 HP per Krew, or 5 Krew → 1 Zapał.
*/
export async function absorbBlood(
actor: CastableActor,
bloodSpent: number,
opts: { greater?: boolean; asZeal?: boolean } = {},
): Promise<{ ok: boolean; hpGained: number; zealGained: number; reason?: string }> {
const a = actor.system.attributes as any;
if (bloodSpent < 1) return { ok: false, hpGained: 0, zealGained: 0, reason: 'minimum-one-blood' };
if (!a.blood || a.blood.value < bloodSpent) return { ok: false, hpGained: 0, zealGained: 0, reason: 'insufficient-blood' };
if (!opts.greater && used(actor, 'absorb') >= 1) return { ok: false, hpGained: 0, zealGained: 0, reason: 'already-used' };
const update: Record<string, number> = { 'system.attributes.blood.value': a.blood.value - bloodSpent };
let hpGained = 0;
let zealGained = 0;
if (opts.greater && opts.asZeal) {
zealGained = Math.min(bloodToZeal(bloodSpent), Math.max(0, (a.zeal?.max ?? 0) - (a.zeal?.value ?? 0)));
update['system.attributes.zeal.value'] = (a.zeal?.value ?? 0) + zealGained;
} else {
hpGained = Math.min(bloodAbsorbHp(bloodSpent, !!opts.greater), Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
update['system.attributes.health.value'] = (a.health?.value ?? 0) + hpGained;
}
if (!opts.greater) Object.assign(update, bump('absorb', actor));
await actor.update(update);
return { ok: true, hpGained, zealGained };
}
/**
* Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz),
* by either side, only from slashing or piercing weapons. Call for the victim and the attacker.
*/
export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise<number> {
const blood = (actor.system.attributes as any)?.blood;
if (!blood) return 0; if (!blood) return 0;
const restore = Math.floor(damageDealt / LIFE_STEAL_DIVISOR); const gained = addKrew(blood, krewFromWeaponHpLoss(hpLost, krewTalent(actor)));
if (restore <= 0) return 0; if (gained > 0) await actor.update({ 'system.attributes.blood.value': blood.value + gained });
const newBlood = Math.min(blood.max, blood.value + restore); return gained;
await actor.update({ 'system.attributes.blood.value': newBlood }); }
return newBlood - blood.value;
/** Start of a fight: Krew pool resets to 0 (Rytualista Krwi: 4) for blood-talent holders. */
export async function startOfFightKrew(actor: CastableActor): Promise<void> {
const talent = krewTalent(actor);
const blood = (actor.system.attributes as any)?.blood;
if (!talent || !blood) return;
const target = Math.min(blood.max, startingKrew(talent));
if (blood.value !== target) await actor.update({ 'system.attributes.blood.value': target });
}
/** End of a fight: leftover Krew vanishes and heals the mage as Wchłanianie Krwi (2 HP per Krew). */
export async function endOfFightKrew(actor: CastableActor): Promise<number> {
const a = actor.system.attributes as any;
if (!krewTalent(actor) || !a.blood || a.blood.value <= 0) return 0;
const hpGained = Math.min(bloodAbsorbHp(a.blood.value, false), Math.max(0, (a.health?.max ?? 0) - (a.health?.value ?? 0)));
await actor.update({
'system.attributes.blood.value': 0,
'system.attributes.health.value': (a.health?.value ?? 0) + hpGained,
});
return hpGained;
} }
+19
View File
@@ -14,6 +14,7 @@
import { HbmTSRoll } from '../dice/ts-roll'; import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants'; import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
import { MANA_ELIXIR_NORMAL, MANA_ELIXIR_QUICK_DIE, manaElixirGain } from './rules';
export interface ElixirRecipe { export interface ElixirRecipe {
name: string; name: string;
@@ -61,3 +62,21 @@ export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Pr
}); });
return { roll, success: !!roll.ts?.isSuccess }; return { roll, success: !!roll.ts?.isSuccess };
} }
/**
* Eliksir Odnowienia Many: normal use restores 3 mana (capped at the daily pool) and
* raises Maksymalny Koszt Zaklęcia by 1 until the end of the drinker's next turn;
* quick use (Akcja Natychmiastowa) restores 1d3 mana only.
*/
export async function drinkManaElixir(actor: CastableActor, quick = false): Promise<{ gained: number }> {
const a = actor.system.attributes as any;
let amount = MANA_ELIXIR_NORMAL;
if (quick) {
const roll = new Roll(`1d${MANA_ELIXIR_QUICK_DIE}`);
await roll.evaluate();
amount = roll.total ?? 1;
}
const gained = manaElixirGain(a.mana.value, a.mana.max, amount);
await actor.update({ 'system.attributes.mana.value': a.mana.value + gained });
return { gained };
}
+11 -3
View File
@@ -9,6 +9,7 @@
*/ */
import { applyDamage } from './damage'; import { applyDamage } from './damage';
import type { SpellDamageProfile } from './rules';
import { CONDITIONS } from './conditions'; import { CONDITIONS } from './conditions';
declare const Hooks: any; declare const Hooks: any;
@@ -71,12 +72,19 @@ async function onApplyDamage(btn: HTMLElement): Promise<void> {
return; return;
} }
const ignoresArmor = btn.dataset.ignoresArmor === 'true'; const ignoresArmor = btn.dataset.ignoresArmor === 'true';
let profile: SpellDamageProfile | null = null;
try { profile = btn.dataset.profile ? JSON.parse(btn.dataset.profile) : null; } catch { /* ignore */ }
await applyDamage(target, { await applyDamage(target, {
amount, amount,
type: (btn.dataset.type as any) ?? 'magical', type: (btn.dataset.type as any) ?? 'magical',
ignoreMagicalArmor: ignoresArmor, // A spell-specific profile replaces the blanket "ignores all armour" flag.
ignoreMagicalShield: ignoresArmor, ignoreMagicalArmor: profile ? !!profile.ignoreMagicalArmor : ignoresArmor,
ignorePhysicalArmor: ignoresArmor, ignoreMagicalShield: profile ? !!profile.ignoreMagicalShield : ignoresArmor,
ignorePhysicalArmor: profile ? !!profile.ignorePhysicalArmor : ignoresArmor,
runicArmorStep: profile?.runicArmorStep,
shieldMultiplier: profile?.shieldMultiplier,
destroyMagicalDefenses: profile?.destroyMagicalDefenses,
minDamageAfterArmor: btn.dataset.minAfterArmor === 'true',
} as any); } as any);
} }
+69 -1
View File
@@ -7,6 +7,8 @@
*/ */
import { applyDamage } from './damage'; import { applyDamage } from './damage';
import { startOfFightKrew, endOfFightKrew } from './blood-magic';
import { restoreAllRunicArmor } from './runic-armor';
function hasStatus(actor: any, id: string): boolean { function hasStatus(actor: any, id: string): boolean {
const effects = actor?.effects ?? []; const effects = actor?.effects ?? [];
@@ -20,18 +22,84 @@ function hasStatus(actor: any, id: string): boolean {
return false; return false;
} }
/** Statuses lasting "until the end of this character's next turn" (see conditions.ts `expires`). */
export const END_OF_NEXT_TURN_STATUSES = ['oszolomiony', 'przerazony'] as const;
const FLAG_SCOPE = 'hbm-rpg-v3';
function expiringEffects(actor: any): any[] {
const out: any[] = [];
for (const ef of actor?.effects ?? []) {
const ids: string[] = ef?.statuses ? [...ef.statuses] : [];
if (ids.some((s) => (END_OF_NEXT_TURN_STATUSES as readonly string[]).includes(s))) out.push(ef);
}
return out;
}
/** Start of the actor's turn: arm effects so they end with this turn. */
async function armExpiring(actor: any): Promise<void> {
for (const ef of expiringEffects(actor)) {
if (!ef.getFlag?.(FLAG_SCOPE, 'turnStarted')) await ef.setFlag?.(FLAG_SCOPE, 'turnStarted', true);
}
}
/** End of the actor's turn: drop armed effects. */
async function expireArmed(actor: any): Promise<void> {
const ids = expiringEffects(actor).filter((ef) => ef.getFlag?.(FLAG_SCOPE, 'turnStarted')).map((ef) => ef.id);
if (ids.length) await actor.deleteEmbeddedDocuments?.('ActiveEffect', ids);
}
export function registerCombatHooks(): void { export function registerCombatHooks(): void {
Hooks.on('combatRound', async (_combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => { Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
// One free Akcja Natychmiastowa per round – reset the per-round counter.
for (const c of (combat as any)?.combatants ?? []) {
const actor = c.actor;
if (actor?.system?.attributes?.immediateActionsUsed) {
await actor.update({ 'system.attributes.immediateActionsUsed': 0 });
}
const bu = actor?.system?.attributes?.bloodUsed;
if (bu && Object.values(bu).some((n) => Number(n) > 0)) {
await actor.update({
'system.attributes.bloodUsed.selfHarm': 0,
'system.attributes.bloodUsed.lifeSteal': 0,
'system.attributes.bloodUsed.sacrifice': 0,
'system.attributes.bloodUsed.absorb': 0,
});
}
}
ChatMessage.create({ ChatMessage.create({
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`, content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
whisper: ChatMessage.getWhisperRecipients('GM'), whisper: ChatMessage.getWhisperRecipients('GM'),
}); });
}); });
// Start of a fight: Krew pool resets (Rytualista Krwi starts with 4).
Hooks.on('combatStart', async (combat: Combat) => {
for (const c of (combat as any)?.combatants ?? []) {
if (c.actor?.type === 'character') await startOfFightKrew(c.actor);
}
});
// End of a fight: disabled Runiczny Pancerz returns, per-round counters clear, leftover Krew heals.
Hooks.on('deleteCombat', async (combat: Combat) => {
for (const c of (combat as any)?.combatants ?? []) {
const actor = c.actor;
if (!actor || actor.type !== 'character') continue;
await restoreAllRunicArmor(actor);
await endOfFightKrew(actor);
}
});
Hooks.on('combatTurn', async (combat: Combat) => { Hooks.on('combatTurn', async (combat: Combat) => {
// The previous combatant's turn just ended: expire their timed Stany.
const prevId = (combat as any).previous?.combatantId;
const prevActor = prevId ? (combat as any).combatants?.get?.(prevId)?.actor : null;
if (prevActor) await expireArmed(prevActor);
const combatant = combat.combatant; const combatant = combat.combatant;
const actor = combatant?.actor; const actor = combatant?.actor;
if (!actor) return; if (!actor) return;
await armExpiring(actor);
// Skip turn for unconscious / restrained-to-incapacitation // Skip turn for unconscious / restrained-to-incapacitation
if (hasStatus(actor, 'nieprzytomny') || hasStatus(actor, 'obezwladniony')) { if (hasStatus(actor, 'nieprzytomny') || hasStatus(actor, 'obezwladniony')) {
+4 -4
View File
@@ -4,7 +4,7 @@
* consumed by the roll pipeline and combat hooks. * consumed by the roll pipeline and combat hooks.
*/ */
import { SYSTEM_ID } from '../hbm'; import { SYSTEM_ID } from '../constants';
export interface ConditionDef { export interface ConditionDef {
id: string; id: string;
@@ -17,14 +17,14 @@ 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, attacksWithinMeters: 3, thresholdStepsAgainst: -2, onlyDefense: 'dodge', standUpCost: 'halfMoveCeil' } } },
{ 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, expires: 'endOfNextTurn' } } },
{ {
id: 'zatruty', id: 'zatruty',
i18nKey: 'HBM.conditions.zatruty', i18nKey: 'HBM.conditions.zatruty',
@@ -53,7 +53,7 @@ export const CONDITIONS: ConditionDef[] = [
{ 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: { fearSource: '', thresholdStepsVsSource: 1, noCloserToSource: true, expires: 'endOfNextTurn', refreshOnReapply: 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).
+78 -15
View File
@@ -1,17 +1,21 @@
/** /**
* 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. Runiczny Pancerz (DR; one hit > 5 disables 1 point until end of fight / restore)
* 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)
* 4. Health (the leftover hits HP) * 4. Health (the leftover hits HP)
* *
* Bypass flags allow effects to skip individual layers. * Bypass flags allow effects to skip individual layers.
* *
* After resolving, if HP loss meets the Oszołomiony threshold ⌈(C+U+D)/3⌉ * After resolving, if HP loss (after reductions) is at least half of max
* the effect is auto-applied to character actors. * Żywotność (= C+U+D) the Oszołomiony status is applied (lasts until the end
* of that character's next turn). Żywotność lost also yields a Koncentracja
* test 4:⌈⅓ lost⌉ (min 1).
*/ */
import { DamageType } from '../constants'; import { DamageType } from '../constants';
import { triggersStun, concentrationTest, runicArmorPointsDisabled } from './rules';
import { gainKrewFromWeaponDamage } from './blood-magic';
export interface ApplyDamageOptions { export interface ApplyDamageOptions {
amount: number; amount: number;
@@ -20,6 +24,21 @@ export interface ApplyDamageOptions {
ignoreMagicalShield?: boolean; ignoreMagicalShield?: boolean;
ignorePhysicalArmor?: boolean; ignorePhysicalArmor?: boolean;
damageArmor?: boolean; damageArmor?: boolean;
/** Magiczny Pocisk: at least 1 damage gets through Pancerz to Żywotność. */
minDamageAfterArmor?: boolean;
/** Shield-breaker: every N (2 instead of 5) damage disable a Runiczny Pancerz point. */
runicArmorStep?: number;
/** Shield-breaker: damage counts ×N against Magiczna Osłona. */
shieldMultiplier?: number;
/** Prawdziwa Dezintegracja: disables all Runiczny Pancerz points and destroys Magiczna Osłona. */
destroyMagicalDefenses?: boolean;
/** Rozerwanie Run: permanently destroys N Runiczny Pancerz points (lowers its maximum). */
destroyRunicPoints?: number;
/** Wound kind of a weapon hit (gear data has none yet – callers pass it); drives passive Krew. */
/** Wound kinds of the attacking weapon; only slashing / piercing give passive Krew. */
woundKinds?: ('slashing' | 'piercing' | 'blunt')[];
/** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */
sourceActor?: any;
postChat?: boolean; postChat?: boolean;
} }
@@ -29,6 +48,14 @@ export interface DamageReport {
hpDamage: number; hpDamage: number;
shieldDropped: boolean; shieldDropped: boolean;
oszolomionyApplied: boolean; oszolomionyApplied: boolean;
/** Runiczny Pancerz points disabled by this hit (restored at end of fight). */
runicArmorDisabled?: number;
/** Runiczny Pancerz points destroyed permanently by this hit. */
runicArmorDestroyed?: number;
/** Krew gained by the victim / attacker through weapon-damage talents. */
krewGained?: { victim: number; source: number };
/** Dusza (Determinacja) 4:S for a concentrating target; absent when no Żywotność was lost. */
concentrationTest?: { threshold: number; successes: number };
} }
interface ActorLike { interface ActorLike {
@@ -54,30 +81,45 @@ 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: Runiczny Pancerz - flat DR. ONE hit disables points (never accumulated across hits):
// > 5 damage after the step → 1 point; shield-breakers: every 2 damage → 1 point. Disabled
// points come back at the end of the fight / when restored (see logic/runic-armor.ts).
if (a.magicalArmor && opts.destroyRunicPoints) {
// Rozerwanie Run: permanent – lowers the maximum, cannot be restored.
const value = Math.max(0, Number(a.magicalArmor.value) || 0);
const max = Math.max(0, Number(a.magicalArmor.max) || 0);
const lost = Math.min(max, opts.destroyRunicPoints);
updates['system.attributes.magicalArmor.max'] = max - lost;
updates['system.attributes.magicalArmor.value'] = Math.min(value, max - lost);
report.runicArmorDestroyed = lost;
}
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);
if (absorbed > 0) { if (absorbed > 0) {
const incomingDmg = remaining; const incoming = remaining;
report.absorbed.magicalArmor = absorbed; report.absorbed.magicalArmor = absorbed;
remaining -= absorbed; remaining -= absorbed;
const newCounter = (Number(a.magicalArmor.runicCounter) || 0) + incomingDmg; const disabled = opts.destroyMagicalDefenses
const decrements = Math.floor(newCounter / 5); ? dr
const newValue = Math.max(0, dr - decrements); : runicArmorPointsDisabled(opts.runicArmorStep ? incoming : remaining, opts.runicArmorStep);
updates['system.attributes.magicalArmor.runicCounter'] = newCounter % 5; const current = (updates['system.attributes.magicalArmor.value'] as number | undefined) ?? dr;
const newValue = Math.max(0, Math.min(current, dr) - disabled);
if (disabled > 0) report.runicArmorDisabled = Math.min(dr, disabled);
updates['system.attributes.magicalArmor.value'] = newValue; updates['system.attributes.magicalArmor.value'] = newValue;
} }
} }
// Layer 2: Magical Shield - pure temp HP (raw subtraction). // Layer 2: Magiczna Osłona - pure temp HP; shield-breakers count damage ×N against it.
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 mult = Math.max(1, opts.shieldMultiplier ?? 1);
if (taken > 0) { const effective = Math.min(shield, remaining * mult);
report.absorbed.magicalShield = taken; if (effective > 0) {
remaining -= taken; const consumed = Math.ceil(effective / mult);
const newShield = shield - taken; report.absorbed.magicalShield = consumed;
remaining -= consumed;
const newShield = opts.destroyMagicalDefenses ? 0 : shield - effective;
updates['system.attributes.magicalShield.value'] = newShield; updates['system.attributes.magicalShield.value'] = newShield;
if (newShield === 0) report.shieldDropped = true; if (newShield === 0) report.shieldDropped = true;
} }
@@ -90,6 +132,10 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (absorbed > 0) { if (absorbed > 0) {
report.absorbed.physicalArmor = absorbed; report.absorbed.physicalArmor = absorbed;
remaining -= absorbed; remaining -= absorbed;
if (opts.minDamageAfterArmor && remaining === 0 && absorbed > 0) {
remaining = 1;
report.absorbed.physicalArmor = absorbed - 1;
}
} }
if (opts.damageArmor && actor.type === 'character' && a.physicalArmor.condition != null) { if (opts.damageArmor && actor.type === 'character' && a.physicalArmor.condition != null) {
// Decrement durability of the first equipped armor piece (character only) // Decrement durability of the first equipped armor piece (character only)
@@ -116,6 +162,23 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (Object.keys(updates).length > 0) await actor.update(updates); if (Object.keys(updates).length > 0) await actor.update(updates);
// Passive Krew from slashing / piercing weapons (talents): victim and attacker side.
if (report.hpDamage > 0 && (opts.woundKinds ?? []).some((k) => k === 'slashing' || k === 'piercing')) {
report.krewGained = {
victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage),
source: opts.sourceActor ? await gainKrewFromWeaponDamage(opts.sourceActor, report.hpDamage) : 0,
};
}
// Oszołomiony: a single hit that costs ≥ half max Żywotność after all reductions.
if (triggersStun(report.hpDamage, Number(a.health?.max) || 0) && actor.toggleStatusEffect) {
const already = actor.effects?.find((e: any) => e.statuses?.has?.('oszolomiony'));
if (!already) await actor.toggleStatusEffect('oszolomiony', { active: true });
report.oszolomionyApplied = true;
}
const concentration = concentrationTest(report.hpDamage);
if (concentration) report.concentrationTest = concentration;
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>`,
+53
View File
@@ -0,0 +1,53 @@
/**
* NBP (Narodowy Bank Polski) table A mid rates for the money converter.
* Campaign year → first published rate on/after 1 June of that year; a year in the
* future (or no table found / request failed) → latest published rate.
*/
import { TRADE } from './rules';
export interface NbpRate {
rate: number;
/** Effective date (YYYY-MM-DD) of the table the rate comes from. */
date: string;
}
const NBP_FIRST_YEAR = 2002;
const NBP_TIMEOUT_MS = 4000;
/** Offline fallbacks when NBP is unreachable. */
export const FALLBACK_RATES: Record<string, number> = { eur: TRADE.eurRate, usd: 4.0 };
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; json(): Promise<any> }>;
async function getRates(url: string, fetchImpl: FetchLike): Promise<NbpRate | null> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), NBP_TIMEOUT_MS);
try {
const res = await fetchImpl(url, { signal: ctrl.signal });
if (!res.ok) return null;
const first = (await res.json())?.rates?.[0];
return typeof first?.mid === 'number' ? { rate: first.mid, date: String(first.effectiveDate ?? '') } : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** Fetch the rate for `currency` (ISO code, any case) valid for the given campaign year. */
export async function fetchNbpRate(
currency: string,
year: number,
now: Date = new Date(),
fetchImpl: FetchLike = (u, i) => fetch(u, i),
): Promise<NbpRate | null> {
const code = currency.toLowerCase();
const base = `https://api.nbp.pl/api/exchangerates/rates/a/${code}`;
if (year >= NBP_FIRST_YEAR && year <= now.getFullYear()) {
// A two-week window always contains a working day; take the first table in it.
const hit = await getRates(`${base}/${year}-06-01/${year}-06-14/?format=json`, fetchImpl);
if (hit) return hit;
}
return getRates(`${base}/?format=json`, fetchImpl);
}
+23 -3
View File
@@ -4,6 +4,7 @@
* Breather (Odetchnięcie, 10 min): * Breather (Odetchnięcie, 10 min):
* - Restore 1d6 + Wytrzymałość skill value HP (capped at max). * - Restore 1d6 + Wytrzymałość skill value HP (capped at max).
* - Restore 1d6 mana (capped at daily pool max). * - Restore 1d6 mana (capped at daily pool max).
* - At most twice between two Długie Odpoczynki (counter attributes.breathersTaken).
* *
* Short Rest (Krótki Odpoczynek, 1h+): * Short Rest (Krótki Odpoczynek, 1h+):
* - Restore half of max HP. * - Restore half of max HP.
@@ -22,6 +23,7 @@
import type { CastableActor } from './spell-cast'; import type { CastableActor } from './spell-cast';
import { CONDITIONS } from './conditions'; import { CONDITIONS } from './conditions';
import { MAX_BREATHERS_BETWEEN_LONG_RESTS } from './rules';
export type RestKind = 'breather' | 'short' | 'long'; export type RestKind = 'breather' | 'short' | 'long';
@@ -49,11 +51,19 @@ export interface RestResult {
bloodRestored: number; bloodRestored: number;
toleranceRecovered: number; toleranceRecovered: number;
effectsCleared: number; effectsCleared: number;
/** True when an Odetchnięcie was refused (already 2 since the last Długi Odpoczynek). */
blocked?: boolean;
} }
export async function rest(actor: CastableActor, kind: RestKind): Promise<RestResult> { export async function rest(actor: CastableActor, kind: RestKind): Promise<RestResult> {
Hooks.callAll('hbm.beforeRest', actor, kind);
const a = actor.system.attributes as any; const a = actor.system.attributes as any;
if (kind === 'breather' && (a.breathersTaken ?? 0) >= MAX_BREATHERS_BETWEEN_LONG_RESTS) {
return {
kind, hpRestored: 0, manaRestored: 0, zealRestored: 0, bloodRestored: 0,
toleranceRecovered: 0, effectsCleared: 0, blocked: true,
};
}
Hooks.callAll('hbm.beforeRest', actor, kind);
const update: Record<string, unknown> = {}; const update: Record<string, unknown> = {};
const hpBefore = a.health?.value ?? 0; const hpBefore = a.health?.value ?? 0;
@@ -69,6 +79,7 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
let toleranceRecovered = 0; let toleranceRecovered = 0;
if (kind === 'breather') { if (kind === 'breather') {
update['system.attributes.breathersTaken'] = (a.breathersTaken ?? 0) + 1;
if (a.mana?.max != null && manaBefore < a.mana.max) { if (a.mana?.max != null && manaBefore < a.mana.max) {
const manaRoll = new Roll('1d6'); const manaRoll = new Roll('1d6');
await manaRoll.evaluate(); await manaRoll.evaluate();
@@ -106,6 +117,10 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
toleranceRecovered = 1; toleranceRecovered = 1;
} }
} else { } else {
// Długi Odpoczynek resets the per-rest counters.
update['system.attributes.breathersTaken'] = 0;
update['system.attributes.manaBorrowed'] = 0;
update['system.attributes.immediateActionsUsed'] = 0;
if (a.health?.max != null && hpBefore < a.health.max) { if (a.health?.max != null && hpBefore < a.health.max) {
update['system.attributes.health.value'] = a.health.max; update['system.attributes.health.value'] = a.health.max;
hpRestored = a.health.max - hpBefore; hpRestored = a.health.max - hpBefore;
@@ -126,8 +141,13 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
update['system.attributes.elixirTolerance'] = Math.max(0, toleranceBefore - 1); update['system.attributes.elixirTolerance'] = Math.max(0, toleranceBefore - 1);
toleranceRecovered = 1; toleranceRecovered = 1;
} }
if (a.magicalArmor?.runicCounter && a.magicalArmor.runicCounter > 0) { // Disabled Runiczny Pancerz points come back (only Rozerwanie Run is permanent).
update['system.attributes.magicalArmor.runicCounter'] = 0; if (a.magicalArmor && a.magicalArmor.value < a.magicalArmor.max) {
update['system.attributes.magicalArmor.value'] = a.magicalArmor.max;
}
if (a.greatSacrificeUsed) update['system.attributes.greatSacrificeUsed'] = 0;
if (a.bloodUsed) {
for (const k of ['selfHarm', 'lifeSteal', 'sacrifice', 'absorb']) update[`system.attributes.bloodUsed.${k}`] = 0;
} }
} }
+266
View File
@@ -0,0 +1,266 @@
/**
* Pure rule helpers from the 2026-10-20 vault sync (Foundry Sync Brief).
* No Foundry globals here, so everything is unit-testable.
*
* Rounding convention: everything fractional rounds UP, except
* - new-spell mana cost (nearest, min 1),
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1),
* - fire damage with Hartowany w Ogniu (halved, DOWN).
*/
/** Odetchnięcie: at most twice between two Długie Odpoczynki. */
export const MAX_BREATHERS_BETWEEN_LONG_RESTS = 2;
/** Pożyczanie Many (Wyczerpanie Many) – tier table, N d6 summed. */
export const MANA_EXHAUSTION_TIERS = [
{ min: 1, max: 4, id: 'zdyszanie', label: 'Zdyszanie' },
{ min: 5, max: 8, id: 'zmeczenie', label: 'Zmęczenie' },
{ min: 9, max: 12, id: 'wyczerpanie', label: 'Wyczerpanie' },
{ min: 13, max: 16, id: 'zalamanie-sil', label: 'Załamanie Sił' },
{ min: 17, max: 20, id: 'zapasc', label: 'Zapaść' },
{ min: 21, max: 24, id: 'ciezka-zapasc', label: 'Ciężka Zapaść' },
{ min: 25, max: Infinity, id: 'wypalenie', label: 'Wypalenie' },
] as const;
export function manaExhaustionTier(total: number) {
return MANA_EXHAUSTION_TIERS.find((t) => total >= t.min && total <= t.max) ?? MANA_EXHAUSTION_TIERS[0];
}
/** Mana beyond the remaining pool that has to be "borrowed" for a cast. */
export function manaToBorrow(cost: number, poolRemaining: number): number {
return Math.max(0, cost - Math.max(0, poolRemaining));
}
/** Number of d6 for the Wyczerpanie Many roll: cumulative borrowed mana incl. this cast. */
export function exhaustionDice(borrowedSoFar: number, borrowedNow: number): number {
return Math.max(0, borrowedSoFar) + Math.max(0, borrowedNow);
}
/** Over-limit overcast: T by base cost, S = E(E+1)/2. */
export function overLimitTest(baseCost: number, excess: number): { threshold: number; successes: number } {
const threshold = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6;
return { threshold, successes: (excess * (excess + 1)) / 2 };
}
/** Nadczarowanie bonus multiplier: levels 1–2 full, level 3+ halved DOWN, min 1. */
export function overcastBonus(level: number, perLevel: number): number {
if (level <= 0) return 0;
if (level <= 2) return perLevel * level;
const full = perLevel * 2;
const tapered = Math.max(1, Math.floor(perLevel / 2));
return full + tapered * (level - 2);
}
/** Halve a rolled dice-bonus result for level 3+ overcast (DOWN, min 1). */
export function taperedDiceBonus(rolled: number): number {
return Math.max(1, Math.floor(rolled / 2));
}
/** Zasada Potężniejszych Zaklęć: nadwyżka = caster's Sukcesy − S (min 0). */
export function spellSurplus(casterSuccesses: number, required: number): number {
return Math.max(0, casterSuccesses - required);
}
export const defenderSuccessesNeeded = (surplus: number) => 1 + surplus;
export const dispelSuccessesNeeded = (surplus: number, indestructible = false) =>
2 + surplus + (indestructible ? 1 : 0);
/** Oszołomiony trigger: one hit costs at least half max Żywotność (after reductions). */
export function triggersStun(healthLost: number, maxHealth: number): boolean {
return maxHealth > 0 && healthLost > 0 && healthLost * 2 >= maxHealth;
}
/** Koncentracja test: Dusza (Determinacja) 4:S, S = ⌈⅓ lost⌉, min 1. None if nothing lost. */
export function concentrationTest(healthLost: number): { threshold: number; successes: number } | null {
if (healthLost <= 0) return null;
return { threshold: 4, successes: Math.max(1, Math.ceil(healthLost / 3)) };
}
/** Unik: positive difference means dodge failed; damage uses difference + 2. */
export function dodgeResult(attackerSuccesses: number, defenderSuccesses: number) {
const diff = attackerSuccesses - defenderSuccesses;
return { hit: diff > 0, damageSuccesses: diff > 0 ? diff + 2 : 0 };
}
/** Parowanie (T−1): parried when parrier ≥ attacker − ⌈½ Walka Wręcz⌉. */
export function parrySucceeds(parrierSuccesses: number, attackerSuccesses: number, melee: number): boolean {
return parrierSuccesses >= attackerSuccesses - Math.ceil(melee / 2);
}
/** Przewrócony: standing up costs half Ruch (rounded up). */
export function standUpCost(movement: number): number {
return Math.ceil(movement / 2);
}
/** Spowolniony / Oszołomiony: Ruch halved, rounded up. */
export function halvedMovement(movement: number): number {
return Math.ceil(movement / 2);
}
/** Szarża requires a straight Ruch of ≥ 4 m started outside the target's reach. */
export const CHARGE_MIN_DISTANCE_M = 4;
/** Immediate Actions: 1 free per round; 1 Zapał buys two extra. Returns Zapał cost of the n-th action (1-based). */
export function immediateActionZealCost(nth: number): number {
if (nth <= 1) return 0;
const extra = nth - 1;
return Math.ceil(extra / 2);
}
/** Total Zapał for n Immediate Actions in one round. */
export function immediateActionsZealTotal(n: number): number {
return n <= 1 ? 0 : Math.ceil((n - 1) / 2);
}
/** Źródło Mocy mana-passing test: T by distance 4/5/6 for <1/<2/<3 m. */
export function manaPassThreshold(distanceM: number): number | null {
if (distanceM < 1) return 4;
if (distanceM < 2) return 5;
if (distanceM < 3) return 6;
return null;
}
/** Pomoc: regular mage contributes at most ⌈½ own Maksymalny Koszt⌉. */
export function helpContributionCap(maxPerSpell: number, manaSource = false): number {
return manaSource ? maxPerSpell : Math.ceil(maxPerSpell / 2);
}
/** Wchłanianie Krwi: HP per Krew (2 base, 3 for Większe). */
export function bloodAbsorbHp(blood: number, greater = false): number {
return Math.max(0, blood) * (greater ? 3 : 2);
}
/** Większe Wchłanianie Krwi: 1 Zapał per 5 Krew. */
export function bloodToZeal(blood: number): number {
return Math.floor(Math.max(0, blood) / 5);
}
/**
* Runiczny Pancerz – points disabled by ONE hit (hits are never added together).
* Default: a hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point.
* Shield-breaking effects (`step` 2): every 2 damage disable 1 point.
* Disabled points return at the end of the fight or when restored; only Rozerwanie Run
* destroys a point permanently.
*/
export function runicArmorPointsDisabled(damage: number, step?: number): number {
const d = Math.max(0, Math.floor(damage));
if (step && step > 0 && step !== DEFAULT_RUNIC_THRESHOLD) return Math.floor(d / step);
return d > DEFAULT_RUNIC_THRESHOLD ? 1 : 0;
}
export const DEFAULT_RUNIC_THRESHOLD = 5;
/** Damage profile of spells that treat the magical defences non-standardly (by spell slug). */
export interface SpellDamageProfile {
ignoreMagicalArmor?: boolean;
ignoreMagicalShield?: boolean;
ignorePhysicalArmor?: boolean;
/** 2 → every 2 (instead of 5) damage disable a Runiczny Pancerz point. */
runicArmorStep?: number;
/** Damage counts this many times against Magiczna Osłona. */
shieldMultiplier?: number;
/** Fully destroys Magiczne Tarcze and disables all Runiczny Pancerz (Prawdziwa Dezintegracja). */
destroyMagicalDefenses?: boolean;
}
const SHIELD_BREAKER: SpellDamageProfile = { runicArmorStep: 2, shieldMultiplier: 2 };
export const SPELL_DAMAGE_PROFILES: Readonly<Record<string, SpellDamageProfile>> = {
'ether-pierce': { ...SHIELD_BREAKER, ignorePhysicalArmor: true },
'divine-spear': { ...SHIELD_BREAKER, ignorePhysicalArmor: true },
'energy-wave': SHIELD_BREAKER,
'mysterious-stream': SHIELD_BREAKER,
'arrow-of-desire': SHIELD_BREAKER,
'last-judgment': SHIELD_BREAKER,
'crimson-bolt': { ignoreMagicalArmor: true, ignoreMagicalShield: true },
'true-disintegration': { ignorePhysicalArmor: true, destroyMagicalDefenses: true },
};
/** Krew – sources (Arcanum Sanguinis). */
export type KrewTalent = 'respect-for-life' | 'krwisty-pojedynkowicz' | 'rytualista-krwi';
/** Samookaleczenie: lose HP = Sukcesy; 1 Krew per HP (2 with Większe Samookaleczenie). */
export function krewFromSelfHarm(hpLost: number, greater = false): number {
return Math.max(0, hpLost) * (greater ? 2 : 1);
}
/** Kradzież Życia: 1 Krew per HP the target actually lost (2 with the greater version). */
export function krewFromLifeSteal(hpLost: number, greater = false): number {
return Math.max(0, hpLost) * (greater ? 2 : 1);
}
/** Złożenie w Ofierze: heal Sukcesy + Magia, gain Krew = Sukcesy. */
export function sacrificeResult(successes: number, magic: number): { heal: number; krew: number } {
return { heal: Math.max(0, successes) + Math.max(0, magic), krew: Math.max(0, successes) };
}
/**
* Wielka Ofiara: Sukcesy above the target's highest Atrybut kill it (all its Żywotność is lost),
* otherwise the mage heals Sukcesy. Krew gained = Żywotność the target lost.
*/
export function greatSacrificeResult(successes: number, highestAttribute: number, targetHealth: number): { kills: boolean; heal: number; krew: number } {
if (successes > highestAttribute) return { kills: true, heal: 0, krew: Math.max(0, targetHealth) };
return { kills: false, heal: Math.max(0, successes), krew: 0 };
}
/** Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (slashing/piercing only). */
export function krewFromWeaponHpLoss(hpLost: number, talent: KrewTalent | null): number {
if (talent === 'respect-for-life') return Math.floor(Math.max(0, hpLost) / 4);
if (talent === 'krwisty-pojedynkowicz') return Math.floor(Math.max(0, hpLost) / 2);
return 0;
}
/** Krew at the start of a fight: Rytualista Krwi 4, everyone else 0. */
export function startingKrew(talent: KrewTalent | null): number {
return talent === 'rytualista-krwi' ? 4 : 0;
}
/** Runiczny Pancerz (Amulet Osłony): recharging costs 2 Mana per point, as an Akcja Natychmiastowa. */
export const RUNIC_ARMOR_MANA_PER_POINT = 2;
/** Points of Runiczny Pancerz that can actually be restored (never above max, never negative). */
export function runicArmorRestorable(value: number, max: number, requested: number): number {
return Math.max(0, Math.min(Math.floor(requested), max - value));
}
/** Mana needed to restore `points` points of Runiczny Pancerz. */
export function runicArmorManaCost(points: number): number {
return Math.max(0, points) * RUNIC_ARMOR_MANA_PER_POINT;
}
/** Eliksir Odnowienia Many: +3 mana (capped by the pool); quick use = 1d3 as Immediate Action. */
export const MANA_ELIXIR_NORMAL = 3;
export const MANA_ELIXIR_QUICK_DIE = 3;
export function manaElixirGain(current: number, max: number, amount: number): number {
return Math.max(0, Math.min(amount, max - current));
}
/** Magiczny Pocisk (and similar): damage that reaches Żywotność after Pancerz is at least 1. */
export function minOneAfterArmor(damage: number, armor: number): number {
return Math.max(1, damage - armor);
}
/** Trade (Złoto, Stal i Magia). */
export const TRADE = {
companyFee: 5000,
companyTest: { threshold: 4, successes: 3 },
bribeTiers: [
{ rate: 0.02, discount: 0.1 },
{ rate: 0.05, discount: 0.2 },
{ rate: 0.08, discount: 0.3 },
],
minBribe: 500,
thresholdFloor: 2,
negotiationSuccessesPerStep: 4,
minigameFailureCost: 0.1,
/** Unconfirmed (brief §16.3) – keep configurable. */
eurRate: 4.36,
} as const;
/** Negotiation bands for the 4:1 test: successes → step (1–2, 3–4, 5–6, 7+). */
export function negotiationBand(successes: number): number {
if (successes <= 0) return 0;
return Math.min(4, Math.ceil(successes / 2));
}
/** Przerażony: modifier on tests aimed at the fear source (T+1), 0 otherwise. */
export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
return sourceId && targetId && sourceId === targetId ? 1 : 0;
}
/** Casting test pool with the player's +/- dice modifier (whole dice, never below 0). */
export function applyDiceModifier(pool: number, modifier?: number): number {
return Math.max(0, Math.trunc(Number(pool) || 0) + Math.trunc(Number(modifier) || 0));
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Runiczny Pancerz – a single hit of more than 5 damage disables 1 point (logic/damage.ts); disabled
* points return at the end of the fight or can be restored as an Akcja Natychmiastowa. The cost
* depends on the source: Amulet Osłony takes 2 Many per point. Only Rozerwanie Run destroys a point
* permanently (it lowers `magicalArmor.max`).
*/
import type { CastableActor } from './spell-cast';
import { runicArmorManaCost, runicArmorRestorable } from './rules';
/** End of fight: every disabled point returns. */
export async function restoreAllRunicArmor(actor: CastableActor): Promise<number> {
const armor = (actor.system.attributes as any).magicalArmor as { value: number; max: number } | undefined;
if (!armor || armor.value >= armor.max) return 0;
await actor.update({ 'system.attributes.magicalArmor.value': armor.max });
return armor.max - armor.value;
}
export interface RunicRestoreResult {
ok: boolean;
restored: number;
manaSpent: number;
reason?: string;
}
/**
* Spend Mana to restore up to `points` points of Runiczny Pancerz (capped at its max).
* Fails with `insufficient-mana` when the pool cannot pay for every requested point;
* pass `partial: true` to restore as many points as the pool affords instead.
*/
export async function rechargeRunicArmor(
actor: CastableActor,
points: number,
opts: { partial?: boolean } = {},
): Promise<RunicRestoreResult> {
const a = actor.system.attributes as any;
const armor = a.magicalArmor as { value: number; max: number } | undefined;
if (!armor) return { ok: false, restored: 0, manaSpent: 0, reason: 'no-runic-armor' };
let restored = runicArmorRestorable(armor.value, armor.max, points);
if (restored <= 0) return { ok: false, restored: 0, manaSpent: 0, reason: 'already-full' };
const mana = Number(a.mana?.value) || 0;
if (runicArmorManaCost(restored) > mana) {
if (!opts.partial) return { ok: false, restored: 0, manaSpent: 0, reason: 'insufficient-mana' };
restored = runicArmorRestorable(armor.value, armor.max, Math.floor(mana / runicArmorManaCost(1)));
if (restored <= 0) return { ok: false, restored: 0, manaSpent: 0, reason: 'insufficient-mana' };
}
const manaSpent = runicArmorManaCost(restored);
await actor.update({
'system.attributes.magicalArmor.value': armor.value + restored,
'system.attributes.mana.value': mana - manaSpent,
});
return { ok: true, restored, manaSpent };
}
+101 -23
View File
@@ -16,6 +16,7 @@
import { HbmTSRoll } from '../dice/ts-roll'; import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants'; import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, applyDiceModifier, SPELL_DAMAGE_PROFILES } from './rules';
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';
@@ -75,11 +76,19 @@ export interface CastOptions {
bypassSuperspellWarning?: boolean; bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean; bypassNonCombatBlock?: boolean;
groupCasters?: string[]; groupCasters?: string[];
/** Modifier to the number of dice in the casting test (+/-, pool never below 0). */
diceModifier?: number;
/** Override required successes (used by variableSuccesses summons). */ /** Override required successes (used by variableSuccesses summons). */
requiredOverride?: number; requiredOverride?: number;
speaker?: ChatMessage.SpeakerData; speaker?: ChatMessage.SpeakerData;
} }
/** A roll made during a cast; rendered inline in the cast card. */
interface CastRollEntry {
label: string;
roll: HbmTSRoll;
}
export interface CastResult { export interface CastResult {
validation: ValidationResult; validation: ValidationResult;
baseRoll: HbmTSRoll | null; baseRoll: HbmTSRoll | null;
@@ -92,6 +101,16 @@ function notify(text: string, severity: 'warn' | 'error' | 'info' = 'info'): voi
u.notifications?.[severity](text); u.notifications?.[severity](text);
} }
/** Queue a roll for the cast card and play the 3D dice animation if available. */
async function recordRoll(rolls: CastRollEntry[], roll: HbmTSRoll, label: string): Promise<void> {
rolls.push({ label, roll });
try {
await (game as any).dice3d?.showForRoll?.(roll, game.user, true);
} catch {
// Dice So Nice is optional; the card still shows the faces.
}
}
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> { export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> {
// 1. Validate // 1. Validate
const validation = validateCast(actor, spell, opts); const validation = validateCast(actor, spell, opts);
@@ -108,16 +127,17 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
// 2-3. Mode dispatch // 2-3. Mode dispatch
const mode = spell.system.castingMode ?? 'standard'; const mode = spell.system.castingMode ?? 'standard';
let baseRoll: HbmTSRoll | null = null; let baseRoll: HbmTSRoll | null = null;
const rolls: CastRollEntry[] = [];
if (mode === 'sacred') { if (mode === 'sacred') {
baseRoll = opts.hekateMode === 'witch' baseRoll = opts.hekateMode === 'witch'
? await castWitch(actor, spell, opts) ? await castWitch(actor, spell, opts, rolls)
: await castSacred(actor, spell, opts); : await castSacred(actor, spell, opts, rolls);
} else if (mode === 'witch') { } else if (mode === 'witch') {
baseRoll = await castWitch(actor, spell, opts); baseRoll = await castWitch(actor, spell, opts, rolls);
} else if (mode === 'blood') { } else if (mode === 'blood') {
baseRoll = await castBlood(actor, spell, opts); baseRoll = await castBlood(actor, spell, opts, rolls);
} else { } else {
baseRoll = await castStandard(actor, spell, opts); baseRoll = await castStandard(actor, spell, opts, rolls);
} }
let damageRoll: Roll | null = null; let damageRoll: Roll | null = null;
@@ -134,7 +154,7 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
} }
// Render unified rich chat card (Phase 1.6) // Render unified rich chat card (Phase 1.6)
await renderCastCard(actor, spell, opts, baseRoll, damageRoll, triggersRegistered); await renderCastCard(actor, spell, opts, baseRoll, damageRoll, triggersRegistered, rolls);
return { validation, baseRoll, damageRoll, triggersRegistered }; return { validation, baseRoll, damageRoll, triggersRegistered };
} }
@@ -146,6 +166,7 @@ async function renderCastCard(
baseRoll: HbmTSRoll | null, baseRoll: HbmTSRoll | null,
damageRoll: Roll | null, damageRoll: Roll | null,
triggersRegistered: number, triggersRegistered: number,
rolls: CastRollEntry[],
): Promise<void> { ): Promise<void> {
const success = baseRoll?.ts?.isSuccess ?? false; const success = baseRoll?.ts?.isSuccess ?? false;
const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard'); const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard');
@@ -173,8 +194,19 @@ async function renderCastCard(
damage = { damage = {
total: damageRoll.total, total: damageRoll.total,
formula: damageRoll.formula, formula: damageRoll.formula,
dice: ((damageRoll as any).dice ?? []).map((d: any) => ({
expression: d.expression,
results: (d.results ?? []).filter((r: any) => !r.discarded).map((r: any) => r.result),
})),
type: spell.system.damageType ?? 'magical', type: spell.system.damageType ?? 'magical',
ignoresArmor: !!spell.system.ignoresArmor, ignoresArmor: !!spell.system.ignoresArmor,
// Magiczny Pocisk: at least 1 damage reaches Żywotność after Pancerz.
minAfterArmor: ((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) === 'magic-missile',
// Shield-breakers / Szkarłatny Pocisk / Prawdziwa Dezintegracja treat the magical defences specially.
profile: (() => {
const p = SPELL_DAMAGE_PROFILES[((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) as string];
return p ? JSON.stringify(p) : '';
})(),
targets: targets.length > 0 ? targets : null, targets: targets.length > 0 ? targets : null,
}; };
} }
@@ -198,11 +230,16 @@ async function renderCastCard(
? spell.system.triggers ? spell.system.triggers
: []; : [];
const rollsHtml = await Promise.all(rolls.map(async (r) => ({
label: r.label,
html: await r.roll.render(),
})));
const data = { const data = {
spell: { name: spell.name }, spell: { name: spell.name },
rolls: rollsHtml,
mode, mode,
school: spell.system.school ?? '', school: spell.system.school ?? '',
circle: (spell.system as any).circle ?? 0,
outcome: { success }, outcome: { success },
costs, costs,
damage, damage,
@@ -216,27 +253,35 @@ async function renderCastCard(
await ChatMessage.create({ await ChatMessage.create({
content: html, content: html,
speaker: opts.speaker ?? ChatMessage.getSpeaker({ actor: actor as any }), speaker: opts.speaker ?? ChatMessage.getSpeaker({ actor: actor as any }),
rolls: [...rolls.map((r) => r.roll), ...(damageRoll ? [damageRoll] : [])].map((r) => r.toJSON()),
flags: { 'hbm-rpg-v3': { spellCast: { spellName: spell.name, success } } }, flags: { 'hbm-rpg-v3': { spellCast: { spellName: spell.name, success } } },
}); });
} }
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const baseCost = Math.max(0, spell.system.manaCost ?? 0); const baseCost = Math.max(0, spell.system.manaCost ?? 0);
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;
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - manaSpent }; // Casting beyond the remaining daily pool is allowed: the missing mana is
// "borrowed" and triggers a Wyczerpanie Many roll (N d6, N = cumulative
// borrowed mana since the last Długi Odpoczynek, incl. this cast).
const borrowed = manaToBorrow(manaSpent, a.mana.value);
const updates: Record<string, any> = { 'system.attributes.mana.value': Math.max(0, a.mana.value - manaSpent) };
if (borrowed > 0) updates['system.attributes.manaBorrowed'] = (a.manaBorrowed ?? 0) + borrowed;
if (opts.castAsPrayer) { if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1; updates['system.attributes.zeal.value'] = a.zeal.value - 1;
} }
await actor.update(updates); await actor.update(updates);
if (borrowed > 0) await rollManaExhaustion(actor, exhaustionDice(a.manaBorrowed ?? 0, borrowed));
const magicDice = typeof a.magic.dicePool === 'number' const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool ? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool; : getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name}`; : `${spell.name}`;
@@ -248,15 +293,14 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
flavor, flavor,
}); });
await baseRoll.evaluate(); await baseRoll.evaluate();
await baseRoll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, baseRoll, flavor);
// Overcast beyond Maksymalny Koszt Zaklęcia - extra TS test. // Overcast beyond Maksymalny Koszt Zaklęcia - extra TS test.
// T depends on the spell's BASE cost; S grows triangularly with the excess // T depends on the spell's BASE cost; S grows triangularly with the excess
// (E) over maxPerSpell: S = E * (E + 1) / 2 (Zasada Nadczarowywania Zaklęć). // (E) over maxPerSpell: S = E * (E + 1) / 2 (Zasada Nadczarowywania Zaklęć).
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 = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6; const { threshold: tThreshold, successes: ySuccesses } = overLimitTest(baseCost, excess);
const ySuccesses = (excess * (excess + 1)) / 2;
const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`; const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`;
const overcastRoll = HbmTSRoll.fromParams({ const overcastRoll = HbmTSRoll.fromParams({
pool, pool,
@@ -265,18 +309,50 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
flavor: overcastFlavor, flavor: overcastFlavor,
}); });
await overcastRoll.evaluate(); await overcastRoll.evaluate();
await overcastRoll.toMessage({ flavor: overcastFlavor, speaker: opts.speaker }); await recordRoll(rolls, overcastRoll, overcastFlavor);
// Failure → Kara za Nieudolność: N d6, N = missing Sukcesy.
const got = Number((overcastRoll as any).ts?.successes ?? 0);
if (got < ySuccesses) await rollIncompetencePenalty(actor, ySuccesses - got, baseCost);
} }
return baseRoll; return baseRoll;
} }
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { /** Wyczerpanie Many: roll N d6 and post the tier (tiers in Wyczerpanie Many.md). */
async function rollManaExhaustion(actor: CastableActor, dice: number): Promise<void> {
if (dice <= 0) return;
const roll = new Roll(`${dice}d6`);
await roll.evaluate();
const tier = manaExhaustionTier(roll.total ?? 0);
await ChatMessage.create({
content: `<strong>${(actor as any).name}</strong>: ${game.i18n.localize('HBM.spellCast.manaExhaustion')} (${dice}k6 = ${roll.total}) → <strong>${tier.label}</strong>`,
rolls: [roll.toJSON()],
speaker: ChatMessage.getSpeaker({ actor: actor as any }),
});
}
/** Kara za Nieudolność: N d6 where N = missing Sukcesy; tier 5–8 also drains the daily pool by the base cost. */
async function rollIncompetencePenalty(actor: CastableActor, missing: number, baseCost: number): Promise<void> {
const roll = new Roll(`${missing}d6`);
await roll.evaluate();
const total = roll.total ?? 0;
if (total >= 5 && total <= 8) {
const a = actor.system.attributes;
await actor.update({ 'system.attributes.mana.value': Math.max(0, a.mana.value - baseCost) });
}
await ChatMessage.create({
content: `<strong>${(actor as any).name}</strong>: ${game.i18n.localize('HBM.spellCast.incompetencePenalty')} (${missing}k6 = ${total})`,
rolls: [roll.toJSON()],
speaker: ChatMessage.getSpeaker({ actor: actor as any }),
});
}
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; const a = actor.system.attributes;
const zealCost = Math.max(1, opts.zealSpent ?? 1); const zealCost = Math.max(1, opts.zealSpent ?? 1);
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 = applyDiceModifier(a.soul.value + (actor.system.skills?.devotion?.value ?? 0), opts.diceModifier);
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({
@@ -286,11 +362,11 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
flavor, flavor,
}); });
await roll.evaluate(); await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, roll, flavor);
return roll; return roll;
} }
async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; const a = actor.system.attributes;
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0); const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
const maxSymbols = Math.ceil(a.magic.actual / 2); const maxSymbols = Math.ceil(a.magic.actual / 2);
@@ -306,9 +382,10 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool; : getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`; : `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
@@ -320,11 +397,11 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
flavor, flavor,
}); });
await roll.evaluate(); await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, roll, flavor);
return roll; return roll;
} }
async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> { async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
const a = actor.system.attributes; const a = actor.system.attributes;
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));
@@ -348,9 +425,10 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool; : getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`; : `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
@@ -362,6 +440,6 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
flavor, flavor,
}); });
await roll.evaluate(); await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker }); await recordRoll(rolls, roll, flavor);
return roll; return roll;
} }
+1 -1
View File
@@ -17,7 +17,7 @@
* unless they map to a known machine effect ID. * unless they map to a known machine effect ID.
*/ */
import { SYSTEM_ID } from '../hbm'; import { SYSTEM_ID } from '../constants';
export interface SpellTrigger { export interface SpellTrigger {
event: string; event: string;
+2 -1
View File
@@ -100,7 +100,8 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) { if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) {
if (manaSpent > Number(a.mana?.value ?? 0)) { if (manaSpent > Number(a.mana?.value ?? 0)) {
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' }); // Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many).
warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' });
} }
} }
if (opts.castAsPrayer) { if (opts.castAsPrayer) {
+1
View File
@@ -63,3 +63,4 @@ export async function smuggling(actor: CastableActor, params: TradeTestParams =
} }
return roll; return roll;
} }
export { TRADE, negotiationBand } from './rules';
+1 -1
View File
@@ -12,7 +12,7 @@
* (DataModel default usually handles this; explicit safety net). * (DataModel default usually handles this; explicit safety net).
*/ */
import { SYSTEM_ID } from '../hbm'; import { SYSTEM_ID } from '../constants';
import type { MigrationStep } from './index'; import type { MigrationStep } from './index';
interface LegacySpellSystem { interface LegacySpellSystem {
+53
View File
@@ -0,0 +1,53 @@
/**
* Migration to v1.4.0 (vault sync 2026-10-20).
*
* Operations:
* - Backfill the new per-rest / per-round counters
* (`manaBorrowed`, `breathersTaken`, `immediateActionsUsed`) on characters and NPCs.
* - Rename the slug flag of embedded Zeus spells "aegis" → "aegis-zeus"
* (the general Aegis spell keeps "aegis").
*
* Magic power levels III/IV swapped their daily mana (24/21) in constants only;
* stored levels are unchanged and mana.max is derived, so no data change is needed.
*/
import type { MigrationStep } from './index';
const FLAG_SCOPE = 'hbm-rpg-v3';
async function backfillCounters(): Promise<void> {
for (const a of (game.actors?.contents ?? []) as any[]) {
const attrs = a.system?.attributes;
if (!attrs) continue;
const update: Record<string, unknown> = {};
if (attrs.manaBorrowed == null) update['system.attributes.manaBorrowed'] = 0;
if (attrs.immediateActionsUsed == null) update['system.attributes.immediateActionsUsed'] = 0;
if (a.type === 'character' && attrs.breathersTaken == null) update['system.attributes.breathersTaken'] = 0;
if (Object.keys(update).length > 0) await a.update(update);
}
}
function isZeusAegis(item: any): boolean {
return item?.type === 'spell'
&& item.flags?.[FLAG_SCOPE]?.slug === 'aegis'
&& item.system?.deity === 'zeus';
}
async function renameZeusAegis(): Promise<void> {
const fix = async (item: any) => {
if (isZeusAegis(item)) await item.update({ [`flags.${FLAG_SCOPE}.slug`]: 'aegis-zeus' });
};
for (const it of (game.items?.contents ?? []) as any[]) await fix(it);
for (const a of (game.actors?.contents ?? []) as any[]) {
for (const it of a.items ?? []) await fix(it);
}
}
export const migration_1_4_0: MigrationStep = {
version: '1.4.0',
description: 'v1.4.0: borrowed-mana / breather / immediate-action counters, Zeus Aegis slug',
async run() {
await backfillCounters();
await renameZeusAegis();
},
};
+3 -1
View File
@@ -6,9 +6,10 @@
* (semver-style string). Migrations are idempotent. * (semver-style string). Migrations are idempotent.
*/ */
import { SYSTEM_ID } from '../hbm'; import { SYSTEM_ID } from '../constants';
import { migration_0_2_0 } from './0.2.0'; import { migration_0_2_0 } from './0.2.0';
import { migration_1_2_0 } from './1.2.0'; import { migration_1_2_0 } from './1.2.0';
import { migration_1_4_0 } from './1.4.0';
export interface MigrationStep { export interface MigrationStep {
version: string; // version this migration brings the world TO version: string; // version this migration brings the world TO
@@ -19,6 +20,7 @@ export interface MigrationStep {
const MIGRATIONS: MigrationStep[] = [ const MIGRATIONS: MigrationStep[] = [
migration_0_2_0, migration_0_2_0,
migration_1_2_0, migration_1_2_0,
migration_1_4_0,
]; ];
function compareSemver(a: string, b: string): number { function compareSemver(a: string, b: string): number {
+1 -1
View File
@@ -78,7 +78,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
.map(([school, items]) => ({ .map(([school, items]) => ({
school, school,
label: game.i18n.localize(`HBM.spellSchool.${school}`), label: game.i18n.localize(`HBM.spellSchool.${school}`),
items: items.sort((a: any, b: any) => (a.system?.circle ?? 0) - (b.system?.circle ?? 0)), items: items.sort((a: any, b: any) => a.name.localeCompare(b.name, 'pl')),
})) }))
.sort((a, b) => a.label.localeCompare(b.label, 'pl')); .sort((a, b) => a.label.localeCompare(b.label, 'pl'));
+2
View File
@@ -9,6 +9,7 @@ import {
AOE_SHAPES, AOE_SHAPES,
SPELL_DAMAGE_TYPES, SPELL_DAMAGE_TYPES,
TRIGGER_EVENTS, TRIGGER_EVENTS,
WOUND_KINDS,
} from '../constants'; } from '../constants';
const { ItemSheetV2 } = foundry.applications.sheets as unknown as { const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
@@ -67,6 +68,7 @@ export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
itemType: item.type, itemType: item.type,
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs', bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
choices: buildChoiceMaps(), choices: buildChoiceMaps(),
woundChoices: Object.fromEntries(WOUND_KINDS.map((k) => [k, `HBM.gear.wound.${k}`])),
effects, effects,
supportsEffects, supportsEffects,
}; };
+5 -30
View File
@@ -1,3 +1,5 @@
import { fetchNbpRate, FALLBACK_RATES } from '../logic/nbp';
const { DialogV2 } = foundry.applications.api as unknown as { const { DialogV2 } = foundry.applications.api as unknown as {
DialogV2: any; DialogV2: any;
}; };
@@ -55,37 +57,10 @@ class MoneyConverterDialog extends DialogV2 {
export async function showMoneyConverter(actor: any): Promise<void> { export async function showMoneyConverter(actor: any): Promise<void> {
const pln = actor.system.details?.money ?? 0; const pln = actor.system.details?.money ?? 0;
const currentYear = actor.system.details?.currentYear ?? 2026; const currentYear = actor.system.details?.currentYear ?? 2026;
const currentRealYear = new Date().getFullYear();
const fetchNBP = async (currency: string, year: number): Promise<{ rate: number; date: string } | null> => { const [eurData, usdData] = await Promise.all([fetchNbpRate('eur', currentYear), fetchNbpRate('usd', currentYear)]);
if (year >= 2002 && year <= currentRealYear) { const eurRate = eurData?.rate ?? FALLBACK_RATES.eur!;
for (let day = 1; day <= 7; day++) { const usdRate = usdData?.rate ?? FALLBACK_RATES.usd!;
const d = `${year}-06-${String(day).padStart(2, '0')}`;
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/${d}/?format=json`);
if (res.ok) {
const data = await res.json();
const r = data?.rates?.[0]?.mid;
if (typeof r === 'number') return { rate: r, date: d };
}
} catch (_) { /* ignore */ }
}
}
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/?format=json`);
if (res.ok) {
const data = await res.json();
const r = data?.rates?.[0]?.mid;
const dt = data?.rates?.[0]?.effectiveDate || '';
if (typeof r === 'number') return { rate: r, date: dt };
}
} catch (_) { /* ignore */ }
return null;
};
const [eurData, usdData] = await Promise.all([fetchNBP('eur', currentYear), fetchNBP('usd', currentYear)]);
const eurRate = eurData?.rate ?? 4.35;
const usdRate = usdData?.rate ?? 4.00;
const rateDate = eurData?.date ?? usdData?.date ?? 'default'; const rateDate = eurData?.date ?? usdData?.date ?? 'default';
const isLive = !!(eurData || usdData); const isLive = !!(eurData || usdData);
+1 -1
View File
@@ -49,7 +49,7 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
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: any; items: any[] } }).actor; const actor = (this as unknown as { actor: { system: any; items: any[] } }).actor;
const spells = actor.items.filter((it: any) => it.type === 'spell') const spells = actor.items.filter((it: any) => it.type === 'spell')
.sort((a: any, b: any) => (a.system?.circle ?? 0) - (b.system?.circle ?? 0) || a.name.localeCompare(b.name, 'pl')); .sort((a: any, b: any) => a.name.localeCompare(b.name, 'pl'));
const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({ const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({
id: ef.id, id: ef.id,
name: ef.name, name: ef.name,
+4
View File
@@ -600,6 +600,10 @@
.hbm.spell-cast-card section { margin: .35rem 0; } .hbm.spell-cast-card section { margin: .35rem 0; }
.hbm.spell-cast-card .damage-line { display: flex; gap: .35rem; align-items: baseline; font-size: 1.2em; } .hbm.spell-cast-card .damage-line { display: flex; gap: .35rem; align-items: baseline; font-size: 1.2em; }
.hbm.spell-cast-card .damage-formula code { font-size: .8em; color: #555; } .hbm.spell-cast-card .damage-formula code { font-size: .8em; color: #555; }
.hbm.spell-cast-card .hbm-card-roll { margin: .35rem 0; padding: .25rem; border: 1px solid rgba(107,63,160,.25); border-radius: 4px; }
.hbm.spell-cast-card .damage-dice { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .15rem; }
.hbm.spell-cast-card .damage-die-group { display: inline-flex; gap: .2rem; align-items: center; }
.hbm.spell-cast-card .damage-die-group .die { display: inline-flex; align-items: center; justify-content: center; min-width: 1.6rem; height: 1.6rem; border-radius: 4px; font-weight: bold; background: #ddd; color: #333; }
.hbm.spell-cast-card .hbm-card-targets, .hbm.spell-cast-card .status-buttons { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .25rem; } .hbm.spell-cast-card .hbm-card-targets, .hbm.spell-cast-card .status-buttons { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .25rem; }
.hbm.spell-cast-card button.hbm-action { font-size: .85em; padding: 2px 8px; border-radius: 4px; cursor: pointer; } .hbm.spell-cast-card button.hbm-action { font-size: .85em; padding: 2px 8px; border-radius: 4px; cursor: pointer; }
.hbm.spell-cast-card .hbm-card-hint { font-size: .8em; color: #777; margin: .15rem 0; font-style: italic; } .hbm.spell-cast-card .hbm-card-hint { font-size: .8em; color: #777; margin: .15rem 0; font-style: italic; }
+2 -2
View File
@@ -2,9 +2,9 @@
"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.37", "version": "1.5.1",
"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.37.zip", "download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip",
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "14" "verified": "14"
+2 -3
View File
@@ -275,14 +275,13 @@
title="Wymagana rasa">🔒</span>{{/if}} title="Wymagana rasa">🔒</span>{{/if}}
</span> </span>
<span class="item-meta"> <span class="item-meta">
K{{spell.system.circle}} {{localize (concat 'HBM.spell.castingMode.' spell.system.castingMode)}}
· {{localize (concat 'HBM.spell.castingMode.' spell.system.castingMode)}}
· {{localize 'HBM.resources.mana'}}: {{spell.system.manaCost}} · {{localize 'HBM.resources.mana'}}: {{spell.system.manaCost}}
{{#if (eq spell.system.castingMode 'sacred')}}{{#if spell.system.deity}} · {{#if (eq spell.system.castingMode 'sacred')}}{{#if spell.system.deity}} ·
{{spell.system.deity}}{{/if}}{{/if}} {{spell.system.deity}}{{/if}}{{/if}}
{{#if (eq spell.system.castingMode 'blood')}} · krew: {{spell.system.bloodCost}}{{/if}} {{#if (eq spell.system.castingMode 'blood')}} · krew: {{spell.system.bloodCost}}{{/if}}
{{#if spell.system.sourceBook}} · <span {{#if spell.system.sourceBook}} · <span
class="source-pip">{{spell.system.sourceBook}}</span>{{/if}} class="source-pip">{{localize (concat 'HBM.sourceBook.' spell.system.sourceBook)}}</span>{{/if}}
</span> </span>
<div class="item-controls"> <div class="item-controls">
<button type="button" class="cast-btn" data-action="castSpell" data-item-id="{{spell.id}}" <button type="button" class="cast-btn" data-action="castSpell" data-item-id="{{spell.id}}"
+1 -2
View File
@@ -206,8 +206,7 @@
<div class="row spell-row"> <div class="row spell-row">
<span class="badge mode mode-{{sp.system.castingMode}}" <span class="badge mode mode-{{sp.system.castingMode}}"
title="{{localize (concat 'HBM.spell.castingMode.' sp.system.castingMode)}}">{{sp.system.castingMode}}</span> title="{{localize (concat 'HBM.spell.castingMode.' sp.system.castingMode)}}">{{sp.system.castingMode}}</span>
<span class="spell-name"><strong>{{sp.name}}</strong>{{#if sp.system.circle}} <span class="spell-name"><strong>{{sp.name}}</strong></span>
<small>K{{sp.system.circle}}</small>{{/if}}</span>
<button type="button" data-action="castSpell" data-item-id="{{sp.id}}" <button type="button" data-action="castSpell" data-item-id="{{sp.id}}"
title="{{localize 'HBM.spell.cast'}}">✦ {{localize 'HBM.spell.cast'}}</button> title="{{localize 'HBM.spell.cast'}}">✦ {{localize 'HBM.spell.cast'}}</button>
<button type="button" data-action="editItem" data-item-id="{{sp.id}}" <button type="button" data-action="editItem" data-item-id="{{sp.id}}"
+16 -2
View File
@@ -4,7 +4,6 @@
<div class="hbm-card-meta"> <div class="hbm-card-meta">
{{#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 (or (eq mode "sacred") (eq mode "prayer"))}} {{#if (or (eq mode "sacred") (eq mode "prayer"))}}
<span class="badge warning">{{localize 'HBM.spellCast.cannotBeUnraveled'}}</span> <span class="badge warning">{{localize 'HBM.spellCast.cannotBeUnraveled'}}</span>
{{/if}} {{/if}}
@@ -24,6 +23,12 @@
</ul> </ul>
{{/if}} {{/if}}
{{#each rolls as |r|}}
<section class="hbm-card-roll">
{{{r.html}}}
</section>
{{/each}}
{{#if outcome.success}} {{#if outcome.success}}
{{#if damage}} {{#if damage}}
<section class="hbm-card-damage"> <section class="hbm-card-damage">
@@ -34,10 +39,19 @@
{{#if damage.ignoresArmor}}<span class="badge ignores-armor">{{localize 'HBM.spell.ignoresArmor'}}</span>{{/if}} {{#if damage.ignoresArmor}}<span class="badge ignores-armor">{{localize 'HBM.spell.ignoresArmor'}}</span>{{/if}}
</div> </div>
<div class="damage-formula"><code>{{damage.formula}}</code></div> <div class="damage-formula"><code>{{damage.formula}}</code></div>
{{#if damage.dice.length}}
<div class="damage-dice">
{{#each damage.dice as |d|}}
<span class="damage-die-group"><small>{{d.expression}}:</small>
{{#each d.results as |face|}}<span class="die">{{face}}</span>{{/each}}
</span>
{{/each}}
</div>
{{/if}}
{{#if damage.targets}} {{#if damage.targets}}
<div class="hbm-card-targets"> <div class="hbm-card-targets">
{{#each damage.targets as |t|}} {{#each damage.targets as |t|}}
<button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}"> <button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}" data-min-after-armor="{{../damage.minAfterArmor}}" data-profile="{{../damage.profile}}">
⚔ {{t.name}} ⚔ {{t.name}}
</button> </button>
{{/each}} {{/each}}
+5
View File
@@ -31,6 +31,11 @@
<option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option> <option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option>
</select> </select>
</label> </label>
<label title="{{localize 'HBM.gear.woundKindsHint'}}">{{localize 'HBM.gear.woundKinds'}}:
<multi-select name="system.weapon.woundKinds">
{{selectOptions woundChoices selected=system.weapon.woundKinds localize=true}}
</multi-select>
</label>
</fieldset> </fieldset>
{{/if}} {{/if}}
-3
View File
@@ -4,9 +4,6 @@
<fieldset> <fieldset>
<legend>{{localize 'HBM.spell.label'}}</legend> <legend>{{localize 'HBM.spell.label'}}</legend>
<div class="row"> <div class="row">
<label>{{localize 'HBM.spell.circle'}}:
<input type="number" name="system.circle" value="{{system.circle}}" min="0" max="9" />
</label>
<label>{{localize 'HBM.spell.school'}}: <label>{{localize 'HBM.spell.school'}}:
<select name="system.school"> <select name="system.school">
{{#each @root.choices.schools as |label key|}} {{#each @root.choices.schools as |label key|}}
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi } from 'vitest';
import {
absorbBlood,
endOfFightKrew,
gainKrewFromWeaponDamage,
greatSacrifice,
resolveLifeSteal,
sacrifice,
selfHarm,
startOfFightKrew,
useLifeSteal,
} from '../../src/logic/blood-magic';
import { krewFromWeaponHpLoss, runicArmorPointsDisabled, startingKrew } from '../../src/logic/rules';
function makeActor(opts: { slugs?: string[]; blood?: number; hp?: number; zeal?: number; used?: Record<string, number> } = {}) {
const flags: Record<string, unknown> = {};
const actor: any = {
system: {
attributes: {
blood: { value: opts.blood ?? 0, max: 20 },
health: { value: opts.hp ?? 20, max: 30 },
zeal: { value: opts.zeal ?? 3, max: 5 },
magic: { value: 3, actual: 3 },
bloodUsed: { selfHarm: 0, lifeSteal: 0, sacrifice: 0, absorb: 0, ...(opts.used ?? {}) },
greatSacrificeUsed: 0,
},
},
items: (opts.slugs ?? []).map((slug) => ({ type: 'talent', system: { slug } })),
update: vi.fn(async () => undefined),
setFlag: vi.fn(async (_s: string, k: string, v: unknown) => { flags[k] = v; }),
getFlag: vi.fn((_s: string, k: string) => flags[k]),
unsetFlag: vi.fn(async (_s: string, k: string) => { delete flags[k]; }),
};
return actor;
}
describe('Samookaleczenie', () => {
it('loses HP = Sukcesy and gains 1 Krew per HP, once per round', async () => {
const actor = makeActor();
const res = await selfHarm(actor, { roller: async () => 3 });
expect(res).toMatchObject({ ok: true, hpLost: 3, krewGained: 3 });
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({
'system.attributes.health.value': 17,
'system.attributes.blood.value': 3,
'system.attributes.bloodUsed.selfHarm': 1,
}));
const again = makeActor({ used: { selfHarm: 1 } });
expect((await selfHarm(again, { roller: async () => 3 })).reason).toBe('already-used');
});
it('Większe Samookaleczenie needs the talent and gives 2 Krew per HP', async () => {
expect((await selfHarm(makeActor(), { greater: true, roller: async () => 2 })).reason).toBe('missing-talent');
const res = await selfHarm(makeActor({ slugs: ['krwisty-pojedynkowicz'] }), { greater: true, roller: async () => 2 });
expect(res.krewGained).toBe(4);
});
});
describe('Kradzież Życia', () => {
it('costs 1 Zapał, arms the next attack and pays Krew for HP actually lost', async () => {
const actor = makeActor({ zeal: 2 });
expect(await useLifeSteal(actor)).toEqual({ ok: true, stepsEasier: 1 });
expect(actor.update).toHaveBeenCalledWith({ 'system.attributes.zeal.value': 1 });
expect(await resolveLifeSteal(actor, 4)).toBe(4);
});
it('greater version pays 2 Krew per HP and may be used twice per round', async () => {
const actor = makeActor({ slugs: ['wieksza-kradziez-zycia'], used: { lifeSteal: 1 } });
expect((await useLifeSteal(actor, { greater: true })).stepsEasier).toBe(2);
expect(await resolveLifeSteal(actor, 3)).toBe(6);
const spent = makeActor({ slugs: ['wieksza-kradziez-zycia'], used: { lifeSteal: 2 } });
expect((await useLifeSteal(spent, { greater: true })).reason).toBe('already-used');
});
});
describe('Złożenie w Ofierze / Wielka Ofiara', () => {
it('heals Sukcesy + Magia and gains Krew = Sukcesy', async () => {
const actor = makeActor({ hp: 10 });
expect(await sacrifice(actor, 2)).toMatchObject({ ok: true, hpGained: 5, krewGained: 2 });
});
it('Wielka Ofiara kills when Sukcesy exceed the highest Atrybut, else heals', async () => {
const actor = makeActor({ slugs: ['rytualista-krwi'], hp: 10 });
const kill = await greatSacrifice(actor, 6, { highestAttribute: 5, health: 12 });
expect(kill).toMatchObject({ ok: true, kills: true, targetHpLost: 12, krewGained: 12 });
const other = makeActor({ slugs: ['rytualista-krwi'], hp: 10 });
expect(await greatSacrifice(other, 4, { highestAttribute: 5, health: 12 })).toMatchObject({ kills: false, hpGained: 4, krewGained: 0 });
expect((await sacrifice(actor, 2)).reason).toBe('replaced-by-wielka-ofiara');
});
});
describe('Wchłanianie Krwi', () => {
it('needs at least 1 Krew; 2 HP per Krew once per round, 3 HP greater', async () => {
expect((await absorbBlood(makeActor({ blood: 5 }), 0)).reason).toBe('minimum-one-blood');
expect((await absorbBlood(makeActor({ blood: 5, hp: 10 }), 3))).toMatchObject({ ok: true, hpGained: 6 });
expect((await absorbBlood(makeActor({ blood: 5, used: { absorb: 1 } }), 1)).reason).toBe('already-used');
expect((await absorbBlood(makeActor({ blood: 5, hp: 10 }), 2, { greater: true }))).toMatchObject({ hpGained: 6 });
});
});
describe('passive Krew', () => {
it('Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP', async () => {
expect(krewFromWeaponHpLoss(9, 'respect-for-life')).toBe(2);
expect(krewFromWeaponHpLoss(9, 'krwisty-pojedynkowicz')).toBe(4);
expect(krewFromWeaponHpLoss(9, 'rytualista-krwi')).toBe(0);
expect(await gainKrewFromWeaponDamage(makeActor({ slugs: ['respect-for-life'] }), 9)).toBe(2);
expect(await gainKrewFromWeaponDamage(makeActor(), 9)).toBe(0);
});
it('fight start/end: Rytualista starts with 4, leftover Krew heals 2 HP each', async () => {
expect(startingKrew('rytualista-krwi')).toBe(4);
const rit = makeActor({ slugs: ['rytualista-krwi'], blood: 0 });
await startOfFightKrew(rit);
expect(rit.update).toHaveBeenCalledWith({ 'system.attributes.blood.value': 4 });
const end = makeActor({ slugs: ['respect-for-life'], blood: 5, hp: 10 });
expect(await endOfFightKrew(end)).toBe(10);
});
});
describe('Runiczny Pancerz – single hit', () => {
it('> 5 disables 1 point; shield-breakers disable per 2 damage', () => {
expect(runicArmorPointsDisabled(5)).toBe(0);
expect(runicArmorPointsDisabled(6)).toBe(1);
expect(runicArmorPointsDisabled(40)).toBe(1);
expect(runicArmorPointsDisabled(7, 2)).toBe(3);
});
});
+35
View File
@@ -80,6 +80,41 @@ describe('applyDamage', () => {
expect(r.hpDamage).toBe(6); expect(r.hpDamage).toBe(6);
}); });
it('one hit above 5 (after Runiczny Pancerz) disables 1 point; hits never accumulate', async () => {
const big = makeActor({ magicalArmor: { value: 3, max: 3 } });
const r = await applyDamage(big.actor, { amount: 10, postChat: false });
expect(r.runicArmorDisabled).toBe(1);
expect(big.actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.value': 2 }));
const small = makeActor({ magicalArmor: { value: 3, max: 3 } });
await applyDamage(small.actor, { amount: 7, postChat: false });
await applyDamage(small.actor, { amount: 7, postChat: false });
expect(small.updates.every((u) => u['system.attributes.magicalArmor.value'] === 3)).toBe(true);
});
it('shield-breakers: every 2 damage disable a point and damage counts double against Magiczna Osłona', async () => {
const { actor } = makeActor({ magicalArmor: { value: 4, max: 4 }, magicalShield: { value: 6 } });
const r = await applyDamage(actor, { amount: 10, runicArmorStep: 2, shieldMultiplier: 2, postChat: false });
expect(r.runicArmorDisabled).toBe(4);
expect(r.absorbed.magicalShield).toBe(3);
expect(r.hpDamage).toBe(3);
});
it('Prawdziwa Dezintegracja destroys shield and disables all Runiczny Pancerz', async () => {
const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 }, magicalShield: { value: 50 } });
await applyDamage(actor, { amount: 10, destroyMagicalDefenses: true, ignorePhysicalArmor: true, postChat: false });
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({
'system.attributes.magicalArmor.value': 0,
'system.attributes.magicalShield.value': 0,
}));
});
it('Rozerwanie Run permanently lowers the maximum', async () => {
const { actor } = makeActor({ magicalArmor: { value: 3, max: 3 } });
const r = await applyDamage(actor, { amount: 1, destroyRunicPoints: 1, postChat: false });
expect(r.runicArmorDestroyed).toBe(1);
expect(actor.update).toHaveBeenCalledWith(expect.objectContaining({ 'system.attributes.magicalArmor.max': 2 }));
});
it('marks shield as dropped when reduced to 0', async () => { it('marks shield as dropped when reduced to 0', async () => {
const { actor } = makeActor({ magicalShield: { value: 3 } }); const { actor } = makeActor({ magicalShield: { value: 3 } });
const r = await applyDamage(actor, { amount: 5, postChat: false }); const r = await applyDamage(actor, { amount: 5, postChat: false });
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchNbpRate } from '../../src/logic/nbp';
const ok = (mid: number, effectiveDate: string) => ({ ok: true, json: async () => ({ rates: [{ mid, effectiveDate }] }) });
const notFound = { ok: false, json: async () => ({}) };
const now = new Date('2026-10-20');
describe('fetchNbpRate', () => {
it('uses the June window for a past year', async () => {
const f = vi.fn().mockResolvedValue(ok(4.4, '2024-06-03'));
expect(await fetchNbpRate('EUR', 2024, now, f)).toEqual({ rate: 4.4, date: '2024-06-03' });
expect(f.mock.calls[0]![0]).toContain('/eur/2024-06-01/2024-06-14/');
});
it('uses the latest rate for a future year without a dated request', async () => {
const f = vi.fn().mockResolvedValue(ok(4.3, '2026-10-19'));
expect(await fetchNbpRate('eur', 2031, now, f)).toEqual({ rate: 4.3, date: '2026-10-19' });
expect(f).toHaveBeenCalledTimes(1);
expect(f.mock.calls[0]![0]).not.toMatch(/\d{4}-\d{2}-\d{2}\//);
});
it('falls back to the latest rate when the dated window has no table', async () => {
const f = vi.fn().mockResolvedValueOnce(notFound).mockResolvedValueOnce(ok(4.2, '2026-10-19'));
expect((await fetchNbpRate('eur', 2026, now, f))?.rate).toBe(4.2);
});
it('returns null when NBP is unreachable', async () => {
const f = vi.fn().mockRejectedValue(new Error('offline'));
expect(await fetchNbpRate('eur', 2024, now, f)).toBeNull();
});
});
+22 -12
View File
@@ -17,7 +17,7 @@ function makeActor(overrides: Partial<any> = {}) {
zeal: { value: 0, max: 5 }, zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 }, blood: { value: 0, max: 6 },
elixirTolerance: 2, elixirTolerance: 2,
magicalArmor: { value: 4, runicCounter: 3 }, magicalArmor: { value: 4, max: 4, runicCounter: 3 },
...overrides.attributes, ...overrides.attributes,
}, },
skills: overrides.skills ?? {}, skills: overrides.skills ?? {},
@@ -30,7 +30,7 @@ function makeActor(overrides: Partial<any> = {}) {
} }
describe('rest', () => { describe('rest', () => {
it('take a breather restores all mana and 1d6 + endurance HP, leaves zeal/blood alone', async () => { it('take a breather restores 1d6 mana and 1d6 + endurance HP, leaves zeal/blood alone', async () => {
const { actor, updates } = makeActor({ const { actor, updates } = makeActor({
attributes: { attributes: {
health: { value: 10, max: 30 }, health: { value: 10, max: 30 },
@@ -43,13 +43,21 @@ describe('rest', () => {
const r = await rest(actor, 'breather'); const r = await rest(actor, 'breather');
expect(r.kind).toBe('breather'); expect(r.kind).toBe('breather');
expect(r.hpRestored).toBe(6); // 4 (stub roll default) + 2 (endurance) = 6 expect(r.hpRestored).toBe(6); // 4 (stub roll default) + 2 (endurance) = 6
expect(r.manaRestored).toBe(19); expect(r.manaRestored).toBe(4); // stub 1d6 = 4
expect(updates['system.attributes.health.value']).toBe(16); expect(updates['system.attributes.health.value']).toBe(16);
expect(updates['system.attributes.mana.value']).toBe(20); expect(updates['system.attributes.mana.value']).toBe(5);
expect(updates['system.attributes.zeal.value']).toBeUndefined(); expect(updates['system.attributes.zeal.value']).toBeUndefined();
expect(updates['system.attributes.breathersTaken']).toBe(1);
}); });
it('short rest restores all mana, 1/3 HP, and removes unconscious state', async () => { it('refuses a third breather between two long rests', async () => {
const { actor, updates } = makeActor({ attributes: { breathersTaken: 2 } });
const r = await rest(actor, 'breather');
expect(r.blocked).toBe(true);
expect(updates['system.attributes.health.value']).toBeUndefined();
});
it('short rest restores ⌈⅓⌉ of the mana pool, ⌈½⌉ HP, and removes unconscious state', async () => {
const effects = [ const effects = [
{ id: 'nieprzytomny', statuses: new Set(['nieprzytomny']) }, { id: 'nieprzytomny', statuses: new Set(['nieprzytomny']) },
{ id: 'przewrocony', statuses: new Set(['przewrocony']) }, { id: 'przewrocony', statuses: new Set(['przewrocony']) },
@@ -63,10 +71,10 @@ describe('rest', () => {
}); });
const r = await rest(actor, 'short'); const r = await rest(actor, 'short');
expect(r.kind).toBe('short'); expect(r.kind).toBe('short');
expect(r.hpRestored).toBe(10); // 30 / 3 = 10 expect(r.hpRestored).toBe(15); // ⌈30 / 2⌉
expect(r.manaRestored).toBe(19); expect(r.manaRestored).toBe(7); // ⌈20 / 3⌉
expect(updates['system.attributes.health.value']).toBe(20); expect(updates['system.attributes.health.value']).toBe(25);
expect(updates['system.attributes.mana.value']).toBe(20); expect(updates['system.attributes.mana.value']).toBe(8);
expect(deletedEffects).toContain('nieprzytomny'); expect(deletedEffects).toContain('nieprzytomny');
expect(deletedEffects).not.toContain('przewrocony'); expect(deletedEffects).not.toContain('przewrocony');
expect(r.effectsCleared).toBe(1); expect(r.effectsCleared).toBe(1);
@@ -86,7 +94,7 @@ describe('rest', () => {
mana: { value: 1, max: 20 }, mana: { value: 1, max: 20 },
zeal: { value: 0, max: 5 }, zeal: { value: 0, max: 5 },
blood: { value: 0, max: 6 }, blood: { value: 0, max: 6 },
magicalArmor: { value: 4, runicCounter: 3 }, magicalArmor: { value: 1, max: 4, runicCounter: 3 },
}, },
}); });
const r = await rest(actor, 'long'); const r = await rest(actor, 'long');
@@ -95,9 +103,11 @@ describe('rest', () => {
expect(updates['system.attributes.mana.value']).toBe(20); expect(updates['system.attributes.mana.value']).toBe(20);
expect(updates['system.attributes.zeal.value']).toBe(5); expect(updates['system.attributes.zeal.value']).toBe(5);
expect(updates['system.attributes.blood.value']).toBe(6); expect(updates['system.attributes.blood.value']).toBe(6);
expect(updates['system.attributes.magicalArmor.runicCounter']).toBe(0); expect(updates['system.attributes.magicalArmor.value']).toBe(4);
expect(r.toleranceRecovered).toBe(1); expect(r.toleranceRecovered).toBe(1);
expect(updates['system.attributes.breathersTaken']).toBe(0);
expect(updates['system.attributes.manaBorrowed']).toBe(0);
expect(deletedEffects).toContain('nieprzytomny'); expect(deletedEffects).toContain('nieprzytomny');
expect(deletedEffects).toContain('przewrocony'); expect(deletedEffects).toContain('przewrocony');
expect(deletedEffects).toContain('eff-1'); expect(deletedEffects).toContain('eff-1');
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import {
manaExhaustionTier, manaToBorrow, exhaustionDice, overLimitTest, overcastBonus, taperedDiceBonus,
spellSurplus, dispelSuccessesNeeded, triggersStun, concentrationTest, dodgeResult, parrySucceeds,
standUpCost, immediateActionsZealTotal, manaPassThreshold, helpContributionCap, bloodAbsorbHp,
bloodToZeal, manaElixirGain, negotiationBand,
} from '../../src/logic/rules';
import { getMagicPowerEntry } from '../../src/constants';
describe('magic power table', () => {
it('has III=24 and IV=21 mana per day', () => {
expect(getMagicPowerEntry(3).manaPerDay).toBe(24);
expect(getMagicPowerEntry(4).manaPerDay).toBe(21);
});
});
describe('Wyczerpanie Many', () => {
it('maps tiers', () => {
expect(manaExhaustionTier(3).id).toBe('zdyszanie');
expect(manaExhaustionTier(8).id).toBe('zmeczenie');
expect(manaExhaustionTier(12).id).toBe('wyczerpanie');
expect(manaExhaustionTier(16).id).toBe('zalamanie-sil');
expect(manaExhaustionTier(20).id).toBe('zapasc');
expect(manaExhaustionTier(24).id).toBe('ciezka-zapasc');
expect(manaExhaustionTier(25).id).toBe('wypalenie');
});
it('borrows only the shortfall and accumulates dice', () => {
expect(manaToBorrow(5, 2)).toBe(3);
expect(manaToBorrow(2, 5)).toBe(0);
expect(exhaustionDice(4, 3)).toBe(7);
});
});
describe('overcast', () => {
it('over-limit test T by base cost, S triangular', () => {
expect(overLimitTest(2, 3)).toEqual({ threshold: 4, successes: 6 });
expect(overLimitTest(4, 1)).toEqual({ threshold: 5, successes: 1 });
expect(overLimitTest(6, 5)).toEqual({ threshold: 6, successes: 15 });
});
it('tapers from level 3 (halved down, min 1)', () => {
expect(overcastBonus(2, 4)).toBe(8);
expect(overcastBonus(3, 4)).toBe(10);
expect(overcastBonus(3, 1)).toBe(3);
expect(taperedDiceBonus(5)).toBe(2);
expect(taperedDiceBonus(1)).toBe(1);
});
});
describe('Zasada Potężniejszych Zaklęć', () => {
it('computes surplus and dispel needs', () => {
expect(spellSurplus(5, 3)).toBe(2);
expect(spellSurplus(2, 3)).toBe(0);
expect(dispelSuccessesNeeded(2, true)).toBe(5);
});
});
describe('combat rules', () => {
it('Oszołomiony at half max Żywotność', () => {
expect(triggersStun(5, 10)).toBe(true);
expect(triggersStun(4, 10)).toBe(false);
expect(triggersStun(0, 0)).toBe(false);
});
it('Koncentracja S = ceil(lost/3), min 1', () => {
expect(concentrationTest(0)).toBeNull();
expect(concentrationTest(1)).toEqual({ threshold: 4, successes: 1 });
expect(concentrationTest(7)).toEqual({ threshold: 4, successes: 3 });
});
it('Unik adds 2 to a positive difference', () => {
expect(dodgeResult(3, 3)).toEqual({ hit: false, damageSuccesses: 0 });
expect(dodgeResult(5, 2)).toEqual({ hit: true, damageSuccesses: 5 });
});
it('Parowanie tolerance is ceil(½ Walka Wręcz)', () => {
expect(parrySucceeds(3, 5, 3)).toBe(true);
expect(parrySucceeds(2, 5, 3)).toBe(false);
});
it('rounds movement up', () => {
expect(standUpCost(5)).toBe(3);
});
it('1 Zapał buys two extra Immediate Actions', () => {
expect([1, 2, 3, 4, 5].map(immediateActionsZealTotal)).toEqual([0, 1, 1, 2, 2]);
});
});
describe('Pomoc / Źródło Mocy', () => {
it('caps contribution and sets pass threshold', () => {
expect(helpContributionCap(5)).toBe(3);
expect(helpContributionCap(5, true)).toBe(5);
expect(manaPassThreshold(0.5)).toBe(4);
expect(manaPassThreshold(2.5)).toBe(6);
expect(manaPassThreshold(3)).toBeNull();
});
});
describe('blood, elixirs, trade', () => {
it('Wchłanianie Krwi 2 HP/Krew, Większe 3, 5:1 Zapał', () => {
expect(bloodAbsorbHp(4)).toBe(8);
expect(bloodAbsorbHp(4, true)).toBe(12);
expect(bloodToZeal(12)).toBe(2);
});
it('mana elixir is capped by the pool', () => {
expect(manaElixirGain(8, 10, 3)).toBe(2);
});
it('negotiation bands', () => {
expect([0, 1, 2, 3, 4, 5, 6, 7, 12].map(negotiationBand)).toEqual([0, 1, 1, 2, 2, 3, 3, 4, 4]);
});
});
describe('applyDiceModifier', () => {
it('adds a signed modifier and never goes below 0', async () => {
const { applyDiceModifier } = await import('../../src/logic/rules');
expect(applyDiceModifier(5, 2)).toBe(7);
expect(applyDiceModifier(5, -2)).toBe(3);
expect(applyDiceModifier(2, -5)).toBe(0);
expect(applyDiceModifier(4)).toBe(4);
expect(applyDiceModifier(4, 1.9)).toBe(5);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { rechargeRunicArmor } from '../../src/logic/runic-armor';
import { runicArmorManaCost, runicArmorRestorable } from '../../src/logic/rules';
function makeActor(armor: { value: number; max: number }, mana: number) {
const update = vi.fn().mockResolvedValue(undefined);
return { actor: { system: { attributes: { magicalArmor: armor, mana: { value: mana, max: 30 } } }, update } as any, update };
}
describe('Runiczny Pancerz restore', () => {
it('costs 2 Mana per point and never exceeds max', () => {
expect(runicArmorManaCost(3)).toBe(6);
expect(runicArmorRestorable(2, 4, 5)).toBe(2);
expect(runicArmorRestorable(4, 4, 1)).toBe(0);
});
it('restores points and spends mana', async () => {
const { actor, update } = makeActor({ value: 1, max: 4 }, 10);
const res = await rechargeRunicArmor(actor, 2);
expect(res).toEqual({ ok: true, restored: 2, manaSpent: 4 });
expect(update).toHaveBeenCalledWith({
'system.attributes.magicalArmor.value': 3,
'system.attributes.mana.value': 6,
});
});
it('refuses when mana is short, unless partial', async () => {
const { actor, update } = makeActor({ value: 0, max: 4 }, 5);
expect((await rechargeRunicArmor(actor, 3)).reason).toBe('insufficient-mana');
expect(update).not.toHaveBeenCalled();
const res = await rechargeRunicArmor(actor, 3, { partial: true });
expect(res).toEqual({ ok: true, restored: 2, manaSpent: 4 });
});
it('reports already-full armor', async () => {
const { actor } = makeActor({ value: 4, max: 4 }, 10);
expect((await rechargeRunicArmor(actor, 1)).reason).toBe('already-full');
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { parseWoundKinds } from '../../scripts/parsers/gear-parser';
describe('gear-parser parseWoundKinds', () => {
it('maps English and Polish labels, single or listed', () => {
expect(parseWoundKinds('slashing')).toEqual(['slashing']);
expect(parseWoundKinds('kłute')).toEqual(['piercing']);
expect(parseWoundKinds('piercing, slashing')).toEqual(['piercing', 'slashing']);
expect(parseWoundKinds('slashing, piercing, blunt, slashing')).toEqual(['slashing', 'piercing', 'blunt']);
});
it('never guesses: missing or unknown stays empty', () => {
expect(parseWoundKinds(undefined)).toEqual([]);
expect(parseWoundKinds('')).toEqual([]);
expect(parseWoundKinds('ognista')).toEqual([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { resolve } from 'node:path';
import { parseSpells, deriveAreaOfEffect, deriveCastingTime } from '../../scripts/parsers/spell-parser';
import { SOURCE_BOOKS } from '../../src/constants';
describe('spell-parser', () => {
it('expands the flat vault frontmatter into a Foundry-loadable system payload', async () => {
const ctx = { repoRoot: resolve(__dirname, '../../../..'), idOverrides: {}, strict: true } as any;
const docs = await parseSpells(ctx);
expect(docs.length).toBeGreaterThan(100);
const mm = docs.find((d) => d.id === 'magic-missile')!;
const s = mm.system as any;
expect(mm.name).toBe('Magiczny Pocisk');
expect(s.sourceBook).toBe('ksiega-magii');
expect(s.castingTime).toBe('Instant');
expect(s.range).toBe('15 m');
expect(s.targets).toBe('1');
expect(s.damageBase).toBe('magicalAbilities/2');
expect(s.damageType).toBe('magical');
expect(s.difficulty).toEqual({ threshold: 4, successes: 1 });
expect(s.overcastOptions).toEqual([{ description: '+1 punkt Obrażeń (najwyżej dwa Poziomy Nadczarowania)', manaPerStep: 1 }]);
expect(s).not.toHaveProperty('circle');
for (const d of docs) {
const sys = d.system as any;
// No object leaks into string fields (the "[object Object]" bug).
for (const k of ['range', 'targets', 'duration', 'castingTime', 'damageBase']) {
expect(typeof sys[k], `${d.id}.${k}`).toBe('string');
}
for (const o of sys.overcastOptions) expect(o.description, d.id).toBeTruthy();
expect(SOURCE_BOOKS as readonly string[], d.id).toContain(sys.sourceBook);
}
});
it('derives area of effect from the targets line', () => {
expect(deriveAreaOfEffect('Kwadrat o boku 5 m')).toMatchObject({ shape: 'square', x: 5, y: 5 });
expect(deriveAreaOfEffect('Prostokąt 3 m x 8 m. Dłuższy bok')).toMatchObject({ shape: 'rectangle', x: 3, y: 8 });
expect(deriveAreaOfEffect('Sfera o średnicy 100 m')).toMatchObject({ shape: 'sphere', x: 50 });
expect(deriveAreaOfEffect('1')).toMatchObject({ shape: 'point', x: 0 });
});
it('derives casting time numbers', () => {
expect(deriveCastingTime('Instant')).toEqual({ rounds: 0, minutes: 0 });
expect(deriveCastingTime('2 godziny')).toEqual({ rounds: 0, minutes: 120 });
expect(deriveCastingTime('10 minut')).toEqual({ rounds: 0, minutes: 10 });
});
});