Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.zip
|
||||
*.log
|
||||
.vite/
|
||||
packs/
|
||||
packs-src/
|
||||
.env
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Changelog — hbm-rpg-v3
|
||||
|
||||
All notable changes to the **HbM RPG v3** Foundry VTT system.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.
|
||||
- Updated `compatibility.verified` to `"14"` to support Foundry VTT v14 build 363.
|
||||
|
||||
## [1.2.0] — 2026-04-30
|
||||
|
||||
Major mechanics + infrastructure release. Adds optional-rules sub-packs,
|
||||
ActiveEffects integration, automated tests, full localization for the new
|
||||
features, and a v1.2.0 world migration.
|
||||
|
||||
### Added
|
||||
- **Talent sub-packs** for optional rules:
|
||||
- `talents-blood` — 5 talents from *Arcanum Sanguinis*
|
||||
- `talents-abyss` — 4 talents from *Klątwa Otchłani*
|
||||
- Each parsed talent is stamped with `flags['hbm-rpg-v3'].sourceModule`
|
||||
so future module-aware UIs can filter or annotate them. *Note:* Foundry
|
||||
has no native gating of compendium content by external modules; the flag
|
||||
is informational only. *Chwała Szkarłatnemu Kultowi* has no talent
|
||||
chapter and is therefore intentionally not split.
|
||||
- **Macro pack `hbm-macros`** (7 macros, Polish labels):
|
||||
Rzut Puli d6, Test Atrybutu, Nałóż Przypadłość, Krótki/Długi Odpoczynek,
|
||||
Pomocnik Rzucania Grupowego, Doładuj Zapał.
|
||||
- **ActiveEffects integration on Talent and Gear sheets.** Both item types
|
||||
now render an Effects fieldset with Add/Edit/Toggle/Delete actions backed
|
||||
by Foundry's built-in ActiveEffect document; `transfer:true` effects are
|
||||
auto-applied to actors when the item is granted.
|
||||
- **Initiative override.** `CONFIG.Combat.initiative.formula = '2d6 + @attributes.initiative'`
|
||||
is registered in the `init` hook; characters use their derived initiative
|
||||
attribute, NPCs default to `2d6 + 0`.
|
||||
- **Per-turn Zeal regeneration is configurable.** The combat-turn handler
|
||||
now reads `flags['hbm-rpg-v3'].zealRegenBonus` (default `0`) added to the
|
||||
base `+1` regen — talents can grant additional regen via an ActiveEffect
|
||||
with `mode: ADD` targeting that flag.
|
||||
- **Long-rest cleanup.** `rest(actor, 'long')` now also deletes temporary
|
||||
ActiveEffects on the actor (those with finite duration or with the
|
||||
`flags['hbm-rpg-v3'].untilLongRest` marker), preserving transferred passives.
|
||||
- **Migration `1.2.0`.** Backfills NPC `mana/zeal/blood` resources, sets
|
||||
default `zealRegenBonus = 0` on character actors, and best-effort attaches
|
||||
a `+1 zealRegenBonus` ActiveEffect to existing talents whose description
|
||||
mentions "+1 Zapał" or "regeneracja Zapału".
|
||||
- **Localization.** Added `HBM.{rest, initiative, talentCategory, macro,
|
||||
activeEffect}` namespaces to both `lang/pl.json` and `lang/en.json`.
|
||||
- **Vitest test harness** (`vitest.config.ts`, `tests/setup.ts`). 5 test
|
||||
files covering damage layers, rest mechanics, zeal-regen helper, talent
|
||||
parser, and the v1.2.0 migration — 18 tests, all passing.
|
||||
|
||||
### Notes on skipped items
|
||||
- **Spell preparation** is intentionally not implemented — every spellcaster
|
||||
can cast any known spell directly (per design).
|
||||
- **Module gating of sub-pack talents** is not enforced because Foundry does
|
||||
not provide a hook to hide compendium documents based on external module
|
||||
presence. The `sourceModule` flag is preserved for future opt-in filtering.
|
||||
|
||||
## [1.1.2] — 2026-04-30
|
||||
|
||||
Mechanics polish release. No content changes; focuses on the spell-cast UX, the
|
||||
NPC sheet, and the cast dialog.
|
||||
|
||||
### Added
|
||||
- **Rich spell-cast chat card** (`templates/chat/spell-cast.hbs`) with header
|
||||
badges (mode / school / circle / success or failure), a costs ledger, damage
|
||||
block, status-effect chips, save line, registered triggers, and a collapsible
|
||||
description.
|
||||
- **Apply-status / apply-damage chat buttons.** Click handlers in
|
||||
`src/logic/chat-cards.ts` toggle status effects on selected/targeted tokens
|
||||
and pipe damage through `applyDamage()` with the correct `ignoreMagical*` /
|
||||
`ignorePhysicalArmor` flags pulled from the spell metadata.
|
||||
- **NPC sheet can cast spells.** `actor/npc.hbs` now lists embedded spell items
|
||||
with a `✦ Rzuć` button; the sheet supports drag-drop of items from
|
||||
compendiums or other actors. NPC schema gained `mana`, `zeal`, `blood`
|
||||
resources (mirroring characters) so the cast pipeline runs unchanged.
|
||||
- **Variable-success picker** in the cast dialog. When a spell defines
|
||||
`system.variableSuccesses[]` (e.g. *Przywołanie Istoty z Otchłani*), the
|
||||
dialog renders radio options and sets `opts.requiredOverride` from the
|
||||
selected variant.
|
||||
- **Canvas-based group-cast UI.** When `system.requiresGroupCast` is set, the
|
||||
cast dialog now renders a checkbox list of canvas tokens (instead of the
|
||||
free-text actor-id field), so group casters can be picked visually.
|
||||
|
||||
### Changed
|
||||
- `src/logic/spell-cast.ts` no longer posts separate damage / status messages —
|
||||
everything is consolidated into the new chat card via `renderTemplate`.
|
||||
- `lang/{pl,en}.json` — added `HBM.spellCast.*` keys for the new card and
|
||||
dialog widgets.
|
||||
- `styles/hbm.css` — styling for the spell-cast card and the new dialog
|
||||
widgets (caster checkbox list, variant radio list).
|
||||
|
||||
## [1.0.0] — 2026-04-29
|
||||
|
||||
First production release. Adds the full magic and resource model from books I–VIII
|
||||
plus a build pipeline that generates compendium content from the source markdown
|
||||
in `_books/`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Spell schema**: full coverage of the spell-reference (`deity`, `bloodCost`,
|
||||
`components.symbols`, `requirements.{race,talent,discipline}`, `isSuperspell`,
|
||||
`requiresGroupCast`, `minCasters`, `nonCombatOnly`, `areaOfEffect.{shape,x,y,unit}`,
|
||||
`damageBase`, `damageType`, `ignoresArmor`, `statusEffects`, `saveAttribute`,
|
||||
`saveSkill`, `overcastOptions`, `triggers`, `sourceBook`, `castingTimeRounds`,
|
||||
`castingTimeMinutes`, `variableSuccesses`).
|
||||
- **Compendium content** (152 docs / 11 packs):
|
||||
- `spells-general`, `spells-elements`, `spells-sacred`, `spells-academic`,
|
||||
`spells-superspells`, `spells-crimson` (93 spells total, blood/abyss packs
|
||||
reserved for AS / KO content).
|
||||
- `talents`, `talents-npc` (34 talents).
|
||||
- `races` (5 races), `disciplines`, `disciplines-forbidden` (20 disciplines).
|
||||
- **Cast pipeline**: `validateCast()` gates resources/race/talent/discipline/deity
|
||||
/group/inCombat/superspell/witch-symbol; `castSpell()` dispatches
|
||||
standard / sacred / witch / blood / Hekate hybrid; `rollSpellDamage()` parses
|
||||
formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`,
|
||||
`damageTaken`, `turnStart`).
|
||||
- **Cast dialog** with mana/zeal/blood inputs, group-caster picker, GM bypass
|
||||
checkboxes, validation feedback inline.
|
||||
- **New mechanics modules**: `blood-magic`, `abyss-magic`, `brewing`, `rest`,
|
||||
`trade` — all exposed under `game.hbm.*`.
|
||||
- **Actor schema**: `attributes.blood`, `attributes.elixirTolerance`,
|
||||
`attributes.insanity` with derived clamps and resource cards on the sheet.
|
||||
- **Mental conditions**: paranoia, fobia, depresja, mania, schizofrenia
|
||||
(Klątwa Otchłani VIII).
|
||||
- **Character sheet**: short-rest / long-rest header buttons; spells tab grouped
|
||||
by school with badges (`★ super`, `⛧ group`, `☮ noncombat`, `🔒 race-lock`)
|
||||
and source-book pip.
|
||||
- **Compendium browser metadata**: per-pack `flags.hbm-rpg-v3.{category,sourceBook,
|
||||
forbidden}` and `packFolders` (Zaklęcia / Talenty / Rasy i Dziedziny).
|
||||
- **Localization**: Polish (canonical) and English mirrors for all new
|
||||
namespaces (`spell`, `spellSchool`, `deity`, `sourceBook`, `aoeShape`,
|
||||
`damageType`, `triggerEvent`, `spellCast`, `bloodMagic`, `mentalCondition`).
|
||||
- **Migration runner** for v0.2.0 worlds (`complexityLevel` →
|
||||
`components.symbols.length`, `targets` regex → `areaOfEffect`, `overcasting`
|
||||
text → `overcastOptions[0].description`).
|
||||
- **Build pipeline**: `scripts/parsers/*` (book-walker, spell/talent/discipline/
|
||||
race parsers) + Zod validation + `scripts/build-packs.ts` (LevelDB via
|
||||
`@foundryvtt/foundryvtt-cli`). `bun run package` runs build → packs → zip.
|
||||
|
||||
### Deferred
|
||||
|
||||
- `gear-*`, `actors-*`, `roll-tables-*` packs — source books lack stat-block data.
|
||||
- Adventures package (`hbm-rpg-v3-adventures`).
|
||||
- Macro packs.
|
||||
- Compendium browser premium-module integration.
|
||||
|
||||
## [0.6.1] — 2026-04-29
|
||||
|
||||
- Localization parity (`en.json` mirrors all `pl.json` namespaces).
|
||||
- CSS polish: badges, source pip, blood-pool tint, school details groups,
|
||||
fieldset and array-row layouts.
|
||||
- Compendium browser metadata + `packFolders`.
|
||||
|
||||
## [0.6.0] — 2026-04-29
|
||||
|
||||
- Spell item-sheet rewrite (8 fieldsets, conditional rendering).
|
||||
- Cast dialog (`askCastOptions`) with mana/zeal/blood/Hekate/group/bypass.
|
||||
- Character header: short-rest / long-rest buttons.
|
||||
- Resources: blood pool, elixir tolerance, insanity (conditional).
|
||||
- Spells tab grouped by school with badges.
|
||||
|
||||
## [0.5.0] — 2026-04-29
|
||||
|
||||
- New mechanics modules under `game.hbm.*`: blood-magic, abyss-magic, brewing,
|
||||
rest, trade. Actor schema gains `elixirTolerance`, `insanity`.
|
||||
|
||||
## [0.4.0] — 2026-04-29
|
||||
|
||||
- Phase 0 foundation: extended `constants`, `SpellData`, `CharacterData.attributes.blood`,
|
||||
migration runner, mental conditions.
|
||||
|
||||
## [0.3.x]
|
||||
|
||||
- Pre-1.0 baseline: data models, sheets, dice, conditions, basic cast
|
||||
(standard / sacred / witch), 4-layer damage model, race/class linking.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
All Rights Reserved.
|
||||
|
||||
Copyright (c) 2026 Iwo Strzeboński.
|
||||
|
||||
This Foundry VTT system and all accompanying source code, templates,
|
||||
stylesheets, and configuration files are the exclusive property of the
|
||||
copyright holder. No part of this work may be reproduced, distributed,
|
||||
modified, or used to create derivative works without prior written
|
||||
permission of the copyright holder.
|
||||
|
||||
Permission is granted to the original author and authorized collaborators
|
||||
to use this software for the purposes of running and developing the
|
||||
Homebrew Magic: Role Playing Game v3 system.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Foundry VTT System: Homebrew Magic RPG v3
|
||||
|
||||
A custom **Foundry Virtual Tabletop v13** system implementing *Homebrew Magic: Role Playing Game v3* — a Polish tabletop RPG with a **d6 success-pool (TS — Trudność:Sukcesy)** dice mechanic.
|
||||
|
||||
> **Internal identifiers are English; user-facing labels are Polish (canonical) with English fallback.** The locale files in [`lang/`](./lang/) hold the translations.
|
||||
|
||||
## Status
|
||||
|
||||
**v1.0.0 — first production release.** Full magic and resource model from books I–VIII, 152 compendium docs across 11 packs, automated build pipeline that parses `_books/*.md` directly. See [`CHANGELOG.md`](./CHANGELOG.md) for the full history.
|
||||
|
||||
A companion lore module ([`foundry-lore`](https://github.com/iwo-strzebonski/hbm-foundry-lore)) ships JournalEntry compendia for narrative chapters.
|
||||
|
||||
## What's implemented
|
||||
|
||||
- **Document types** — Actor: `character`, `npc`. Item: `spell`, `gear`, `ability`, `class`, `race`, `discipline`, `talent`.
|
||||
- **TS d6 dice pool roller** (`HbmTSRoll`) — `1` auto-fail, `6` auto-success, configurable Threshold (T) and required Successes (Y), plus T-step / Y-step modifiers driven by Active Effects.
|
||||
- **Derived stats** — `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0–X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter.
|
||||
- **4-layer damage model** — Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss.
|
||||
- **Combat hooks** — Mana resets each round; Zeal +1 each turn; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save.
|
||||
- **Spell-cast workflow** — `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`).
|
||||
- **Cast dialog** — mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback.
|
||||
- **Mechanics modules** (`game.hbm.*`) — `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling).
|
||||
- **Status conditions (Active Effects)** — 15 physical conditions (Aneks A) plus 5 mental conditions from Klątwa Otchłani VIII (paranoja, fobia, depresja, mania, schizofrenia).
|
||||
- **Sheets** — ApplicationV2 + Handlebars for every document type. Character sheet: short-rest / long-rest header buttons, conditional resource cards (blood pool, elixir tolerance, insanity), spells tab grouped by school with badges (`★ super`, `⛧ group`, `☮ noncombat`, `🔒 race-lock`) and source-book pip.
|
||||
- **Compendia** (11 packs, 152 docs) — `spells-general`, `spells-elements`, `spells-sacred`, `spells-academic`, `spells-superspells`, `spells-crimson` (93 spells); `talents`, `talents-npc` (34 talents); `races` (5), `disciplines`, `disciplines-forbidden` (20). Each pack carries `flags.hbm-rpg-v3.{category, sourceBook}` for compendium-browser; `packFolders` group them in the sidebar.
|
||||
- **Race / Class linking** — drag a Race or Class item onto a Character to auto-link `system.details.raceId` / `system.details.classIds[year]`. Feeds the Advancement panel (attribute / skill points available).
|
||||
- **Migration runner** — `src/migrations/` upgrades v0.2.x worlds (`complexityLevel` → `components.symbols.length`, `targets` regex → `areaOfEffect`, `overcasting` text → `overcastOptions[0].description`).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/foundry-system/
|
||||
├── system.json # Foundry system manifest (includes documentTypes since v14)
|
||||
├── LICENSE.txt
|
||||
├── lang/ # pl.json (canonical) + en.json
|
||||
├── styles/hbm.css
|
||||
├── templates/
|
||||
│ ├── actor/ # character.hbs, npc.hbs
|
||||
│ ├── item/ # per-type partials + dispatcher
|
||||
│ └── chat/ts-roll.hbs
|
||||
├── scripts/package.ts # zip builder
|
||||
├── src/
|
||||
│ ├── hbm.ts # entry point
|
||||
│ ├── constants.ts
|
||||
│ ├── data/ # DataModels
|
||||
│ ├── dice/ # HbmTSRoll + macros
|
||||
│ ├── sheets/
|
||||
│ └── logic/ # combat, conditions, spell-cast
|
||||
└── package.json + vite.config.ts + tsconfig.json
|
||||
```
|
||||
|
||||
## Build & deploy
|
||||
|
||||
This system is built with **Vite + TypeScript**. Foundry runs in Docker on a remote host, so the workflow is **build → package → drop the zip into the container's `Data/systems/`**.
|
||||
|
||||
```sh
|
||||
cd src/foundry-system
|
||||
bun install # one-time
|
||||
bun run build # compile TS to dist/
|
||||
bun run build:packs # parse _books/ → dist/packs/ (LevelDB)
|
||||
bun run package # → hbm-rpg-v3-v1.0.0.zip
|
||||
```
|
||||
|
||||
To deploy to a Docker-hosted Foundry instance:
|
||||
|
||||
```sh
|
||||
# Option A: extract into the volume-mounted Data folder
|
||||
unzip -o hbm-rpg-v3-v0.3.x.zip -d /path/to/foundry-data/Data/systems/
|
||||
|
||||
# Option B: docker cp into a running container
|
||||
docker cp hbm-rpg-v3-v0.3.x.zip foundry:/data/Data/systems/
|
||||
docker exec foundry sh -c "cd /data/Data/systems && unzip -o hbm-rpg-v3-v0.3.x.zip"
|
||||
docker restart foundry
|
||||
```
|
||||
|
||||
Then in Foundry: **Game Systems → Install System → (system appears once it's in `Data/systems/`)** → create a world.
|
||||
|
||||
## Mechanic notes
|
||||
|
||||
### TS dice pool (d6)
|
||||
|
||||
```
|
||||
Roll N d6 → count successes → succeed if successes ≥ Y
|
||||
1 → forced failure (always)
|
||||
6 → forced success (always)
|
||||
>= T (2..6) → success
|
||||
```
|
||||
|
||||
Default `T = 4`, `Y = 1`. The roll dialog accepts both per-roll for harder tasks (GM may demand `Y ≥ 2`).
|
||||
|
||||
### Magic Power Level table
|
||||
|
||||
| Level | Dice | Max/spell | Mana/round |
|
||||
|------:|-----:|----------:|-----------:|
|
||||
| 0 | 6 | 10 | 20 |
|
||||
| I | 4 | 6 | 12 |
|
||||
| II | 4 | 5 | 10 |
|
||||
| III | 3 | 5 | 7 |
|
||||
| IV | 3 | 4 | 8 |
|
||||
| V | 3 | 4 | 6 |
|
||||
| VI | 2 | 3 | 6 |
|
||||
| VII | 2 | 3 | 4 |
|
||||
| VIII | 1 | 2 | 3 |
|
||||
| IX | 1 | 1 | 2 |
|
||||
| X | 0 | 1 | 1 |
|
||||
|
||||
## Out of scope (v1.0.0)
|
||||
|
||||
Deferred to follow-up versions:
|
||||
- Gear / artifact / actor / roll-table compendia — source books lack stat-block data.
|
||||
- Adventures package (`hbm-rpg-v3-adventures`).
|
||||
- Character creation wizard, advancement automation (PD spending).
|
||||
- Macro packs.
|
||||
- Compendium browser premium-module integration (basic flag metadata only).
|
||||
|
||||
## Reference material
|
||||
|
||||
Game rules and content live in sibling folders of this repo:
|
||||
|
||||
- [`../../_books/`](../../_books/) — synced PDFs of the rulebooks (read-only)
|
||||
- [`../../spells/`](../../spells/), [`../../creatures/`](../../creatures/), [`../../items/`](../../items/) — structured content
|
||||
- [`../../rules/`](../../rules/), [`../../revisions/`](../../revisions/) — current and proposed mechanics
|
||||
|
||||
## Useful links
|
||||
|
||||
- [Foundry VTT System Development](https://foundryvtt.com/article/system-development/)
|
||||
- [Foundry API v13](https://foundryvtt.com/api/)
|
||||
|
||||
## License
|
||||
|
||||
All Rights Reserved — see [LICENSE.txt](./LICENSE.txt).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"label": "Forbidden Disciplines",
|
||||
"entries": {
|
||||
"Magia Otchłani — Magia Aspektów": {
|
||||
"name": "AbyssAspects"
|
||||
},
|
||||
"Magia Otchłani — Pierwotna Magia": {
|
||||
"name": "AbyssPrimal"
|
||||
},
|
||||
"Magia Krwi": {
|
||||
"name": "Blood Magic"
|
||||
},
|
||||
"Magia Szkarłatu": {
|
||||
"name": "Crimson"
|
||||
},
|
||||
"Dzika Wiedźmia Magia": {
|
||||
"name": "Wild Witch Magic"
|
||||
},
|
||||
"Magia Otchłani": {
|
||||
"name": "Abyss Magic"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"label": "Magic Disciplines",
|
||||
"entries": {
|
||||
"Alchemia - Warzenie Eliksirów": {
|
||||
"name": "Alchemy - Brewing of Elixirs"
|
||||
},
|
||||
"Alchemia - Transmutacja": {
|
||||
"name": "Alchemy - Transmutation"
|
||||
},
|
||||
"Rzemiosło Artefaktów": {
|
||||
"name": "Crafting of Artifacts",
|
||||
"description": "[[Rzemiosło Artefaktów]]"
|
||||
},
|
||||
"Botanika": {
|
||||
"name": "Botany",
|
||||
"description": "[[Botanika]]"
|
||||
},
|
||||
"Magia Żywiołów (Powietrze)": {
|
||||
"name": "Elemental Magic - Air"
|
||||
},
|
||||
"Magia Żywiołów (Ziemia)": {
|
||||
"name": "Elemental Magic - Earth"
|
||||
},
|
||||
"Magia Żywiołów (Ogień)": {
|
||||
"name": "Elemental Magic - Fire"
|
||||
},
|
||||
"Magia Żywiołów (Woda)": {
|
||||
"name": "Elemental Magic - Water"
|
||||
},
|
||||
"Golemancja": {
|
||||
"name": "Golemancy"
|
||||
},
|
||||
"Magia Iluzji": {
|
||||
"name": "Magic of Illusion",
|
||||
"description": "[[Magic Iluzji]]"
|
||||
},
|
||||
"Źródło Mocy": {
|
||||
"name": "Sources of Power",
|
||||
"description": "Nie potrafisz rzucać zaklęć, ale Twoje ciało jest w stanie przechowywać znacznie więcej many, a ponadto możesz asystować innym magom w rzucaniu zaklęć, udzielając im many. Pierwotna Energia Nie możesz rzucać ani rozplatać zaklęć. Twój Maksymalny Koszt Zaklęcia oraz Ilość Many zostają dwukrotnie zwiększone."
|
||||
},
|
||||
"Nekromancja": {
|
||||
"name": "Necromancy",
|
||||
"description": "![[spells/academic/necromancy/ether-guide]]"
|
||||
},
|
||||
"Magia Runiczna": {
|
||||
"name": "Runic Magic"
|
||||
},
|
||||
"Magia Sakralna": {
|
||||
"name": "Sacred Magic"
|
||||
},
|
||||
"Magia Sakralna — Egzorcyzmy": {
|
||||
"name": "Sacred Magic - Exorcisms",
|
||||
"description": "[[Magic Sakralna]]"
|
||||
},
|
||||
"Wiedźmia Magia": {
|
||||
"name": "Witch Magic",
|
||||
"description": "[[Wiedźmia Magic]]"
|
||||
},
|
||||
"Alchemia": {
|
||||
"name": "Alchemy"
|
||||
},
|
||||
"Magia Żywiołów": {
|
||||
"name": "Elemental Magic"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"label": "HbM Macros",
|
||||
"entries": {
|
||||
"Nałóż Przypadłość": {
|
||||
"name": "Apply Condition"
|
||||
},
|
||||
"Test Atrybutu": {
|
||||
"name": "Attribute Check"
|
||||
},
|
||||
"Pomocnik Rzucania Grupowego": {
|
||||
"name": "Group Cast Helper"
|
||||
},
|
||||
"Długi Odpoczynek": {
|
||||
"name": "Long Rest"
|
||||
},
|
||||
"Rzut Puli d6": {
|
||||
"name": "Pool Roll"
|
||||
},
|
||||
"Doładuj Zapał": {
|
||||
"name": "Refill Zeal"
|
||||
},
|
||||
"Krótki Odpoczynek": {
|
||||
"name": "Short Rest"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"label": "Armor",
|
||||
"entries": {
|
||||
"Pancerz Ciężki": {
|
||||
"name": "Heavy Armor",
|
||||
"description": "<p>* **Armor Points:** 3\n* **Requirements:** Body >= 4</p>"
|
||||
},
|
||||
"Pancerz Lekki": {
|
||||
"name": "Light Armor",
|
||||
"description": "<p>* **Armor Points:** 1</p>"
|
||||
},
|
||||
"Pancerz Średni": {
|
||||
"name": "Medium Armor",
|
||||
"description": "<p>* **Armor Points:** 2\n* **Requirements:** Body >= 2</p>"
|
||||
},
|
||||
"Tarcza": {
|
||||
"name": "Shield",
|
||||
"description": "<p>A shield, when used, increases the owner's Defense by one step.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"label": "Equipment (Other)",
|
||||
"entries": {
|
||||
"Przybory do tworzenia artefatów": {
|
||||
"name": "Artifact Crafting Tools",
|
||||
"description": "<p>Podobnie jak zestaw do golemancji, zestaw do tworzenia artefaktów składa się z przede wszystkim dłuteł, jednak zamiast szpachelek są soczewki oraz podręczne mierniki magii.</p>"
|
||||
},
|
||||
"Kociołek": {
|
||||
"name": "Cauldron",
|
||||
"description": "<p>Kociołek jest najważniejszą rzeczą dla osób ważących eliksiry. Zawsze można w nim ugotować obiad, jak się zgłodnieje w lesie podczas zbierania ziół!</p>"
|
||||
},
|
||||
"Przedmioty do wróżenia": {
|
||||
"name": "Divination Tools",
|
||||
"description": "<p>Od kiedy na Ziemi jest magia,zaczęły pojawiać się osoby naprawdę potrafiące przewidywać przyszłość, stąd też zwiększone zapotrzebowanie na przedmioty, które mogą służyć do wróżenia. Kategoria ta obejmuje kule do wróżenia, tablice astrologiczne, kamienie z runami Futharku oraz karty do tarota.</p>"
|
||||
},
|
||||
"Szklana fiolka": {
|
||||
"name": "Glass Vial",
|
||||
"description": "<p>Szklane fiolki są w magii wykorzystywane praktycznie przez wszystkich do przetrzymywania cieczy oraz magicznych składników</p>"
|
||||
},
|
||||
"Przybory do golemancji": {
|
||||
"name": "Golemancy Tools",
|
||||
"description": "<p>Zestaw dłut, szpachelek, noży oraz młoteczków, które służą do tworzenia golemów.</p>"
|
||||
},
|
||||
"Miecz świetlny": {
|
||||
"name": "Lightsaber",
|
||||
"description": "<p>Miecze świetlne są konstruowane w różny sposób (mowa tutaj o \"cywilnych\" mieczach). Część z nich nadaje się do pojedynków!</p>"
|
||||
},
|
||||
"Artefakt skupiający magię": {
|
||||
"name": "Magic Focus Artifact",
|
||||
"description": "<p>Rasy mniej utalentowane magicznie, zwłaszcza ludzie, korzystają z różnych przedmiotów, które ułatwiają skupianie magii, od różdżek, przez laski do kamieni. Otrzymujesz dodatkową kość przy rzutach na Zdolności Magiczne</p>"
|
||||
},
|
||||
"Artefakt przechowujący manę": {
|
||||
"name": "Mana Storage Artifact",
|
||||
"description": "<p>Magowie rzucając potężne zaklęcia potrafią bardzo szybko zużyć całą swoją Manę, a nie zawsze mają w pobliżu Źródło Mocy - z pomocą przychodzą artefakty potrafiące przechowywać energię magiczną. Takie artefakty dzielą się na dwie kategorie - różdżki wykonane z odmian magicznych drzew, takich jak Wspaniałodrzew bądź Srebrodrzew, oraz na takie wykonane z kamieni szlachetnych, które potrafią przechowywać znacznie więcej mocy, są jednak niesamowicie drogie.</p>"
|
||||
},
|
||||
"Tablica Ouija": {
|
||||
"name": "Ouija Board",
|
||||
"description": "<p>Tablica służąca do komunikacji ze zmarłymi. Tak jak przedmioty do wróżenia, tablica Ouija jest obecnie w cenie, zwłaszcza w rękach wprawnych nekromantów.</p>"
|
||||
},
|
||||
"Karty do gry": {
|
||||
"name": "Playing Cards",
|
||||
"description": "<p>Karty do gry są dostępne w przeróżnych kształtach, kolorach i do przeróżnych gier. Jak się okazuje, część ras, które pojawiły się na Ziemi, bardzo polubiły gry karciane, a zwłaszcza krasnoludy.</p>"
|
||||
},
|
||||
"Zestaw do rytuałów": {
|
||||
"name": "Ritual Kit",
|
||||
"description": "<p>Zestaw do rytuałów składa się z poświęconej kredy, kadzidła, świec rytualnych, sztyletu ofiarnego, różdżki oraz garści innych przydatnych rzeczy.</p>"
|
||||
},
|
||||
"Shinai": {
|
||||
"name": "Shinai",
|
||||
"description": "<p>Bambusowy miecz używany do japońskiej szermierki zwanej Kendo.</p>"
|
||||
},
|
||||
"Teleskop": {
|
||||
"name": "Telescope",
|
||||
"description": "<p>W związku z tym, że spora część magów wykorzystuje astronomię do swoich rytuałów, to teleskopy są kupowane na potęgę - w niektórych sklepach potrafią znikać kilka godzin po dostawie! Oprócz tego, że jest to teraz towar deficytowy, to oczywiście podrożał, choć trzeba przyznać, że magicznie wspomagane teleskopy są całkiem nezłym sprzętem.</p>"
|
||||
},
|
||||
"Miecz treningowy": {
|
||||
"name": "Training Sword",
|
||||
"description": "<p>Miecze treningowe są wykonane z drewna lub, co raz częściej, z tworzyw sztucznych.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"label": "Weapons",
|
||||
"entries": {
|
||||
"Łuk Klasyczny": {
|
||||
"name": "Classic Bow"
|
||||
},
|
||||
"Łuk Bloczkowy": {
|
||||
"name": "Compound Bow"
|
||||
},
|
||||
"Sztylet": {
|
||||
"name": "Dagger"
|
||||
},
|
||||
"Łuk Elfi": {
|
||||
"name": "Elven Bow"
|
||||
},
|
||||
"Broń jednoręczna lub półtoraręczna": {
|
||||
"name": "One Handed Or Bastard Weapon"
|
||||
},
|
||||
"Laska Quarterstaff": {
|
||||
"name": "Quarterstaff"
|
||||
},
|
||||
"Włócznia": {
|
||||
"name": "Spear"
|
||||
},
|
||||
"Broń dwuręczna": {
|
||||
"name": "Two Handed Weapon"
|
||||
},
|
||||
"Bicz": {
|
||||
"name": "Whip"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"label": "Races",
|
||||
"entries": {
|
||||
"Anioł": {
|
||||
"name": "Angel",
|
||||
"description": "Anioły są dosyć rzadko spotykaną na Ziemi rasą - przez większość ludzi wciąż są uznawane za istoty bliskie bóstwom, co jest poniekąd prawdą.\nWszystkie anioły są dziećmi (tworami?) Boga, bóstwa, które jest czymś najbliżej przypominającym Jahwe. Jako że jest to jedyne bóstwo, które uznają za faktyczne bóstwo, nazywają go także Jedynym.\nAnioły specjalizują się w Magii Sakralnej.\nSą ciche oraz zdystansowane."
|
||||
},
|
||||
"Elf": {
|
||||
"name": "Elf",
|
||||
"description": "Elfy nie są szczególnie liczną grupą, jednak wciąż są częściej spotykane na Ziemi niż anioły.\nTajemnicza i uwielbiająca intrygi, ta rasa przysporzyła już olbrzymich problemów Ziemianom poprzez wykorzystywanie wszelkich luk prawnych.\nSą wyższe od ludzi - przeciętny elf ma koło 2 metrów wysokości - a do tego mają długie, spiczaste uszy.\nElfy są bardzo potężną magicznie rasą, choć nie wszystkie potrafią korzystać z magii. Duża część elfów to Źródła Mocy."
|
||||
},
|
||||
"Feles": {
|
||||
"name": "Feles",
|
||||
"description": "Feles, inaczej znane jako kotowate, to delikatna rasa humanoidalnych istot, które są bardzo związane z naturą. Są dosyć ciche i nieśmiałe, choć to też zależy od danego przedstawiciela tej rasy.\nWszystkie w jakiś sposób czczą Artemidę-Dianę, boginię lasów i łowów."
|
||||
},
|
||||
"Człowiek": {
|
||||
"name": "Human",
|
||||
"description": "Ludzie są najliczniejszą rasą zamieszkującą Ziemię (kto by się spodziewał...), przez co są równocześnie najliczniejszą rasą w ziemskich szkołach magii.\nLudzie są znani ze swoich zdolności do adaptowania się do otoczenia oraz do zajmowania się czymkolwiek, co akurat jest potrzebne.\nMoże i nie są potężni magicznie, jednak przez ich wszechstronność nie powinni być lekceważeni.\nPomimo olbrzymiego zróżnicowania, jeśli chodzi o dziedziny magii, nie ma ludzkich Źródeł Mocy."
|
||||
},
|
||||
"Lamia": {
|
||||
"name": "Lamia",
|
||||
"description": "Lamie (bądź nagi) to rasa wyróżniająca się tym, że od pasa w dół mają tułów/ogon przypominający węże (czy też inne pełzające zwierzęta pokryte łuskami).\nNagi są wyśmienitymi wojownikami, bardzo często widuje się przedstawicieli tej rasy z włócznią i tarczą, lecz dzierżą także inne rodzaje broni. Preferują tradycyjną broń białą.\nPomimo tego, że niewiele lamii ma talent magiczny, wśród nich raz na jakiś czas zdarza się dziecko ze zdolnościami do panowania nad którymś z żywiołów."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"label": "Academic Disciplines",
|
||||
"entries": {
|
||||
"Celność": {
|
||||
"name": "Accuracy",
|
||||
"description": "Mag wykorzystuje swój talent magiczny, aby ułatwić sobie celowanie oraz żeby nieznacznie korygować swoje ataki dystansowe.\n\nAtaki dystansowe maga są o jeden stopień prostsze, a ponadto otrzymuje dwie dodatkowe kości do testów związanych z Umiejętnością Umiejętności Strzeleckie."
|
||||
},
|
||||
"Powietrzna Fala": {
|
||||
"name": "Air Wave",
|
||||
"description": "Mag wyrzuca z siebie potężną falę wiatru.\n\nKażda osoba w obszarze działania zaklęcia musi wykonać udany Test na Body (Siła) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia cel zostaje Powalony. Niezależnie od wyniku testu każda osoba w stożku otrzymuje tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga."
|
||||
},
|
||||
"Ożywienie Szkieletu": {
|
||||
"name": "Animate Skeleton",
|
||||
"description": "Mag używa magii aby sprawić, że kupka kości w jego pobliżu zaczyna się układać w kształt istoty, do której kiedyś należały.\n\nTak powstały szkielet nie ma własnej woli. Wszystkie szkielety utworzone tym zaklęciem mogą być kontrolowane przez maga w ramach Akcji. Szkielety, które nie otrzymają nowego rozkazu, będą próbowały wykonać ostatni otrzymany rozkaz. Jeżeli nie będzie to możliwe, to będą stały nieruchomo, czekając na nowy rozkaz. Rozkaz może się składać z maksymalnie 10 słów. Po zakończeniu działania zaklęcia lub w wypadku utraty przytomności przez maga, szkielet zamienia się w pył, uniemożliwiając ponowne ożywienie."
|
||||
},
|
||||
"Gorejąca Skóra": {
|
||||
"name": "Burning Skin",
|
||||
"description": "Skóra i ubrania maga zaczynają się palić. Ogień ten nie niszczy ubrań maga ani nie zadaje mu obrażeń.\n\nWszyscy przeciwnicy, którzy zaatakują maga wręcz w trakcie działania tego zaklęcia otrzymują tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga.\n\n---"
|
||||
},
|
||||
"Wyczucie Zagrożenia": {
|
||||
"name": "Danger Sense",
|
||||
"description": "Mag zaczyna skupiać się na delikatnych zmianach w otaczającym go powietrzu, co pomaga mu w unikaniu ataków.\n\nWszystkie ataki, których celem jest mag rzucający to zaklęcie, są trudniejsze o dwa stopnie (do maksymalnie Trudności (T) równej „6”), a ponadto mag otrzymuje dwie dodatkowe kości do rzutów związanych z Umiejętnością Refleks."
|
||||
},
|
||||
"Wykrycie Zmian": {
|
||||
"name": "Detect Changes",
|
||||
"description": "Mag wykorzystuje swoją wiedzę i swój dar związany z Dziedziną Magii, aby sprawdzić, czy dany przedmiot był modyfikowany w magiczny sposób.\n\nMistrz Gry określa wymaganą liczbę Sukcesów (S).\n\nPonadto, jeżeli Mag posiada Talent Zmysł Magiczny, Trudność (T) tego zaklęcia jest obniżona o 1 stopień."
|
||||
},
|
||||
"Woal Snów": {
|
||||
"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.\n\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.\n\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.\n\nW przypadku zdania Testu Postać tak czy inaczej ma karę do Testów związanych z Umiejętnością Spostrzegawczość."
|
||||
},
|
||||
"Trzęsienie Ziemi": {
|
||||
"name": "Earthquake",
|
||||
"description": "W wybranym przez maga obszarze ziemia zaczyna się silnie trząść.\n\nWszystkie istoty w obszarze działania muszą wykonać udany Test na Body (Atletyka) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać zostaje Unieruchomiona i Powalona do początku swojej następnej tury. Efekt ten aktywuje się na końcu każdej tury maga i kończy się na początku trzeciej tury maga od rzucenia zaklęcia. Ponadto, jeśli postać nie zda tego testu trzykrotnie w trakcie działania tego zaklęcia, postać staje się Nieprzytomna do końca trwania zaklęcia."
|
||||
},
|
||||
"Eteryczny Przewodnik": {
|
||||
"name": "Ether Guide",
|
||||
"description": "Postać przywołuje ducha, któremu może wydawać polecenia.\n\nDuch nie może wpływać na rzeczy materialne, nie ma żadnej wiedzy o tym świecie oraz nie potrafi myśleć i będzie starał się wykonać rozkaz najlepiej jak potrafi, co oznacza, że im dokładniejsze polecenie, tym większe prawdopodobieństwo na to, że polecenie zostanie wykonane."
|
||||
},
|
||||
"Ognista Powłoka": {
|
||||
"name": "Fire Cloak",
|
||||
"description": "Broń pokrywa się płomieniami, obniżając Trudność ataków wykonywanych tą bronią o 1 stopień oraz powodując, że broń dodatkowo zadaje 2 punkty obrażeń magicznych."
|
||||
},
|
||||
"Kula Ognia": {
|
||||
"name": "Fireball",
|
||||
"description": "Mag splata ogień w kulę, którą następnie ciska w wybrany cel.\n\nCel otrzymuje tyle punktów obrażeń, ile mag ma punktów w umiejętności Zdolności Magiczne, a następnie musi wykonać udany Test na Body (Refleks) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia Postać zostaje Podpalona na trzy rundy."
|
||||
},
|
||||
"Latanie": {
|
||||
"name": "Flight",
|
||||
"description": "Postać może latać przez 1 minutę."
|
||||
},
|
||||
"Złoto Głupców": {
|
||||
"name": "Fools Gold",
|
||||
"description": "Mag sprawia, że przedmiot, którego dotknie, zmieni wygląd i właściwości na takie, jakby był wykonany ze złota. Osoby bez żadnych możliwości magicznego sprawdzenia takiego przedmiotu nie będą miały powodu, by wątpić w autentyczność przedmiotu, jednak osoba z odpowiednim sprzętem bądź Zmysłem Magicznym jest w stanie bez problemu określić, że przedmiot był modyfikowany w sposób magiczny, znacząco obniżając jego wartość. Po zakończeniu działania zaklęcia przedmiot wraca do poprzedniego stanu."
|
||||
},
|
||||
"Żwirowa Zbroja": {
|
||||
"name": "Gravel Armor",
|
||||
"description": "Mag zamyka się w twardej, kamiennej zbroi.\n\nMag otrzymuje +1 punkt Pancerza, ale ze względu na to, że zbroja utrudnia ruchy, jego Prędkość zostaje zmniejszona o połowę."
|
||||
},
|
||||
"Gradobicie": {
|
||||
"name": "Hailstorm",
|
||||
"description": "Mag przywołuje nad dany obszar chmurę, z której zaczyna padać grad.\n\nKażda osoba w obszarze działania zaklęcia musi wykonać udany Test na Body (Refleks) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać otrzymuje tyle punktów obrażeń, ile wynosi wartość Umiejętności Zdolności Magiczne maga.\n\nDodatkowo obszar działania zaklęcia jest traktowany jak Trudny Teren (wszystkie istoty poruszają się w nim o połowę wolniej)."
|
||||
},
|
||||
"Lodowa Podłoga": {
|
||||
"name": "Ice Floor",
|
||||
"description": "Na obszarze o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) pojawia się lód. Osoby w polu muszą wykonać udany Test na Body (Atletyka) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać zostaje Powalona."
|
||||
},
|
||||
"Podpalenie": {
|
||||
"name": "Ignition",
|
||||
"description": "Na obszarze o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) pojawia się ogień. Każda osoba znajdująca się w tym obszarze otrzymuje 3 punkty obrażeń, a ponadto na początku swojej tury każda postać otrzymuje kolejne 3 punkty obrażeń, tak długo, jak będą się znajdować w obszarze tego zaklęcia."
|
||||
},
|
||||
"Niewidzialność": {
|
||||
"name": "Invisibility",
|
||||
"description": "Mag rzucający to zaklęcie bądź wskazana istota staje się całkowicie niewidzialna do końca trwania zaklęcia lub gdy zaatakuje lub rzuci kolejne zaklęcie. Dla obserwatorów postać zdaje się znikać z rzeczywistości, nie pozostawiając po sobie śladów.\n\nPoruszanie się nie przerywa efektu, ale każda interakcja z otoczeniem wymaga udanego Testu Magic (Zręczność) 5:1, aby utrzymać niewidzialność."
|
||||
},
|
||||
"Wzniecanie Ognia": {
|
||||
"name": "Kindle",
|
||||
"description": "Postać podpala dotkniętą przez siebie rzecz dotykaną przez siebie."
|
||||
},
|
||||
"Przemiana w Ołów": {
|
||||
"name": "Lead Transmutation",
|
||||
"description": "Mag zbiera swoją moc magiczną i przenosi ją do obszaru o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) , sprawiając, że broń przeciwników w wybranym przez maga obszarze przemienia się w ołów.\n\nPrzeciwnicy w wybranym przez maga obszarze otrzymują -2 kości do rzutów na Walkę Wręcz oraz Umiejętności Strzeleckie."
|
||||
},
|
||||
"Magiczna Odporność": {
|
||||
"name": "Magic Resistance",
|
||||
"description": "Mag otacza się ochronną chmurą, która chroni go przed wybranym przy rzucaniu zaklęcia Stanem. Jeżeli mag już na sobie dany Stan, to jest z niego usuwany. To zaklęcie nie usuwa Koncentracji."
|
||||
},
|
||||
"Magiczne Kajdany": {
|
||||
"name": "Magic Shackles",
|
||||
"description": "Cel zaklęcia czuje, jakby spętały go widmowe, niezniszczalne łańcuchy. Efekt działa wyłącznie na umysł celu - Postać nie jest w rzeczywistości związana.\n\nPostać musi wykonać Test na Soul (Determinacja) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów.\n\nW przypadku niepowodzenia Postać jest traktowana tak, jakby była Unieruchomiona, a ponadto nie może rzucać zaklęć."
|
||||
},
|
||||
"Magnetyzm": {
|
||||
"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.\n\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.\n\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).\n\nPonadto wszystkie istoty w Twoim pobliżu otrzymują -1 kość do rzutów na Walkę Wręcz oraz Umiejętności Strzeleckie.\n\n---"
|
||||
},
|
||||
"Labirynt Umysłu": {
|
||||
"name": "Mind Labyrinth",
|
||||
"description": "Mind ofiary zostaje wciągnięty w iluzoryczny labirynt pełen sprzecznych myśli i rozkojarzeń.\n\nGdy cel chce wykonać jakąkolwiek Akcję, musi wykonać Test na Koncentrację (patrz: Stan Koncentracja), gdzie wymagana liczba Sukcesów (S) jest równa liczbie wyrzuconych przez maga Sukcesów. W wypadku niepowodzenia istota traci Akcję. Postać nie może z własnej woli zakończyć Koncentracji.\n\nW wypadku niepowodzenia otrzymuje tyle 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ć natychmiast kończy swoją turę."
|
||||
},
|
||||
"Zwierciadlany Wizerunek": {
|
||||
"name": "Mirror Image",
|
||||
"description": "W wybranym przez maga miejscu pojawia się iluzoryczny sobowtór, który powtarza jego ruchy i głos.\n\nMag może w ramach swojej Akcji kontrolować ruchy iluzji, jednak nie może ona się znaleźć dalej niż 20 m od maga.\n\nIluzja może zostać dostrzeżona za pomocą Testu 5:S Mind (Spostrzegawczość) lub, jeśli Postać posiada Talent Magiczny Zmysł, 3:S Magic (Spostrzegawczość), gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów.\n\nPo wykonaniu Ataku w iluzję Postać dowiaduje się, że atakowała iluzję."
|
||||
},
|
||||
"Mistyczna Mgła": {
|
||||
"name": "Mystic Fog",
|
||||
"description": "Mag przywołuje bardzo gęstą magiczną mgłę dookoła siebie.\n\nWszyscy przeciwnicy są Oślepieni, zaś wszyscy sojusznicy (w tym Mag) są traktowani jakby byli w pełni zasłonięci (przeciwnicy nie mogą atakować ich atakami dystansowymi ani wpływać na nich zaklęciami, które wymagają wybrania określonego celu, chyba że takie zaklęcie jest zaklęciem dotykowym).\n\nWszyscy sojusznicy (w tym mag) bez problemu widzą przez mgłę oraz widzą jej granicę. Mgła podąża za magiem.\n\n---"
|
||||
},
|
||||
"Negacja": {
|
||||
"name": "Negation",
|
||||
"description": "Na obszarze o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) pojawia się bardzo gęsta, wchłaniającą magię mgła.\n\nIstoty znajdujące się w tym obszarze nie mogą rzucać zaklęć oraz wszystko, co znajduje się w tym obszarze, traci tymczasowo właściwości magiczne."
|
||||
},
|
||||
"Ruchome Piaski": {
|
||||
"name": "Quicksand",
|
||||
"description": "Mag tymczasowo zmienia podłoże w ruchome piaski, utrudniając poruszanie się w obszarze działania zaklęcia.\n\nWszystkie istoty w tym obszarze mogą się poruszyć maksymalnie o zaokrągloną w górę połowę swojego Ruchu (czyli jeśli dana istota mogła się normalnie ruszać 10 m, tak w obszarze działania zaklęcia może się poruszyć o zaledwie 5 m)."
|
||||
},
|
||||
"Rozsypanie": {
|
||||
"name": "Shatter",
|
||||
"description": "Mag wysysa energię życiową i magiczną z celu.\n\nRozsypanie zadaje tyle punktów obrażeń, ile wynosi dwukrotność wartości Umiejętności Zdolności Magiczne maga. Ponadto, jeśli cel jest Nieumarłym i ma mniej punktów Żywotności niż dwukrotność wartości Atrybutu Soul maga, natychmiast się rozsypuje.\n\nNieumarłego zniszczonego tym zaklęciem nie da się wskrzesić, choć istnieją wyjątki od tej reguły, jak chociażby lisze i potężniejsze wampiry."
|
||||
},
|
||||
"Ukojenie": {
|
||||
"name": "Solace",
|
||||
"description": "Dookoła maga pojawia się kojąca rany delikatna mgiełka.\n\nWszyscy sojusznicy (w tym mag) w promieniu działania zaklęcia na początku następnej rundy maga odzyskują tyle Punktów Żywotności, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga."
|
||||
},
|
||||
"Rozmawianie ze Zmarłymi": {
|
||||
"name": "Speak With Dead",
|
||||
"description": "Mag przywołuje duszę zmarłego i może zadać jej pytanie.\n\nSoul odpowiada na jedno pytanie zgodnie z prawdą i swoją wiedzą - martwi nie mają powodu by kłamać - ale jeżeli dusza ma błędne informacje, które uważa za prawdziwe, to je przekaże (zmarli nie są wszystkowiedzący), a następnie wraca do krainy zmarłych. Tej duszy już nie będzie można ponownie przywołać.\n\n---"
|
||||
},
|
||||
"Skalista Tarcza": {
|
||||
"name": "Stone Shield",
|
||||
"description": "Postać tworzy w pobliżu siebie w wolnej przestrzeni ścianę z ziemi, piasku bądź żwiru.\n\nŚciana ta wytrzymuje ilość obrażeń równą wartości Umiejętności Zdolności Magiczne maga, a ponadto blokuje wizję oraz możliwość przejścia przez dany obszar istotom rozmiaru Średniego lub większego."
|
||||
},
|
||||
"Skalista Pułapka": {
|
||||
"name": "Stone Trap",
|
||||
"description": "Mag zaklina ziemię, piasek i żwir w danym obszarze, aby stworzyć pułapkę, która przebije kolcami pierwszą osobę, która wejdzie w dany obszar.\n\nKolce zadają tyle punktów obrażeń, ile mag ma punktów w Umiejętności Zdolności Magiczne, a następnie zaklęcie przestaje działać.\n\n---"
|
||||
},
|
||||
"Tworzenie i Kontrolowanie Wody": {
|
||||
"name": "Water Create",
|
||||
"description": "Postać tworzy i/lub zaczyna kontrolować do 1 litra wody. Gdy kontrolowana woda opuści zasięg 5 m, mag przestaje ją kontrolować. Zaklęcie to pozwala również na usunięcie lub wyparowanie do 1 litra wody."
|
||||
},
|
||||
"Szybki jak Wiatr": {
|
||||
"name": "Wind Swift",
|
||||
"description": "Mag splata wiatr, aby wspomóc się przy poruszaniu się.\n\nPrędkość maga zwiększa się dwukrotnie, a ponadto mag nie może zostać Powalony.\n\n---"
|
||||
},
|
||||
"Alchemia": {
|
||||
"name": "Alchemy"
|
||||
},
|
||||
"Magia Ziemi": {
|
||||
"name": "Earth Magic"
|
||||
},
|
||||
"Magia Ognia": {
|
||||
"name": "Fire Magic"
|
||||
},
|
||||
"Alchemia - Transmutacja": {
|
||||
"name": "Alchemy - Transmutation"
|
||||
},
|
||||
"Magia Żywiołów": {
|
||||
"name": "Elemental Magic"
|
||||
},
|
||||
"Magia Powietrza": {
|
||||
"name": "Air Magic"
|
||||
},
|
||||
"Nekromancja": {
|
||||
"name": "Necromancy"
|
||||
},
|
||||
"Magia Wody": {
|
||||
"name": "Water Magic"
|
||||
},
|
||||
"Magia Iluzji": {
|
||||
"name": "Magic of Illusion"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"label": "Crimson Cult Magic",
|
||||
"entries": {
|
||||
"Szkarłatny Pocisk": {
|
||||
"name": "Crimson Bolt",
|
||||
"description": "Mag wyczarowuje szkarłatny pocisk.\n\nSzkarłatny Pocisk zadaje tyle punktów obrażeń, ile mag ma punktów w umiejętności Zdolności Magiczne, a ponadto ignoruje Pancerz celu."
|
||||
},
|
||||
"Klątwa Szkarłatu": {
|
||||
"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.\n\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."
|
||||
},
|
||||
"Szkarłatny Sztylet": {
|
||||
"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\n\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ę.\n\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": {
|
||||
"name": "Crimson Flames",
|
||||
"description": "Wybrany cel zostaje spowity szkarłatnymi płomieniami, których się nie da ugasić.\n\nTak długo, jak to zaklęcie działa, jeżeli cel ma na sobie Magiczną Tarczę, na początku tury celu, Magiczna Tarcza zostaje natychmiast zniszczona, a dodatkowo cel otrzymuje tyle punktów obrażeń, ile wynosiła obecna wytrzymałość Magicznej Tarczy."
|
||||
},
|
||||
"Szkarłatna Sprawiedliwość": {
|
||||
"name": "Crimson Justice",
|
||||
"description": "Na wybrany cel spada z nieba (bądź sklepienia) szkarłatna błyskawica.\n\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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"label": "Spells - Eldritch",
|
||||
"entries": {
|
||||
"Zakazana Osłona": {
|
||||
"name": "Forbidden Shield",
|
||||
"description": "Mag spowija się tarczą złożoną z tajemniczych symboli.\n\nGdy mag ma zostać trafiony zaklęciem, które zada mu obrażenia lub go osłabi, to zaklęcie zmienia natychmiast cel na postać, która rzuciła to zaklęcie. Jeżeli to zaklęcie miałoby efekt obszarowy, to wtedy jest ono centrowane na postaci, która rzuciła to zaklęcie, ale działa w sposób odwrotny - zamiast ranić lub osłabiać sojuszników maga, osłabia lub rani sojuszników postaci, która rzuciła to zaklęcie; podobnie z efektami leczącymi bądź wzmacniającymi.\n\nZaklęcie przestaje działać, gdy efekt zaklęcia zostanie aktywowany bądź gdy skończy się walka.\n\nPonadto, w trakcie działania zaklęcia, Zakazana Osłona zmniejsza wszystkie otrzymywane obrażenia o 1."
|
||||
},
|
||||
"Tajemniczy Strumień": {
|
||||
"name": "Mysterious Stream",
|
||||
"description": "Z ręki maga wystrzeliwuje błyskawica utkana z mroczno-fioletowej energii.\n\nTajemniczy Strumień zadaje celowi zadaje tyle punktów obrażeń, ile mag ma punktów w Umiejętności Zdolności Magiczne.\n\nPonadto trafiony cel musi wykonać udany Test na Determinacja (Soul) 6:S, gdzie liczba wymaganych Sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać otrzymuje 1k3 Punktów Obłędu."
|
||||
},
|
||||
"Neutralizacja": {
|
||||
"name": "Neutralization",
|
||||
"description": "Mag wybiera postać, którą przeszywa dziwna, niezrozummiała energia.\n\nCel na końcu swojej tury co turę do końca trwania zaklęcia otrzymuje tyle punktów obrażeń, ile wynosi wartość Atrybutu Mind celu.\n\nPonadto jeżeli cel jest magiem, to do końca trwania zaklęcia nie może rzucać zaklęć ani wykonywać Testów związanych z Atrybutem Magic (wszystkie takie Testy są traktowane automatycznie jako niepowodzenie)."
|
||||
},
|
||||
"Pierwotna Kula": {
|
||||
"name": "Primal Orb",
|
||||
"description": "Mag wyczarowywuje kulę pierwotnej energii, którą następnie ciska w wybranego przeciwnika.\n\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).\n\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": {
|
||||
"name": "Summon Eldritch Entity",
|
||||
"description": "Mag przywołuje do wybranego miejsca wybraną przez siebie Istotę z Otchłani. Liczba Sukcesów (S) zależy od wybranej Istoty, zaś Istota wykonuje polecenia maga do końca trwania zaklęcia, następnie wraca tam, skąd przybyła."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"Celność": {
|
||||
"name": "Accuracy",
|
||||
"description": "Mag wykorzystuje swój talent magiczny, aby ułatwić sobie celowanie oraz żeby nieznacznie korygować swoje ataki dystansowe.\n\nAtaki dystansowe maga są o jeden stopień prostsze, a ponadto otrzymuje dwie dodatkowe kości do testów związanych z Umiejętnością Umiejętności Strzeleckie."
|
||||
},
|
||||
"Powietrzna Fala": {
|
||||
"name": "Air Wave",
|
||||
"description": "Mag wyrzuca z siebie potężną falę wiatru.\n\nKażda osoba w obszarze działania zaklęcia musi wykonać udany Test na Body (Siła) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia cel zostaje Powalony. Niezależnie od wyniku testu każda osoba w stożku otrzymuje tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga."
|
||||
},
|
||||
"Gorejąca Skóra": {
|
||||
"name": "Burning Skin",
|
||||
"description": "Skóra i ubrania maga zaczynają się palić. Ogień ten nie niszczy ubrań maga ani nie zadaje mu obrażeń.\n\nWszyscy przeciwnicy, którzy zaatakują maga wręcz w trakcie działania tego zaklęcia otrzymują tyle punktów obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga.\n\n---"
|
||||
},
|
||||
"Wyczucie Zagrożenia": {
|
||||
"name": "Danger Sense",
|
||||
"description": "Mag zaczyna skupiać się na delikatnych zmianach w otaczającym go powietrzu, co pomaga mu w unikaniu ataków.\n\nWszystkie ataki, których celem jest mag rzucający to zaklęcie, są trudniejsze o dwa stopnie (do maksymalnie Trudności (T) równej „6”), a ponadto mag otrzymuje dwie dodatkowe kości do rzutów związanych z Umiejętnością Refleks."
|
||||
},
|
||||
"Trzęsienie Ziemi": {
|
||||
"name": "Earthquake",
|
||||
"description": "W wybranym przez maga obszarze ziemia zaczyna się silnie trząść.\n\nWszystkie istoty w obszarze działania muszą wykonać udany Test na Body (Atletyka) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać zostaje Unieruchomiona i Powalona do początku swojej następnej tury. Efekt ten aktywuje się na końcu każdej tury maga i kończy się na początku trzeciej tury maga od rzucenia zaklęcia. Ponadto, jeśli postać nie zda tego testu trzykrotnie w trakcie działania tego zaklęcia, postać staje się Nieprzytomna do końca trwania zaklęcia."
|
||||
},
|
||||
"Ognista Powłoka": {
|
||||
"name": "Fire Cloak",
|
||||
"description": "Broń pokrywa się płomieniami, obniżając Trudność ataków wykonywanych tą bronią o 1 stopień oraz powodując, że broń dodatkowo zadaje 2 punkty obrażeń magicznych."
|
||||
},
|
||||
"Kula Ognia": {
|
||||
"name": "Fireball",
|
||||
"description": "Mag splata ogień w kulę, którą następnie ciska w wybrany cel.\n\nCel otrzymuje tyle punktów obrażeń, ile mag ma punktów w umiejętności Zdolności Magiczne, a następnie musi wykonać udany Test na Body (Refleks) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia Postać zostaje Podpalona na trzy rundy."
|
||||
},
|
||||
"Latanie": {
|
||||
"name": "Flight",
|
||||
"description": "Postać może latać przez 1 minutę."
|
||||
},
|
||||
"Żwirowa Zbroja": {
|
||||
"name": "Gravel Armor",
|
||||
"description": "Mag zamyka się w twardej, kamiennej zbroi.\n\nMag otrzymuje +1 punkt Pancerza, ale ze względu na to, że zbroja utrudnia ruchy, jego Prędkość zostaje zmniejszona o połowę."
|
||||
},
|
||||
"Gradobicie": {
|
||||
"name": "Hailstorm",
|
||||
"description": "Mag przywołuje nad dany obszar chmurę, z której zaczyna padać grad.\n\nKażda osoba w obszarze działania zaklęcia musi wykonać udany Test na Body (Refleks) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać otrzymuje tyle punktów obrażeń, ile wynosi wartość Umiejętności Zdolności Magiczne maga.\n\nDodatkowo obszar działania zaklęcia jest traktowany jak Trudny Teren (wszystkie istoty poruszają się w nim o połowę wolniej)."
|
||||
},
|
||||
"Lodowa Podłoga": {
|
||||
"name": "Ice Floor",
|
||||
"description": "Na obszarze o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) pojawia się lód. Osoby w polu muszą wykonać udany Test na Body (Atletyka) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać zostaje Powalona."
|
||||
},
|
||||
"Podpalenie": {
|
||||
"name": "Ignition",
|
||||
"description": "Na obszarze o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m) pojawia się ogień. Każda osoba znajdująca się w tym obszarze otrzymuje 3 punkty obrażeń, a ponadto na początku swojej tury każda postać otrzymuje kolejne 3 punkty obrażeń, tak długo, jak będą się znajdować w obszarze tego zaklęcia."
|
||||
},
|
||||
"Wzniecanie Ognia": {
|
||||
"name": "Kindle",
|
||||
"description": "Postać podpala dotkniętą przez siebie rzecz dotykaną przez siebie."
|
||||
},
|
||||
"Mistyczna Mgła": {
|
||||
"name": "Mystic Fog",
|
||||
"description": "Mag przywołuje bardzo gęstą magiczną mgłę dookoła siebie.\n\nWszyscy przeciwnicy są Oślepieni, zaś wszyscy sojusznicy (w tym Mag) są traktowani jakby byli w pełni zasłonięci (przeciwnicy nie mogą atakować ich atakami dystansowymi ani wpływać na nich zaklęciami, które wymagają wybrania określonego celu, chyba że takie zaklęcie jest zaklęciem dotykowym).\n\nWszyscy sojusznicy (w tym mag) bez problemu widzą przez mgłę oraz widzą jej granicę. Mgła podąża za magiem.\n\n---"
|
||||
},
|
||||
"Ruchome Piaski": {
|
||||
"name": "Quicksand",
|
||||
"description": "Mag tymczasowo zmienia podłoże w ruchome piaski, utrudniając poruszanie się w obszarze działania zaklęcia.\n\nWszystkie istoty w tym obszarze mogą się poruszyć maksymalnie o zaokrągloną w górę połowę swojego Ruchu (czyli jeśli dana istota mogła się normalnie ruszać 10 m, tak w obszarze działania zaklęcia może się poruszyć o zaledwie 5 m)."
|
||||
},
|
||||
"Ukojenie": {
|
||||
"name": "Solace",
|
||||
"description": "Dookoła maga pojawia się kojąca rany delikatna mgiełka.\n\nWszyscy sojusznicy (w tym mag) w promieniu działania zaklęcia na początku następnej rundy maga odzyskują tyle Punktów Żywotności, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne maga."
|
||||
},
|
||||
"Skalista Tarcza": {
|
||||
"name": "Stone Shield",
|
||||
"description": "Postać tworzy w pobliżu siebie w wolnej przestrzeni ścianę z ziemi, piasku bądź żwiru.\n\nŚciana ta wytrzymuje ilość obrażeń równą wartości Umiejętności Zdolności Magiczne maga, a ponadto blokuje wizję oraz możliwość przejścia przez dany obszar istotom rozmiaru Średniego lub większego."
|
||||
},
|
||||
"Skalista Pułapka": {
|
||||
"name": "Stone Trap",
|
||||
"description": "Mag zaklina ziemię, piasek i żwir w danym obszarze, aby stworzyć pułapkę, która przebije kolcami pierwszą osobę, która wejdzie w dany obszar.\n\nKolce zadają tyle punktów obrażeń, ile mag ma punktów w Umiejętności Zdolności Magiczne, a następnie zaklęcie przestaje działać.\n\n---"
|
||||
},
|
||||
"Tworzenie i Kontrolowanie Wody": {
|
||||
"name": "Water Create",
|
||||
"description": "Postać tworzy i/lub zaczyna kontrolować do 1 litra wody. Gdy kontrolowana woda opuści zasięg 5 m, mag przestaje ją kontrolować. Zaklęcie to pozwala również na usunięcie lub wyparowanie do 1 litra wody."
|
||||
},
|
||||
"Szybki jak Wiatr": {
|
||||
"name": "Wind Swift",
|
||||
"description": "Mag splata wiatr, aby wspomóc się przy poruszaniu się.\n\nPrędkość maga zwiększa się dwukrotnie, a ponadto mag nie może zostać Powalony.\n\n---"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"label": "Spells - General",
|
||||
"entries": {
|
||||
"Aegis": {
|
||||
"name": "Aegis",
|
||||
"description": "Czasem najpotężniejsze osłony przygotowywane przez lata nie wystarczają, a czasem takich brakuje.\n\nKilku elfickich magów, w trakcie Apokalipsy gdy stolica była oblegana przez orki, skończyło opracowywać zaklęcie, które ochroniłoby całe miasto nie tylko przed najeźdźcami, ale także przed Apokalipsą. Niestety nie przewidzieli trzech rzeczy - tarcza nikogo nie wypuści, więc zamknięci pod kopułą cywile zostali wymordowani przez przebywające pod kopułą orki; tarcza nikogo nie wpuści, więc wojsko elfickie nie było w stanie przyjść z pomocą; ich życie ucięło się znienacka przez miesiące życia, które oddali, aby ta potężna magiczna bariera zadziałała.\n\nZaklęcie wznosi niezniszczalną kopułę magiczną o promieniu do 500 m, która chroni przed wszelkimi atakami - fizycznymi, magicznymi i psychicznymi. Bariera może wytrzymać uderzenie superzaklęcia, jak np. superzaklęcia Destrukcji. Nic prócz powietrza i światła nie przejdzie przez barierę.\n\nKopuła może być utrzymywana przez maksymalnie 24 godziny, jednak każda godzina działania skraca życie każdego uczestnika o miesiąc.\n\nPo opadnięciu pozostawia po sobie magiczną aurę, która odstrasza potwory. Czas trwania tej aury to tyle miesięcy, przez ile godzin kopuła była utrzymywana."
|
||||
},
|
||||
"Przeskok": {
|
||||
"name": "Blink",
|
||||
"description": "Postać wykonuje “przeskok” do wybranego przez siebie miejsca w promieniu 20 m. Postać nie aktywuje efektów pokroju Talentu Oportunizm."
|
||||
},
|
||||
"Destrukcja": {
|
||||
"name": "Destruction",
|
||||
"description": "Znane potocznie jako “nuke spell”, to potężne zaklęcie pochłania olbrzymią ilość Many, która następnie jest wyzwalana w wybranym przez maga miejscu. Nagłe wyzwolenie tak dużej ilości Many powoduje eksplozję, której moc jest porównywalna do mocy broni nuklearnej - stąd właśnie ta nazwa potoczna.\n\nZaklęcie po rzuceniu “przenosi” Manę do wybranego przez maga miejsca w promieniu 100 m od niego, która następnie jest wyzwalana, wywołując potężną eksplozję o promieniu 50 m. W miejscu eksplozji powstaje krater, a w promieniu 10 km widać smugi zakrzywionej Many, które wskazują na miejsce wybuchu. “Dzika” mana, która powstaje po wybuchu, ma własności podobne do odpadów promieniotwórczych (oczywiście działają na innych zasadach), w związku z czym część rzeczy i istot, na które trafi, mutuje bądź jest zamieniana w odpady alchemiczne.\n\nRzuć tyloma kośćmi, ile zostało wykorzystanych punktów Many, a następnie zsumuj wyniki na wszystkich kościach. W promieniu 15 m od centrum eksplozji wszystkie istoty otrzymują pełną ilość obrażeń. W promieniu 30 m od centrum eksplozji wszystkie istoty otrzymują połowę obrażeń. W promieniu 50 m od centrum eksplozji wszystkie istoty otrzymują ćwierć obrażeń.\n\nPonadto na początku każdej rundy, dopóki teren nie zostanie oczyszczony lub przez następne pięć lat, każda istota w obszarze po eksplozji niebędąca odpowiednio zabezpieczona traci 1k^ maksymalnych punktów Żywotności, które można odzyskać wyłącznie z użyciem potężnej magii. Jeżeli Postać ze wszystkich źródeł utraci więcej niż jedną trzecią maksymalnych punktów Żywotności, na początku rundy rzuca również jedną kością stuścienną lub procentową (1k100). Na rzucie 100 Postać zostaje zmutowana - jeżeli była to Postać Gracza, gracz traci nad nią kontrolę. Za każdą rundę w danym obszarze próg wymagany do mutacji od odpadów alchemicznych jest permanentnie zmniejszany o 1.\n\n---"
|
||||
},
|
||||
"Bramy Wymiarów": {
|
||||
"name": "Dimension Gates",
|
||||
"description": "Legenda głosi, że pierwsze Bramy Wymiarów zostały otwarte przez arcyczarodzieja Tuaranusa, który poszukiwał ukochanej uwięzionej w świecie snów. Zaklęcie to rozdziera samo płótno rzeczywistości, łącząc dwa miejsca - lub dwa byty - nawet jeśli oddziela je nieskończona przestrzeń, czas czy prawa natury. Użyte siłą i bez zrozumienia, Bramy mogą rozszczepić wymiary lub sprowadzić na świat istoty znane tylko z dziecięcych koszmarów. Wielu magów poświęciło życie, żeby zapanować nad potęgą tego czaru i nie zostać wessanym w pustkę pomiędzy światami. Każdorazowe otwarcie Bram jest naznaczone echem starożytnych głosów - niektórzy mówią, że to ostrzeżenia, inni: zaproszenie.\n\nZaklęcie otwiera portal między dwoma lokalizacjami, niezależnie od wymiaru oraz odległości, która je dzieli. Brama ma do pięciu metrów średnicy i pozwala na swobodne przejście w obydwu kierunkach.\n\nPortal pozostaje aktywny przez określony czas, a jego istnienie powoli zmienia lokalną magię oraz otoczenie w promieniu 5 m od nich, tworząc “magiczne echo”.\n\nZamknięcie portalu przed czasem wymaga podobnego wysiłku jak jego stworzenie."
|
||||
},
|
||||
"Zagłada": {
|
||||
"name": "Doom",
|
||||
"description": "Zagł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.\n\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.\n\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ć.\n\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.\n\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.\n\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.\n\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.\n\n---"
|
||||
},
|
||||
"Fala Energii": {
|
||||
"name": "Energy Wave",
|
||||
"description": "Mag wyrzuca z siebie potężną falę półwidzialnej energii.\n\nKażda osoba w obszarze działania zaklęcia musi wykonać udany Test na Body (Refleks) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia 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."
|
||||
},
|
||||
"Przebicie Eteru": {
|
||||
"name": "Ether Pierce",
|
||||
"description": "Mag skupia swoją siłę, naginając rzeczywistość wokół celu, raniąc go pomimo osłon.\n\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.\n\nKażde dwa (zamiast pięciu) punkty obrażeń wyłączają jeden punkt Runicznej Tarczy oraz zadają jeden punkt obrażeń Magicznym Tarczom."
|
||||
},
|
||||
"Poblask": {
|
||||
"name": "Gleam",
|
||||
"description": "Przedmiot zaczyna świecić na kolor zależny od Śladu Magicznego."
|
||||
},
|
||||
"Transmutacja Krajobrazu": {
|
||||
"name": "Landscape Transmutation",
|
||||
"description": "Transmutacja Krajobrazu to zaklęcie, które wykracza poza zwykłe manipulacje elementami przyrody. Słynne są legendy o magach, którzy zmieniali pustynie w zielone doliny lub zatapiali zamki przeciwników pod falą skał. Owa moc staje się narzędziem stworzenia i zniszczenia - lokalne podania przypisują powstanie Gór Mglistych właśnie temu czarowi rzuconemu podczas wojny pomiędzy dwoma rodami magów. Nieliczni odważni są w stanie zapanować nad ogromną energią, jaką wyzwala Transmutacja, bo każde użycie pozostawia trwały ślad na krajobrazie i życiu ich mieszkańców. Starzy mistrzowie przestrzegają, że czarodziej, który raz skosztuje tej siły, już nigdy nie spojrzy tak samo na świat otaczający go - bo będzie wiedział, jak łatwo można go przemienić lub unicestwić.\n\nCałkowicie zmienia krajobraz na obszarze do 5 kilometrów kwadratowych. Może przekształcić pustynię w las, jezioro w ląd, góry w równiny itp. Wszystkie zmiany są trwałe i naturalne - nowy krajobraz rozwija się zgodnie z prawami natury.\n\nZmiana wpływa na lokalne wzorce pogodowe w promieniu 50 km. Ekosystem potrzebuje dekad na pełne przystosowanie.\n\nMagowie uczestniczący w rytuale zyskują trwałą więź z przekształconym terenem."
|
||||
},
|
||||
"Magiczny Pocisk": {
|
||||
"name": "Magic Missile",
|
||||
"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."
|
||||
},
|
||||
"Magiczna Plaga": {
|
||||
"name": "Magic Plague",
|
||||
"description": "Zakazana 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.\n\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.\n\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.\n\nIstoty niemagiczne i niemagowie są odporni na chorobę, jednak dalej mogą być jej nosicielami.\n\nIstoty magiczne, np. anioły, elfy czy demony są na nią szczególnie wrażliwe.\n\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.\n\n---"
|
||||
},
|
||||
"Magiczna Tarcza": {
|
||||
"name": "Magic Shield",
|
||||
"description": "Mag wyczarowuje magiczną osłonę, która chroni wybrany przez niego cel przed atakami.\n\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ć."
|
||||
},
|
||||
"Szybka Myśl": {
|
||||
"name": "Quick Thought",
|
||||
"description": "Twój umysł pracuje znacznie szybciej: otrzymujesz +1 do Inicjatywy, oraz +1 kość do wszystkich Testów związanych z rozwiązywaniem zagadek, analizy magicznych wzorców i szybkich reakcji. Pozwala też w wyjątkowych okolicznościach na krótkotrwałe unikanie dezinformacji, iluzji, czy zamętu umysłowego.\n\n---"
|
||||
},
|
||||
"Reanimacja": {
|
||||
"name": "Reanimation",
|
||||
"description": "Reanimacja to jedno z najstarszych i najbardziej kontrowersyjnych zaklęć rytualnych - prastara sztuka przywracania życia tym, którzy przekroczyli próg śmierci. Wedle legend zaklęcie to zostało po raz pierwszy użyte w czasach Wielkiego Rozdarcia, kiedy magowie próbowali ocalić upadłe armie i wskrzeszać poległych. Tylko nieliczni mistrzowie nekromancji potrafią przeprowadzić rytuał w całości, gdyż wymaga nie tylko mocy, ale i niebywałej odwagi - oraz gotowości poniesienia ceny. Uważa się, że każda reanimowana dusza niesie w sobie bliznę bycia rozdartą z objęć śmierci, a linia między żywym a martwym zostaje na chwilę zatarta. Używanie Reanimacji bywa zakazane w wielu krainach, choć są tacy, którzy nie zawahaliby się zapłacić każdą cenę, aby odzyskać ukochaną osobę.\n\nRytuał przywraca do życia zmarłego, ale tylko jeśli śmierć nastąpiła w ciągu ostatnich 24 godzin. W przeciwieństwie do zwykłej nekromancji, wskrzeszony wraca z pełną świadomością, wspomnieniami i osobowością. Body jest w pełni żywe i funkcjonalne, choć cel ma 0 punktów Żywotności i jest Nieprzytomny.\n\nKażdy mag, który brał udział w rytuale, starzeje się o 1k6 lat. Miejsce wskrzeszenia staje się “naznaczone”, co sprawia, że zjawy, duchy oraz inne nieumarłe istoty czują się tam przyciągane."
|
||||
},
|
||||
"Requiem": {
|
||||
"name": "Requiem",
|
||||
"description": "W dawnych kronikach Requiem symbolizowało narzędzie “ostatecznej separacji” - poprzez halabardę otwierano szczeliny w rzeczywistości, by elita Kultu mogła błyskawicznie przedrzeć się przez obronę, zasiewając zamęt i rozbicie wśród wrogów. Nikogo nie zabiera, nie drenuje życia, lecz wnosi do świata esencję Szkarłatu: rozprężenie struktur, dezorientację i przewagę dla wtajemniczonych.\n\nW ręce maga pojawia się utkana ze szkarłatnej energii halabarda, którą następnie może rzucić w wybrany przez siebie cel.\n\nGdy halabarda wbija się w wyznaczone miejsce, natychmiast zadaje wszystkim istotom w promieniu 10 metrów 3k6 obrażeń ignorujących Pancerz oraz wszystkie odporności, następnie mag (oraz do 2 sprzymierzeńców w promieniu 2 m) natychmiast teleportuje się do miejsca wbicia halabardy.\n\nPo wbiciu się ostrza, wokół (przez 1 godzinę na promieniu 50 m) gęstnieje szkarłatna aura. Każda istota (niebędąca członkiem Kultu) wchodząca lub przebywająca w strefie na początku swojej tury otrzymuje 2k6 obrażeń ignorujących Pancerz oraz wszystkie odporności oraz wykonuje Test na Soul (Determinacja) (T:S, 6:2) lub popada w panikę/dezorientację (opis narracyjny zależny od MG).\n\nW miejscu wbicia tworzy się trwały energetyczny obelisk (Szkarłatny Znak), działający jak fokus dla dalszych zaklęć Magii Szkarłatu (wszystkie zaklęcia Magii Szkarłatu rzucane przez członków Szkarłatnego Kultu są o jeden stopień prostsze, do minimalnej Trudności równej 3).\n\nUczestnicy rytuału podlegają “szkarłatnym snom” przez 7 dni, a ponadto wszystkie zaklęcia nienależące do dziedziny Magii Szkarłatu przez ten czas wymagają 3 Sukcesów więcej. “Szkarłatne sny” wpływają na zachowanie postaci (Mistrz Gry decyduje, czy da się uniknąć wykonywania poleceń “szeptów”).\n\nRequiem zawsze było zarówno narzędziem destrukcji, jak i rozłamu rzeczywistości — halabarda powstająca z esencji “Szkarłatnego Wymiaru” nie tylko umożliwia wtargnięcie w centrum bitwy, ale natychmiast wywołuje falę energii dezorganizującą, rozbijającą linie wroga i uderzającą w samą czasoprzestrzeń. Pozostaje symbolem siły Kultu i jego destruktywnego potencjału."
|
||||
},
|
||||
"Telekineza": {
|
||||
"name": "Telekinesis",
|
||||
"description": "Postać może dowolnie przesuwać wybranym obiektem, ale ten obiekt musi pozostać w 20 m od rzucającego to zaklęcie."
|
||||
},
|
||||
"Teleportacja": {
|
||||
"name": "Teleport",
|
||||
"description": "Postać wyobraża sobie cel, do którego chce się przeteleportować, bądź nawiązuje mentalne połączenie z Węzłem Teleportacyjnym, a następnie przez 10 minut zbiera moc potrzebną do rzucenia zaklęcia. Jeśli nic nie przerwie procesu, to postać natychmiast przenosi się do wybranego miejsca."
|
||||
},
|
||||
"Prawdziwa Dezintegracja": {
|
||||
"name": "True Disintegration",
|
||||
"description": "Niektóre istoty są tak potężne, że najpotężniejsi i najmądrzejsi magowie wolą przed nimi uciekać - umiejętności wypaczające rzeczywistość, rozszarpujące duszę, czy sprawiające tak olbrzymie cierpienie że wszystkie piekielne tortury są przy nim niczym to tylko jedne z nielicznych rzeczy, które sprawiały, że wszystkie ekspedycje mające na celu zgładzenie lub zbadanie takiej istoty znikały bez śladu.\n\nJednak pewnego dnia jednemu magowi, który stracił poczytalność po spotkaniu z jedną z takich istot, udało się uciec i przeżyć. Dzięki bardzo długim i wymagającym próbom i obliczeniom udało się mu wymyślić zaklęcie wzorowane na jednej z umiejętności istoty, którą napotkał.\n\nTak oto powstało Superzaklęcie Prawdziwej Dezintegracji.\n\nBędąc zauważalnie prostsze od “standardowych” superzaklęć, niestety jest odpowiednio słabsze.\n\nCiemnozielony promień wystrzeliwywany z ręki przewodzącego rutuałowi maga owszem, niszczy wszystko na swojej drodze, jednak ma niezwykle krótki zasięg.\n\nWiązka niszczy wszystko na swojej drodze - ściany, meble, przedmioty, broń, artefakty. Żaden materiał nie jest odporny.\n\nJeżeli to zaklęcie trafi w żywą istotę, to należy porównać najwyższą wartość Atrybutu (Body, Mind, Soul) celu z ilością Sukcesów wyrzuconych podczas rzucania zaklęcia.\n\nJeżeli ilość Sukcesów jest mniejsza, to wtedy cel:\n* traci zaokrągloną w górę ⅓ wartości każdego Atrybutu,"
|
||||
},
|
||||
"Przebłysk Prawdy": {
|
||||
"name": "Truth Glimpse",
|
||||
"description": "Dotknięty cel przez krótki czas znacznie łatwiej jest w stanie określić prawdomówność innych.\n\nWszystkie Testy związane z wykrywaniem kłamstw, rozumieniem intencji oraz analizowaniem zachowań mają bonus +1 kości."
|
||||
},
|
||||
"Superzaklęcia": {
|
||||
"name": "Superspells"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"label": "Spells - Sacred",
|
||||
"entries": {
|
||||
"Przyspieszone Żniwa": {
|
||||
"name": "Accelerated Harvest",
|
||||
"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.\n\nKażde kolejne siewy będą mniej obfite, chyba że pole zostanie ponownie poświęcone.\n\nTo zaklęcie może zostać rzucone tylko raz na dzień, raz w sezonie na konkretny obszar."
|
||||
},
|
||||
"Egida": {
|
||||
"name": "Aegis",
|
||||
"description": "Zeus-Jowisz otacza wybrany cel swoją ochroną.\n\nCel otrzymuje +1 Pancerza, a ponadto wszystkie ataki kierowanie w cel są trudniejsze o jeden stopień."
|
||||
},
|
||||
"Tajemne Poznanie": {
|
||||
"name": "Arcane Cognition",
|
||||
"description": "Hekate dzieli się fragmentem swojej wiedzy magicznej z jej wyznawcą oraz jego towarzyszami.\n\nWszystkie osoby w polu (wliczając maga) otrzymują +1 kość do testów związanych z Umiejętnościami Wiedza Magiczna i Teologia.\n\n---"
|
||||
},
|
||||
"Strzała Pożądania": {
|
||||
"name": "Arrow Of Desire",
|
||||
"description": "Mag wystrzeliwuje magiczną, niematerialną strzałę przesyconą energią Amora-Erosa, która trafia wybranego przeciwnika, zadając mu tyle punktów Obrażeń, ile wynosi wartość Zdolności Magiczne maga, a ponadto jeżeli wartość Obrażeń jest większa niż wartość Atrybutu Soul celu, to zostaje on Zauroczony na jedną rundę.\n\nPo zakończeniu zaklęcia cel dowiaduje się, że został Zauroczony."
|
||||
},
|
||||
"Przebudzenie": {
|
||||
"name": "Awakening",
|
||||
"description": "Postać zmienia się w wybrane zwierzę. Postać przemienia się z powrotem w swoją oryginalną formę, jeżeli czas zaklęcia upłynie, zdecyduje się na to z własnej woli, lub jeżeli forma, którą Postać przyjęła, straci wszystkie punkty Żywotności. Statystyki zwierząt znajdują się w Bestiariuszu."
|
||||
},
|
||||
"Piękno Bogini": {
|
||||
"name": "Beauty Of The Goddess",
|
||||
"description": "Mag otacza się aurą boskiego piękna, zyskując trzy dodatkowe kości do Testów związanych z Umiejętnością Przekonywanie, a ponadto agresywne działania wobec maga wymagają Testu na Koncentrację (patrz: Stan Koncentracja), gdzie wymagana liczba Sukcesów (S) jest równa liczbie wyrzuconych przez maga Sukcesów. W wypadku niepowodzenia istota, która próbowała podjąć to działanie, traci Akcję."
|
||||
},
|
||||
"Ostrze Dawida": {
|
||||
"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).\n\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."
|
||||
},
|
||||
"Więź Serc": {
|
||||
"name": "Bond Of Hearts",
|
||||
"description": "Tworzy głęboką empatyczną więź pomiędzy dwiema osobami, ułatwiając komunikację i rozwiązywanie konfliktów. Obojgu przysługuje premia +2 Sukcesów do wszystkich Testów wymagających współpracy.\n\nTego Zaklęcia nie można użyć w trakcie walki, a ponadto cele muszą wyrazić zgodę.\n\nTo zaklęcie nie może mieć w żaden sposób zwiększonej ilości Celów."
|
||||
},
|
||||
"Pomost": {
|
||||
"name": "Bridge",
|
||||
"description": "Wybrana część ciała zamienia się w zwierzęcy odpowiednik. Wraz z Mistrzem Gry należy ustalić efekt, który ta zmiana daje."
|
||||
},
|
||||
"Błyskawice Łańcuchowe": {
|
||||
"name": "Chain Lightning",
|
||||
"description": "Błyskawica wystrzeliwuje z ręki Postaci i uderza w wybranego wroga, a następnie przeskakuje na następne cele.\n\nWybrany cel, a następnie wybierz następny cel, który jest w promieniu 5 m. Od tego celu wybierz kolejny cel w promieniu 5 m, który nie był jeszcze celem tego zaklęcia. Wszystkie cele otrzymują tyle punktów Obrażeń, ile wynosi wartość Umiejętności Zdolności Magiczne lub Oddanie Postaci. Za każdy niezużyty łańcuch wybrany przez Postać cel otrzymuje 1k3 punktów Obrażeń."
|
||||
},
|
||||
"Urok Niewinności": {
|
||||
"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ą."
|
||||
},
|
||||
"Strzała Kupidyna": {
|
||||
"name": "Cupids Arrow",
|
||||
"description": "Magiczna strzała po trafieniu w cel wzbudza w nim silne uczucie sympatii i zauroczenia wobec maga, sprawiając, że staje się Zauroczony.\n\nPo zakończeniu zaklęcia cel dowiaduje się, że został Zauroczony."
|
||||
},
|
||||
"Klątwa Hekate": {
|
||||
"name": "Curse Of Hekate",
|
||||
"description": "Wybrany obszar pokrywa się fioletowymi płomieniami, które znacznie osłabiają wszystkich niewiernych magów.\n\nOsoby w polu muszą wykonać udany Test na Magic (Determinacja) 4:S, gdzie liczba wymaganych sukcesów (S) jest równa liczbie wyrzuconych przez maga sukcesów. W wypadku niepowodzenia postać wykonuje wszystkie Testy związane z Umiejętnością Zdolności Magiczne z jedną kością mniej oraz jest Przerażona do końca trwania zaklęcia."
|
||||
},
|
||||
"Boska Celność": {
|
||||
"name": "Divine Accuracy",
|
||||
"description": "Bogini Łowów błogosławi wybraną osobę, ułatwiając jej celowanie. Do końca trwania zaklęcia cel otrzymuje trzy dodatkowe kości do wszystkich Testów związanych z Umiejętnością Umiejętności Strzeleckie."
|
||||
},
|
||||
"Boska Sprawiedliwość": {
|
||||
"name": "Divine Justice",
|
||||
"description": "Krótka, pełna żaru modlitwa wzywająca boską sprawiedliwość przeciwko wrogom wiary.\n\nMag kieruje boski gniew w wybrany przez siebie cel, który otrzymuje ilość obrażeń równą zaokrągloną w górę połowie wartości Zdolności Magiczne lub Oddanie maga. Jeżeli jest innowiercą, otrzymuje dodatkowe 1k6 obrażeń.\n\n---"
|
||||
},
|
||||
"Boska Włócznia": {
|
||||
"name": "Divine Spear",
|
||||
"description": "Bogini Łowów wręcza Ci włócznię, którą następnie możesz cisnąć we wroga.\n\nWybierz wroga w zasięgu 15 m. Cel otrzymuje tyle punktów Obrażeń, które ignorują Pancerz, ile wynosi wartość Umiejętności Zdolności Magiczne lub Oddanie Postaci. Każdy dodatkowy Sukces zwiększa zadawane Obrażenia o 1k3."
|
||||
},
|
||||
"Moc Ziemi": {
|
||||
"name": "Earth Power",
|
||||
"description": "Mag zaczyna czerpać siłę życiową z ziemi pod stopami. Dopóki stoi na naturalnej glebie, odzyskuje tyle punktów Żywotności, ile wynosi wartość Umiejętności Zdolności Magiczne lub Oddanie maga oraz wartość Pancerza zostaje tymczasowo zwiększona o 1.\n\nTego Pancerza nie da się zniszczyć."
|
||||
},
|
||||
"Wyładowanie Elektrostatyczne": {
|
||||
"name": "Electrostatic Discharge",
|
||||
"description": "Teren dookoła postaci zaczyna iskrzyć od wyładowań elektrostatycznych.\n\nWszystkie istoty w promieniu 2 m od Postaci tyle punktów Obrażeń ignorujących Pancerz, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne lub Oddanie maga, a następnie muszą wykonać Test 4:S na Wytrzymałość (Body), gdzie Sukcesy (S) wymagane do zdania Testu przez cel są równe ilości sukcesów wyrzuconych przez Postać. W wypadku nieudanego Testu cel zostaje Przewrócony."
|
||||
},
|
||||
"Fanatyczne Poświęcenie": {
|
||||
"name": "Fanatical Sacrifice",
|
||||
"description": "Ludzie, pomimo tego że są znani z postępowości, są mimo wszystko najbardziej fanatyczną rasą jeśli chodzi o wierzących przedstawicieli tej rasy. Szczególnie jeśli weźmiemy pod uwagę wyznawców chrześcijaństwa i islamu (w końcu to ten sam Bóg). Nieczęsto fanatyzm ludzkich wyznawców Boga-Jahwe-Jedynego wprawia w osłupienie i niedowierzanie anioły.\n\nLudzkość jest skłonna do poświęceń, które dla innych ras byłyby nie do pomyślenia.\n\nMag otrzymuje podwójne obrażenia, ale zadaje podwójne obrażenia w Walce Wręcz. Dodatkowo obrażenia zadawane przez maga w Walce Wręcz ignorują Pancerz celu i są traktowane jako obrażenia magiczne, a ponadto wszystkie trafione przez maga ataki w Walce Wręcz sprawiają, że przeciwnicy stają się Przerażeni."
|
||||
},
|
||||
"Płomień Namiętności": {
|
||||
"name": "Flame Of Passion",
|
||||
"description": "Cel doświadcza intensywnych emocji i namiętności (miłosnej, twórczej lub\ndowolnej pasji wybranej przez maga), zyskując dwie dodatkowe kości do Testów związanych z Umiejętnościami Empatia i Przekonywanie, ale dostając również karę do Testów związanych z Atrybutem Mind, które cel musi wykonywać z jedną kością mniej do końca trwania zaklęcia."
|
||||
},
|
||||
"Uzdrowienie Chorych": {
|
||||
"name": "Heal Sick",
|
||||
"description": "Mag dotyka chorego, na którego spływa łaska Boga-Jahwe-Jedynego.\n\nZ wybranej osoby usuwana jest jedna choroba lub trucizna. Jeśli choroba jest trudna do uleczenia (lub trucizna trudna do usunięcia), to choroba cofa się o jedno stadium. Mistrz Gry określa trudność danej choroby do uleczenia."
|
||||
},
|
||||
"Leczący Dotyk": {
|
||||
"name": "Healing Touch",
|
||||
"description": "Na dotkniętą osobę spływa święta moc, zasklepiająca rany. Cel odzyskuje ilość punktów Żywotności równą wartości Umiejętności Zdolności Magiczne lub Oddanie maga."
|
||||
},
|
||||
"Tęsknota Serca": {
|
||||
"name": "Heart Longing",
|
||||
"description": "Wskazany cel zaczyna obsesyjnie tęsknić za wybranym przez maga obiektem uczuć.\n\nTrudność ze skupieniem się na innych zadaniach oraz chęć odnalezienia lub zobaczenia obiektu tęsknoty sprawia, że cel otrzymuje karę -1 kości do wszystkich Testów związanych z Umiejętnością Determinacja.\n\nTego Zaklęcia nie można użyć w trakcie walki."
|
||||
},
|
||||
"Święta Stal": {
|
||||
"name": "Holy Steel",
|
||||
"description": "Wybrana broń biała po dotknięciu pokrywa się białym płomieniem.\n\nBroń zadaje 1 dodatkowy punkt obrażeń magicznych. Cel po trafieniu wykonuje wszystkie testy na Refleks z 1 kością mniej do początku następnej tury osoby rzucającej zaklęcie."
|
||||
},
|
||||
"Chmara Insektów": {
|
||||
"name": "Insect Swarm",
|
||||
"description": "Na wybranym obszarze pojawia się chmara insektów, obniżająca Pancerz o 1 oraz zmniejszając prędkość Ruchu o 50% wszystkim istotom znajdującym się w wybranym Obszarze.\n\n> Kelner, kelner! Milion os proszę!"
|
||||
},
|
||||
"Sąd Ostateczny": {
|
||||
"name": "Last Judgment",
|
||||
"description": "Mag wzywa potęgę Boga-Jahwe-Jedynego, który osobiście osądza wskazanego grzesznika lub innowiercę.\n\nWybrany cel otrzymuje tyle punktów obrażeń, ile wynosi trzykrotność wartości Umiejętności Zdolności Magiczne lub Oddanie maga.\n\nPonadto, jeśli cel jest innowiercą lub jest demonem bądź nieumarłym, a wartość Umiejętności Oddanie maga jest większa bądź równa wartości Atrybutu Soul, to cel natychmiast umiera. Jeśli cel jest ważną postacią, to traci tyle punktów Żywotności, ile wynosi zaokrąglona w górę."
|
||||
},
|
||||
"Wzmocnienie Magii": {
|
||||
"name": "Magic Empowerment",
|
||||
"description": "Hekate spogląda przychylnie na wiernego jej maga i na chwilę pozwala mu korzystać z ułamka jej mocy.\n\nMag przez następne dwie rundy otrzymuje cztery dodatkowe kości do wszystkich rzutów związanych z Umiejętnością Zdolności Magiczne."
|
||||
},
|
||||
"Magiczne Przyspieszenie": {
|
||||
"name": "Magic Haste",
|
||||
"description": "Mag poświęca część swojej mocy ku czci Hekate, która w zamian pomaga wybranej przez maga osobie, regenerując mu 1 punkt Zapału."
|
||||
},
|
||||
"Kradzież Many": {
|
||||
"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.\n\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."
|
||||
},
|
||||
"Modlitwa o Opiekę": {
|
||||
"name": "Prayer For Protection",
|
||||
"description": "Krótka modlitwa o ochronę i błogosławieństwo. Każda osoba w promieniu 2 m od maga otrzymuje bonus +1 kość do Testów na unikanie efektów uroków, zaklęć i klątw."
|
||||
},
|
||||
"Obecność Króla Bogów": {
|
||||
"name": "Presence Of The King Of Gods",
|
||||
"description": "Zeus-Jowisz obdarowuje swojego wyznawcę swoim autorytetem.\n\nPostać otrzymuje Autorytet. Sukcesy (S) wymagane do zdania Testu przez cel są równe ilości sukcesów wyrzuconych przez Postać.\n\n---"
|
||||
},
|
||||
"Promień Harmonii": {
|
||||
"name": "Ray Of Harmony",
|
||||
"description": "Mag zaczyna emanować aurą harmonii i zgody o promieniu 2 m (mechanicznie reprezentowanym przez kwadrat o boku 5 m), która dodatkowo rozświetla ten obszar delikatnym, różowawym światłem.\n\nKażda postać w zasięgu działania zaklęcia musi wykonać Test na Koncentrację (patrz: Stan Koncentracja), gdzie wymagana liczba Sukcesów (S) jest równa liczbie wyrzuconych przez maga Sukcesów. W wypadku niepowodzenia konflikty i spory danej postaci są automatycznie tłumione, a ponadto zyskuje ona premię dwóch dodatkowych kości do wszystkich rzutów związanych z negocjacjami i rozwiązywaniem sporów."
|
||||
},
|
||||
"Uświęcenie": {
|
||||
"name": "Sanctification",
|
||||
"description": "Broń zadaje obrażenia magiczne zamiast fizycznych."
|
||||
},
|
||||
"Cykl Pór Roku": {
|
||||
"name": "Seasons Cycle",
|
||||
"description": "Zaklęcie pozwala przejściowo zmienić „mikroklimat” na obszarze (np. przyspieszyć nadejście wiosny, wywołać letnią pogodę, zaostrzyć jesienne chłody lub pozycję słońca). Efekt dotyczy klimatu, roślin i pogody na ograniczonym terenie."
|
||||
},
|
||||
"Pożądanie Zmysłów": {
|
||||
"name": "Senses Desire",
|
||||
"description": "Zmysły i pożądanie celu zostają intensywnie pobudzone, co sprawia, że cel staje się bardziej wylewny, odważny i skłonny do romantycznych deklaracji - dostaje bonus +5 kości we wszystkich Testach związanych z relacjami romantycznymi, jednak w zamian dostaje karę -1 kości do wszystkich Testów związanych z Atrybutem Mind oraz jeżeli dany Test jest związany z opieraniem się pokusom, dodatkową karę na wszystkie Testy związane z Umiejętnością Determinacja."
|
||||
},
|
||||
"Błogosławieństwo Zasiewów": {
|
||||
"name": "Sowing Blessing",
|
||||
"description": "Mag prosi boginię o otoczenie nasion, pola lub ogrodu aurą płodności. Rośliny rosną szybciej, są odporne na większość chorób oraz mniej podatne na suszę i szkodniki.\n\nIlość plonów z tego obszaru zostaje zwiększona o 20% w danym sezonie.\n\nTo zaklęcie może zostać rzucone tylko raz na dzień, raz w sezonie na konkretny obszar."
|
||||
},
|
||||
"Nić Przeznaczenia": {
|
||||
"name": "Thread Of Destiny",
|
||||
"description": "Mag tworzy magiczną więź przeznaczenia między dwiema osobami. Obie mają wrażenie wzajemnego przyciągania, łatwiej się dogadują i silniej odczuwają obecność oraz emocje drugiej osoby.\n\nObydwa cele wykonują Test na Soul (Empatia) 4:S, gdzie wymagana liczba Sukcesów (S) jest równa liczbie wyrzuconych przez maga Sukcesów. Jeżeli przynajmniej jeden cel zda Test, zaklęcie się nie udaje.\n\nW innym wypadku obojgu przysługuje premia +3 Sukcesów do wszystkich Testów wymagających współpracy, ale jeżeli nie widzą się i są od siebie dalej niż 20 m, to obydwa cele otrzymują karę -2 Sukcesów we wszystkich Testach oraz zablokowanie premii do ponownego spotkania. Ponadto jeżeli jeden cel otrzyma obrażenia, to trzecia część tych obrażeń jest przenoszona na drugi cel.\n\nTego Zaklęcia nie można użyć w trakcie walki\n\nTo zaklęcie nie może mieć w żaden sposób zwiększonej ilości Celów."
|
||||
},
|
||||
"Grom z Nieba": {
|
||||
"name": "Thunderbolt",
|
||||
"description": "Na uniżoną prośbę swojego wyznawcy, Zeus-Jowisz ciska swoim piorunem w wybrany punkt.\n\nWszyscy przeciwnicy w wybranym obszarze muszą wykonać Test 4:S na Wytrzymałość (Body), gdzie Sukcesy (S) oznacza ilość sukcesów wyrzuconą przez Postać. Każdy przeciwnik, który nie zda Testu, otrzymuje tyle punktów Obrażeń, ile wynosi dwukrotność wartości Umiejętności Zdolności Magiczne lub Oddanie Postaci, oraz zostaje Ogłuszony."
|
||||
},
|
||||
"Mądrość Salomona": {
|
||||
"name": "Wisdom Of Solomon",
|
||||
"description": "Mag prosi o radę Boga-Jahwe-Jedynego, który dzieli się ze swoim wyznawcą fragmentem swojej mądrości.\n\nMag przez następną godzinę otrzymuje +1 kość do wszystkich testów związanych z Atrybutem Mind."
|
||||
},
|
||||
"Gniew Matki Ziemi": {
|
||||
"name": "Wrath Of Mother Earth",
|
||||
"description": "Rośliny, korzenie i ziemia aktywnie bronią obszaru przed tymi, którzy go niszczą: pędy oplatają, trawa gęstnieje, korzenie podcinają nogi, a ciernie wbijają się w intruzów.\n\nKażda istota, która wchodzi na dany obszar lub się w nim przemieszcza, otrzymuje tyle punktów Obrażeń, ile wynosi zaokrąglona w górę połowa wartości Umiejętności Zdolności Magiczne lub Oddanie maga.\n\n---"
|
||||
},
|
||||
"Amor (Eros)": {
|
||||
"name": "Cupid (Eros)"
|
||||
},
|
||||
"Zeus (Jowisz)": {
|
||||
"name": "Zeus (Jupiter)"
|
||||
},
|
||||
"Afrodyta (Wenus)": {
|
||||
"name": "Aphrodite (Venus)"
|
||||
},
|
||||
"Hekate": {
|
||||
"name": "Hecate"
|
||||
},
|
||||
"Demeter (Ceres)": {
|
||||
"name": "Demeter (Ceres)"
|
||||
},
|
||||
"Modlitwy Ogólne": {
|
||||
"name": "General Prayers"
|
||||
},
|
||||
"Bóg (Jedyny)": {
|
||||
"name": "God (The One)"
|
||||
},
|
||||
"Artemida (Diana)": {
|
||||
"name": "Artemis (Diana)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Superzaklęcie - Requiem": {
|
||||
"name": "Superspell Requiem",
|
||||
"description": "W dawnych kronikach Requiem symbolizowało narzędzie “ostatecznej separacji” - poprzez halabardę otwierano szczeliny w rzeczywistości, by elita Kultu mogła błyskawicznie przedrzeć się przez obronę, zasiewając zamęt i rozbicie wśród wrogów. Nikogo nie zabiera, nie drenuje życia, lecz wnosi do świata esencję Szkarłatu: rozprężenie struktur, dezorientację i przewagę dla wtajemniczonych.\n\nW ręce maga pojawia się utkana ze szkarłatnej energii halabarda, którą następnie może rzucić w wybrany przez siebie cel.\n\nGdy halabarda wbija się w wyznaczone miejsce, natychmiast zadaje wszystkim istotom w promieniu 10 metrów 3k6 obrażeń ignorujących Pancerz oraz wszystkie odporności, następnie mag (oraz do 2 sprzymierzeńców w promieniu 2 m) natychmiast teleportuje się do miejsca wbicia halabardy.\n\nPo wbiciu się ostrza, wokół (przez 1 godzinę na promieniu 50 m) gęstnieje szkarłatna aura. Każda istota (niebędąca członkiem Kultu) wchodząca lub przebywająca w strefie na początku swojej tury otrzymuje 2k6 obrażeń ignorujących Pancerz oraz wszystkie odporności oraz wykonuje Test na Soul (Determinacja) (T:S, 6:2) lub popada w panikę/dezorientację (opis narracyjny zależny od MG).\n\nW miejscu wbicia tworzy się trwały energetyczny obelisk (Szkarłatny Znak), działający jak fokus dla dalszych zaklęć Magii Szkarłatu (wszystkie zaklęcia Magii Szkarłatu rzucane przez członków Szkarłatnego Kultu są o jeden stopień prostsze, do minimalnej Trudności równej 3).\n\nUczestnicy rytuału podlegają “szkarłatnym snom” przez 7 dni, a ponadto wszystkie zaklęcia nienależące do dziedziny Magii Szkarłatu przez ten czas wymagają 3 Sukcesów więcej. “Szkarłatne sny” wpływają na zachowanie postaci (Mistrz Gry decyduje, czy da się uniknąć wykonywania poleceń “szeptów”).\n\nRequiem zawsze było zarówno narzędziem destrukcji, jak i rozłamu rzeczywistości — halabarda powstająca z esencji “Szkarłatnego Wymiaru” nie tylko umożliwia wtargnięcie w centrum bitwy, ale natychmiast wywołuje falę energii dezorganizującą, rozbijającą linie wroga i uderzającą w samą czasoprzestrzeń. Pozostaje symbolem siły Kultu i jego destruktywnego potencjału."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"label": "Talents - Curse of the Abyss",
|
||||
"entries": {
|
||||
"Swobodny Przepływ Magii": {
|
||||
"name": "Free Flow Of Magic",
|
||||
"description": "**Wymagania**: Dziedzina Magii (Magic Otchłani (Aspektów))\n\nAby móc otrzymać ten Talent, mag musi odrzucić swoją poprzednią dziedzinę i poświęcić się w pełni tej dziedzinie magii. Zmiana dziedziny magii jest możliwa wyłącznie jeden raz.\n\nMag może rzucać zaklęcia z innych Akademickich Dziedzin Magii niż jego pierwotna.\nMoże uczyć się zaklęć z Dziedzin innej niż swoja, ale są one o jeden punkt Many droższe, a ponadto do rzucania takich zaklęć wymagana jest esencja w formie płynnej. Mag natomiast nie może uczyć się ani rzucać modlitw. Ilość i potrzebne rodzaje esencji określa Mistrz Gry, natomiast zasada jest taka, że za każdy punkt Many, który kosztuje to zaklęcie, należy dodać kolejną fiolkę 50 ml Esencji. Fiolka może zostać podzielona na porcje - jedna fiolka zawiera pięć porcji Esencji.\n\nMagowie Aspektów mogą się ukrywać jako magowie swojej pierwotnej dziedziny"
|
||||
},
|
||||
"Dar Otchłani": {
|
||||
"name": "Gift Of The Abyss",
|
||||
"description": "**Wymagania**: Dziedzina Magii (Magic Otchłani (Pierwotna Magic)), Talent (Swobodny Przepływ Magii)\n\nAby móc otrzymać ten Talent, mag musi odrzucić swoją poprzednią dziedzinę i poświęcić się w pełni tej dziedzinie magii. Mag musi być obecnie Magiem Aspektów oraz musi być magiem poziomu co najmniej II oraz musi mieć talent Zakazana Wiedza (Otchłań). Zmiana Dziedziny magii jest możliwa wyłącznie jeden raz. Ten Talent zastępuje Talent “Swobodny Przepływ Magii”.\n\nMag może rzucać zaklęcia ze wszystkich Akademickich Dziedzin Magii oraz ich się uczyć.\nNie musi wykorzystywać many, ale za każdy punkt Many, który kosztuje zaklęcie, musi osiągnąć jeden sukces w Teście Magic (Zdolności Magiczne), gdzie Trudność (T) wynosi 5. W wypadku niepowodzenia zaklęcie się nie udaje, a mag musi rzucać tyloma kośćmi na tabelę Kary za Nieudolność, ile brakowało mu sukcesów do zdania Testu.\n\nMag Pierwotnej Magii traci wszelki związek ze swoją pierwotną dziedziną magii - jego ślad magiczny zmienia się nie do poznania."
|
||||
},
|
||||
"Metamagia": {
|
||||
"name": "Metamagic",
|
||||
"description": "*Ten Talent można wykupić wyłącznie podczas tworzenia postaci lub jeżeli Postać staje się Magiem Otchłani.*\n\n**Wymagania**: Dziedzina Magii (Wiedźmia Magic, Magic Alchemii (Transmutacji) lub Magic Otchłani)\n\nTego Talentu nie można wziąć jeżeli Postać ma już Talent Odporność na Magię lub Jedność z Magią.\n\nNiektórzy Magowie są w stanie zjednoczyć się z magią, jednak w przeciwieństwie do ras takich jak elfy, których magia jest naturą, jedność nie polega na umiejętności kierowania i rozkazywania magii, lecz na umiejętności zrozumienia i manipulacji magią w otoczeniu.\n\nTen Talent można brać wielokrotnie do maksymalnie dwóch dodatkowych razy, za każdym razem zwiększając maksymalną ilość Punktów Metamagii o 2.\n\nPostać może mieć jednocześnie tyle punktów Magii, ile wynosi zaokrąglona w górę połowa wartości Atrybutu Soul + 1. Każda z Akcji Metamagii może być wykonana wyłącznie raz w trakcie rundy oraz nie mogą wpływać na to samo zaklęcie, chyba że jest napisane inaczej.\n\nWszystkie Punkty Metamagii odnawiają się po Długim Odpoczynku.\n\nPostać z tym Talentem otrzymuje dostęp do następujących Akcji:\n#### Rozdwojone Zaklęcie\n**Koszt**: 3 Punkty Metamagii\n**Wymagania**: Zaklęcie, którego celem są pojedyncze cele i Nadczarowanie nie zwiększyło ilości celów.\n\nZaklęcie rzucane przez Postać działa na dodatkowy cel.\n\n#### Niezniszczalne Zaklęcie\n**Koszt**: 1 Punkt Metamagii\n\nPostać rzuca kością 1k3. Zaklęcie wymaga tyle dodatkowych Sukcesów do rozplecenia, ile wyniósł ten rzut.\n\n#### Rozproszenie Magii\n**Koszt**: 2 Punkty Metamagii, 1 Punkt Zapału\n**Wymagania**: Inna Postać rzucająca zaklęcie\n\nGdy wybrany cel rzuca zaklęcie, Postać może rzucić kością 1k3. Rzucane przez cel zaklęcie wymaga tyle dodatkowych Sukcesów, ile wyniósł ten rzut.\n\n#### Błyskawiczne Czarostwo\n**Koszt**: 2 Punkty Metamagii\n\nZaklęcie wymaga jednej tury mniej do rzucenia.\n\n---"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"label": "Talents - Blood Magic",
|
||||
"entries": {
|
||||
"Krwisty Pojedynkowicz": {
|
||||
"name": "Krwisty Pojedynkowicz",
|
||||
"description": "*Ten Talent zastępuje Talent “Szacunek do Życia”.*\n*Tego Talentu nie można wziąć jeżeli Postać ma już Talent Rytualista Krwi.*\n**Wymagania**: Talent (Szacunek do Życia)\n\nZdecydowałeś się na życie włóczęgi, żywiąc się siłami życiowymi pokonanych przez siebie przeciwników.\n\nKażdą walkę zaczynasz z zeroma punktami Krwi. Punkty Krwi możesz wydawać na niektóre Akcje lub rzucanie zaklęć. Zamiast rzucać (lub nadczarowywać) zaklęcie z użyciem Many, możesz do tego celu użyć Punktów Krwi w ilości równej ilości potrzebnej Many + 1. Punkty Krwi na koniec walki znikają, lecząc maga (zgodnie z Akcją “Większe Wchłanianie Krwi”).\n\nKażde utracone przez Ciebie (w wyniku ataków wrogów) oraz Twoich wrogów (w wyniku Twoich ataków) dwa punkty Żywotności w wyniku ataków broniami przebijającymi lub tnącymi sprawiają, że zyskujesz 1 punkt Krwi.\n\nPostać zyskuje dostęp do następujących Akcji:\n#### Większe Samookaleczenie\n*Ta Akcja zastępuje Akcję “Samookaleczenie”*\n**Koszt**: Akcja Natychmiastowa\n\nMag używa ostrego lub kłującego narzędzia, aby się zranić. Mag wykonuje Test na Magic (Walka Wręcz) i traci tyle punktów Żywotności, ile wyrzuci Sukcesów. Trudność (T) tego Testu wynosi “2”. Za każdy utracony punkt Żywotności mag zyskuje 2 punkty Krwi.\n\nAkcja ta może być wykonana wyłącznie raz na rundę.\n#### Większa Kradzież Życia\n*Ta Akcja zastępuje Akcję “Kradzież Życia”*\nKoszt: 1 punkt Zapału\n\nMag przeklina swoją broń, sprawiając, że następny atak odda mu siły życiowe jego przeciwnika. Atak jest o dwa stopnie prostszy, a ponadto zyskuje 2 punkty Krwi za każdy utracony przez cel punkt Żywotności.\n\nAkcja ta może być wykonana wyłącznie dwa razy na rundę.\n#### Większe Wchłanianie Krwi\nTa 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."
|
||||
},
|
||||
"Szacunek do Życia": {
|
||||
"name": "Respect For Life",
|
||||
"description": "**Wymagania**: Dziedzina Magii (Magic Krwi)\n\nJesteś Magiem Krwi i rozumiesz, jak cenne jest życie…\n\n…Twoje życie. Inni są tylko środkiem do celu.\n\nPrzed Tobą jeszcze długa droga, jednak już teraz Twoja moc pozwala Ci śmiać się w twarz tym wszystkim magom “akademickim”, niepotrafiącym pojąć potęgi, jaką daje poświęcanie sił życiowych.\n\nKażdą walkę zaczynasz z zeroma punktami Krwi. Punkty Krwi możesz wydawać na niektóre Akcje lub rzucanie zaklęć. Zamiast rzucać (lub nadczarowywać) zaklęcie z użyciem Many, możesz do tego celu użyć Punktów Krwi w ilości równej ilości potrzebnej Many + 1. Punkty Krwi na koniec walki znikają, lecząc maga (zgodnie z Akcją “Wchłanianie Krwi”).\n\nKażde utracone przez Ciebie (w wyniku ataków wrogów) oraz Twoich wrogów (w wyniku Twoich ataków) cztery punkty Żywotności w wyniku ataków broniami przebijającymi lub tnącymi sprawiają, że zyskujesz 1 punkt Krwi."
|
||||
},
|
||||
"Rytualista Krwi": {
|
||||
"name": "Rytualista Krwi",
|
||||
"description": "*Ten Talent zastępuje Talent “Szacunek do Życia”.*\n*Tego Talentu nie można wziąć jeżeli Postać ma już Talent Krwisty Pojedynkowicz.*\n**Wymagania**: Talent (Szacunek do Życia)\n\nLata prac, badań i poświęcania niewinnych nauczyły Cię, że lepiej zbudować sobie schronienie i po cichu zyskiwać moc.\n\nKażdą walkę zaczynasz z czteroma punktami Krwi. Punkty Krwi możesz wydawać na niektóre Akcje lub rzucanie zaklęć. Zamiast rzucać (lub nadczarowywać) zaklęcie z użyciem Many, możesz do tego celu użyć Punktów Krwi w ilości równej ilości potrzebnej Many + 1. Punkty Krwi na koniec walki znikają, lecząc maga (zgodnie z Akcją “Wchłanianie Krwi”).\n\nPostać zyskuje dostęp do następującego Celu: \n#### Ołtarz Krwi\nOłtarz Krwi jest budowany przez rok w wybranym przez Postać bezpiecznym, ukrytym miejscu. Wymaga również dużej ilości ofiar. Po ukończeniu budowy, tak długo jak ołtarz istnieje i otrzymuje wystarczającą ilość ofiar, mag otrzymuje bonus dwóch kości do wszystkich rzutów związanych z Atrybutem Magic, a jeżeli jest w pobliżu ołtarza - zamiast tego cztery kości lub dwa Sukcesy.\n\nPostać zyskuje dostęp do następującej Akcji:\n#### Wielka Ofiara\n*Ta Akcja zastępuje Akcję “Złożenie w Ofierze”*\n**Koszt**: 1 punkt Zapału\n**Wymagania**: dowolna atakowana istota w promieniu 1 m\n\nMag używa ostrego lub kłującego narzędzia, aby poświęcić właśnie atakowaną istotę. Jeżeli ilość Sukcesów wyrzuconych przez maga jest większa niż wartość największego Atrybutu celu, to cel natychmiast traci wszystkie punkty Żywotności, w innym wypadku mag odzyskuje tyle punktów Żywotności, ile wyrzucił Sukcesów. Mag natychmiast zyskuje tyle punktów Krwi, ile cel stracił Punktów Żywotności.\n\nAkcja ta może być wykonana wyłącznie raz na rundę."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"label": "Talents - Curse of the Eldritch",
|
||||
"entries": {
|
||||
"Swobodny Przepływ Magii": {
|
||||
"name": "Free Flow Of Magic",
|
||||
"description": "**Wymagania**: Dziedzina Magii (Magic Otchłani (Aspektów))\n\nAby móc otrzymać ten Talent, mag musi odrzucić swoją poprzednią dziedzinę i poświęcić się w pełni tej dziedzinie magii. Zmiana dziedziny magii jest możliwa wyłącznie jeden raz.\n\nMag może rzucać zaklęcia z innych Akademickich Dziedzin Magii niż jego pierwotna.\nMoże uczyć się zaklęć z Dziedzin innej niż swoja, ale są one o jeden punkt Many droższe, a ponadto do rzucania takich zaklęć wymagana jest esencja w formie płynnej. Mag natomiast nie może uczyć się ani rzucać modlitw. Ilość i potrzebne rodzaje esencji określa Mistrz Gry, natomiast zasada jest taka, że za każdy punkt Many, który kosztuje to zaklęcie, należy dodać kolejną fiolkę 50 ml Esencji. Fiolka może zostać podzielona na porcje - jedna fiolka zawiera pięć porcji Esencji.\n\nMagowie Aspektów mogą się ukrywać jako magowie swojej pierwotnej dziedziny"
|
||||
},
|
||||
"Dar Otchłani": {
|
||||
"name": "Gift Of The Abyss",
|
||||
"description": "**Wymagania**: Dziedzina Magii (Magic Otchłani (Pierwotna Magic)), Talent (Swobodny Przepływ Magii)\n\nAby móc otrzymać ten Talent, mag musi odrzucić swoją poprzednią dziedzinę i poświęcić się w pełni tej dziedzinie magii. Mag musi być obecnie Magiem Aspektów oraz musi być magiem poziomu co najmniej II oraz musi mieć talent Zakazana Wiedza (Otchłań). Zmiana Dziedziny magii jest możliwa wyłącznie jeden raz. Ten Talent zastępuje Talent “Swobodny Przepływ Magii”.\n\nMag może rzucać zaklęcia ze wszystkich Akademickich Dziedzin Magii oraz ich się uczyć.\nNie musi wykorzystywać many, ale za każdy punkt Many, który kosztuje zaklęcie, musi osiągnąć jeden sukces w Teście Magic (Zdolności Magiczne), gdzie Trudność (T) wynosi 5. W wypadku niepowodzenia zaklęcie się nie udaje, a mag musi rzucać tyloma kośćmi na tabelę Kary za Nieudolność, ile brakowało mu sukcesów do zdania Testu.\n\nMag Pierwotnej Magii traci wszelki związek ze swoją pierwotną dziedziną magii - jego ślad magiczny zmienia się nie do poznania."
|
||||
},
|
||||
"Metamagia": {
|
||||
"name": "Metamagic",
|
||||
"description": "*Ten Talent można wykupić wyłącznie podczas tworzenia postaci lub jeżeli Postać staje się Magiem Otchłani.*\n\n**Wymagania**: Dziedzina Magii (Wiedźmia Magic, Magic Alchemii (Transmutacji) lub Magic Otchłani)\n\nTego Talentu nie można wziąć jeżeli Postać ma już Talent Odporność na Magię lub Jedność z Magią.\n\nNiektórzy Magowie są w stanie zjednoczyć się z magią, jednak w przeciwieństwie do ras takich jak elfy, których magia jest naturą, jedność nie polega na umiejętności kierowania i rozkazywania magii, lecz na umiejętności zrozumienia i manipulacji magią w otoczeniu.\n\nTen Talent można brać wielokrotnie do maksymalnie dwóch dodatkowych razy, za każdym razem zwiększając maksymalną ilość Punktów Metamagii o 2.\n\nPostać może mieć jednocześnie tyle punktów Magii, ile wynosi zaokrąglona w górę połowa wartości Atrybutu Soul + 1. Każda z Akcji Metamagii może być wykonana wyłącznie raz w trakcie rundy oraz nie mogą wpływać na to samo zaklęcie, chyba że jest napisane inaczej.\n\nWszystkie Punkty Metamagii odnawiają się po Długim Odpoczynku.\n\nPostać z tym Talentem otrzymuje dostęp do następujących Akcji:\n#### Rozdwojone Zaklęcie\n**Koszt**: 3 Punkty Metamagii\n**Wymagania**: Zaklęcie, którego celem są pojedyncze cele i Nadczarowanie nie zwiększyło ilości celów.\n\nZaklęcie rzucane przez Postać działa na dodatkowy cel.\n\n#### Niezniszczalne Zaklęcie\n**Koszt**: 1 Punkt Metamagii\n\nPostać rzuca kością 1k3. Zaklęcie wymaga tyle dodatkowych Sukcesów do rozplecenia, ile wyniósł ten rzut.\n\n#### Rozproszenie Magii\n**Koszt**: 2 Punkty Metamagii, 1 Punkt Zapału\n**Wymagania**: Inna Postać rzucająca zaklęcie\n\nGdy wybrany cel rzuca zaklęcie, Postać może rzucić kością 1k3. Rzucane przez cel zaklęcie wymaga tyle dodatkowych Sukcesów, ile wyniósł ten rzut.\n\n#### Błyskawiczne Czarostwo\n**Koszt**: 2 Punkty Metamagii\n\nZaklęcie wymaga jednej tury mniej do rzucenia.\n\n---"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"label": "Talents",
|
||||
"entries": {
|
||||
"Powietrzne Uniki": {
|
||||
"name": "Air Evasion",
|
||||
"description": "Gdy postać jest atakowana na dystans, to podwaja bonusowe kości z Umiejętności Refleks gdy unika ataku."
|
||||
},
|
||||
"Oburęczny": {
|
||||
"name": "Ambidextrous",
|
||||
"description": "Sprawnie posługujesz się bronią w obydwu rękach.\n\n\nGdy wykonujesz ataki dwiema broniami, otrzymujesz dwie dodatkowe kości"
|
||||
},
|
||||
"Arcymag (Dziedzina)": {
|
||||
"name": "Archmage",
|
||||
"description": "Wszystkie Testy związane z daną Dziedziną Magii są prostsze o jeden stopień, a ponadto wszystkie zaklęcia są traktowane, jakby były nadczarowane o jeden stopień (ale nie zużywają dodatkowej many). Dodatkowo postać zna wszystkie zaklęcia z tej Dziedziny.\n\n\nTen Talent może zostać wybrane dla maksymalnie jednej Dziedziny - tej, w której jest Mistrzem Magii."
|
||||
},
|
||||
"Pancerz Wiary": {
|
||||
"name": "Armor Of Faith",
|
||||
"description": "Możesz rzucać zaklęcia będąc w zbroi Średniej lub Ciężkiej bez kar."
|
||||
},
|
||||
"Gadanina": {
|
||||
"name": "Babble",
|
||||
"description": "Podwajasz liczbę kości wynikających z ilości Punktów Umiejętności w Umiejętności Przekonywanie jeżeli chcesz kogoś zagadać lub odwrócić czyjąś uwagę swoją gadaniną."
|
||||
},
|
||||
"Cios w Plecy": {
|
||||
"name": "Backstab",
|
||||
"description": "Jeśli cel nie wie o Twojej obecności, to obrażenia, które zadajesz, są podwajane.\n\n\nTen Talent działa wyłącznie wtedy, gdy atakujesz metodami konwencjonalnymi."
|
||||
},
|
||||
"Gotowość do Walki": {
|
||||
"name": "Battle Readiness",
|
||||
"description": "Nie możesz zostać zaskoczony(a) podczas walki oraz otrzymujesz bonus w postaci 2 punktów do Inicjatywy. Ten Talent możesz wybrać wielokrotnie, za każdym razem zwiększając swoją Inicjatywę o 2 punkty."
|
||||
},
|
||||
"Błogosławione Pochodzenie": {
|
||||
"name": "Blessed Origin",
|
||||
"description": "Anioły od Boga-Jahwe-Jedynego dostały dwa dary - naturalną zdolność do latania oraz możliwość odrodzenia się po śmierci."
|
||||
},
|
||||
"Sen na Jawie": {
|
||||
"name": "Daydream",
|
||||
"description": "Nie potrzebujesz snu.\n\n\nElfy w przeciwieństwie do innych ras nie śpią, ale nie wszystkie potrafią kontrolować swoje myśli w trakcie odpoczynku."
|
||||
},
|
||||
"Błogosławieństwo (Bóstwo)": {
|
||||
"name": "Deity Blessing",
|
||||
"description": "Wymagane do Magii Sakralnej oraz modlitw.\nZostałeś/aś pobłogosławiony/a przez wybrane bóstwo i możesz wykorzystywać zaklęcia z dziedziny Magii Sakralnej. Do wyboru masz modlitwy powszechne i własnego bóstwa.\n\n\nJednocześnie można być pobłogosławionym wyłącznie przez jedno bóstwo.\n\n\nDodatkowo każde znane Ci zaklęcie możesz rzucić jako modlitwę.\n\n\nRzucanie zaklęć jako modlitw polega na wykorzystaniu sumy wartości Atrybutu Soul oraz wartości Umiejętności Oddanie do rzucenia zaklęcia. Każda modlitwa kosztuje, oprócz many, jeden punkt Zapału, natomiast nie da się jej rozpleść."
|
||||
},
|
||||
"Dyplomata": {
|
||||
"name": "Diplomat",
|
||||
"description": "Masz Przewagę na wszystkie Przeciwstawne rzuty gdy starasz się rozwiązać konflikt, ocenić emocje i zamiary innych bądź określić, czy inni kłamią. Ponadto jesteś szanowany(a) i większość osób będzie się zwracała do Ciebie z szacunkiem, nawet jeśli za Tobą nie przepadają."
|
||||
},
|
||||
"Finta": {
|
||||
"name": "Feint",
|
||||
"description": "Postać wykonuje zwodniczy atak, którego celem jest zmylenie przeciwnika.\n\n\nNależy wykonać Test Przeciwstawny zgodnie z zasadami Akcji Atak, jednak w przypadku, gdy Postać, która zadeklarowała, że będzie wykorzystywała ten Talent wygra pojedynek, nie zadaje Obrażeń. W zamian cel Finty do końca następnej rundy atakującego bądź do następnego Ataku atakującego skierowanego w cel nie może unikać, odpierać ani parować ataku Postaci, która z powodzeniem użyła Finty.\n\n\nJeżeli atakujący atakuje oburącz, a Postać również trzyma dwie bronie, może sparować obydwa ataki, jednak musi podzielić pulę kości na obydwa parowania (podobnie jak przy walce oburącz)."
|
||||
},
|
||||
"Zakazana Wiedza (Dziedzina)": {
|
||||
"name": "Forbidden Knowledge",
|
||||
"description": "Liczba kości dla wybranej Umiejętności przy rzutach związanych z daną dziedziną jest podwajana. Jeśli pomimo tego Test się nie uda, musisz liczyć się z groźnymi konsekwencjami…"
|
||||
},
|
||||
"Darmowy Talent": {
|
||||
"name": "Free Talent",
|
||||
"description": "Czasem Mistrz Gry zdecyduje nagrodzić Gracza nowym talentem, a czasem Postać po prostu zaczyna z możliwością wybrania dowolnego Talentu.\n\n\nTen Talent może być przyznany wyłącznie przez Mistrza Gry. Niektóre postaci przy tworzeniu zaczynają z tym Talentem."
|
||||
},
|
||||
"Niezniszczalne Zaklęcia": {
|
||||
"name": "Indestructible Spells",
|
||||
"description": "Ilość Sukcesów wymaganych do rozplecenia zaklęcia rzuconego przez Ciebie jest zwiększona o zaokrągloną w górę połowę wartości Umiejętności Zdolności Magiczne."
|
||||
},
|
||||
"Czuły Zmysł (Zmysł)": {
|
||||
"name": "Keen Sense",
|
||||
"description": "Jeden z Twoich zmysłów jest szczególnie rozwinięty. Wybierz jeden ze zmysłów (wzrok, słuch, dotyk, smak, węch). Jeśli wykonujesz Test na Spostrzegawczość z użyciem danego zmysłu, wartość Umiejętności Spostrzegawczość zostaje podwojona przy tym rzucie."
|
||||
},
|
||||
"Zawisanie w Powietrzu": {
|
||||
"name": "Levitation",
|
||||
"description": "Postać, która normalnie musi być w ciągłym ruchu gdy jest w powietrzu, dzięki magii może się zatrzymać i zawisnąć nad ziemią."
|
||||
},
|
||||
"Magiczna Dyscyplina": {
|
||||
"name": "Magic Discipline",
|
||||
"description": "Jeżeli rzucasz zaklęcie obszarowe, możesz wybrać osoby, na które to zaklęcie nie będzie miało wpływu. Maksymalna liczba osób, które mogą zostać w ten sposób wybrane, jest równa poziomowi Umiejętności Korzystanie z Magii. Jeżeli jednak zaklęcie to powoduje czasową zmianę danego obszaru (jak np. Podpalenie), osoby, które zostaną wybrane dzięki temu Talentowi otrzymają obrażenia, o ile nie wyjdą z danego obszaru do Twojej następnej tury."
|
||||
},
|
||||
"Odporność na Magię": {
|
||||
"name": "Magic Resistance",
|
||||
"description": "Ten Talent można wykupić wyłącznie podczas tworzenia postaci.\nTen Talent można wykupić wielokrotnie, jeżeli zaczynało się z tym Talentem.\n\n\nPodczas tworzenia postaci po wykonaniu rzutu na Magię do wyniku na rzucie kością, jeżeli wynik jest mniejszy niż 10, dodaj 1.\n\n\nPostać raz na rundę może za darmo przerzucić wynik testu na przeciwstawianie się efektom zaklęć.\n\n\nTego Talentu nie można wziąć jeżeli Postać ma już Talent Jedność z Magią."
|
||||
},
|
||||
"Magiczny Zmysł (Zmysł)": {
|
||||
"name": "Magical Sense",
|
||||
"description": "Masz wrodzony dar do wykrywania i obrazowania otaczającej Cię magii, jej ukierunkowania oraz efektu. Testy na opieranie się efektowi zaklęć i na rozplatanie zaklęć wymagają jednego sukcesu mniej. Ponadto wybierasz, którym zmysłem \"widzisz\" magię (wzrok - wstęgi w powietrzu, dotyk - drżenie w palcach o różnym natężeniu i częstotliwości, słuch - różne dźwięki itd.)."
|
||||
},
|
||||
"Mistrz Magii (Dziedzina)": {
|
||||
"name": "Master Of Magic",
|
||||
"description": "Przy rzutach związanych z daną Dziedziną Magii liczba Kości otrzymywana we wszystkich Umiejętnościach zostaje podwojona. \n\n\nTen Talent może zostać wybrane dla maksymalnie jednej Dziedziny."
|
||||
},
|
||||
"Oportunista": {
|
||||
"name": "Opportunist",
|
||||
"description": "Twoja zdolność do reagowania na wydarzenia w trakcie walki jest zadziwiająca.\n\n\nRaz na rundę gdy przeciwnik odchodzi od Ciebie i masz możliwość zaatakowania go, możesz podjąć próbę ataku."
|
||||
},
|
||||
"Parowanie": {
|
||||
"name": "Parry",
|
||||
"description": "Zamiast unikać bądź kontrować ataków, raz na rundę Postać może sparować jeden atak wręcz. \n\n\nJeżeli atakujący atakuje oburącz, a Postać również trzyma dwie bronie, może sparować obydwa ataki, jednak musi podzielić pulę kości na obydwa parowania (podobnie jak przy walce oburącz).\n\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."
|
||||
},
|
||||
"Urok Osobisty": {
|
||||
"name": "Personal Charm",
|
||||
"description": "Wszystkie Testy związane z zauraczaniem kogoś lub wykorzystywaniem swojego wyglądu do zmanipulowania innej osoby są prostsze o jeden stopień."
|
||||
},
|
||||
"Odporność na Trucizny": {
|
||||
"name": "Poison Resistance",
|
||||
"description": "Postać do pewnego stopnia uodporniła się na zatrucia.\n\n\nGdy postać musi wykonać test na Wytrzymałość aby uniknąć zatrucia, podwój ilość kości wynikającą z poziomu Umiejętności Wytrzymałość."
|
||||
},
|
||||
"Riposta": {
|
||||
"name": "Riposte",
|
||||
"description": "Postać potrafi wykorzystywać swoje niesamowite umiejętności w fechtunku nie tylko aby unikać ataków bądź się przed nimi bronić, ale także aby wyprowadzać dewastujące kontrataki.\n\n\nRaz na rundę Postać po udanym Parowaniu może wykonać Ripostę. Riposta może być atakiem wręcz bądź dystansowym, ale musi być skierowana w przeciwnika, którego atak został Sparowany. Cel Riposty nie może odpierać tego ataku (jednak może go unikać za punkt Zapału), a ponadto sam atak jest prostszy o jeden Stopień."
|
||||
},
|
||||
"Rozplatanie Zaklęć": {
|
||||
"name": "Spell Shattering",
|
||||
"description": "Każdy mag posiadający ten Talent może raz na rundę za darmo dokonać próby rozplatania, jednak dane zaklęcie może być rozplatane wyłącznie raz.\n\n\nMagowie posiadający Zapał mogą próbować rozpleść więcej zaklęć - każda następna taka próba kosztuje jeden punkt Zapału. Magowie sakralni mogą potraktować rozplatanie zaklęcia jako modlitwę, zmieniając Atrybut swojego Testu na Duszę, ale jednocześnie zwiększając koszt wykorzystania tego Talentu o 1 punkt Zapału.\n\n\nJeżeli Postać widzi istotę rzucającą zaklęcie i jest od niej w zasięgu 20 m, może zadeklarować, że próbuje rozpleść rzucane zaklęcie. Postać wykonuje Test na Zdolności Magiczne (Magic lub Soul, jeśli jest magiem sakralnym), gdzie Trudność (T) jest równa trudności zaklęcia, a liczba wymaganych Sukcesów (S) jest o 1 większa od liczby sukcesów wyrzuconych przez maga rzucającego zaklęcie. Jeżeli wynik Testu Postaci będzie wyższy niż wynik Testu przeciwnika, zaklęcie zostaje rozplecione."
|
||||
},
|
||||
"Technologia Jest Wśród Nas": {
|
||||
"name": "Technology Is Among Us",
|
||||
"description": "Jak inne rasy na co dzień mają styczność z magią, tak ludzie posługują się znacznie bardziej zaawansowaną technologią niż inne rasy - telefony, komputery, samochody… Długo by tu wymieniać. Dla innych ras ludzka technologia jest niezrozumiała, ale ludzie potrafią się nią bez problemu posługiwać.\n\n\nPodstawowa obsługa ludzkich urządzeń nie sprawia Ci kłopotu, a dodatkowo jeżeli masz instrukcję do danego urządzenia w języku zrozumiałym dla ciebie, to bez problemu możesz skorzystać z technologii Obcej cywilizacji, której poziom technologiczny jest porównywalny bądź niższy do poziomu cywilizacji ziemskiej."
|
||||
},
|
||||
"Jedność z Magią": {
|
||||
"name": "Unity With Magic",
|
||||
"description": "Ten Talent można wykupić wyłącznie podczas tworzenia postaci.\n\n\nPodczas tworzenia postaci po wykonaniu rzutu na Magię od wyniku na rzucie kością odejmij 1, a ponadto jeżeli wynik po odjęciu będzie nie mniejszy niż 7, wykonaj ponownie rzut.\n\n\nTego Talentu nie można wziąć jeżeli Postać ma już Talent Odporność na Magię."
|
||||
},
|
||||
"Jedność z Naturą": {
|
||||
"name": "Unity With Nature",
|
||||
"description": "Jesteś bardzo blisko związany(a) z Naturą. \n\n\nPodwajasz liczbę kości wynikających z ilości Punktów Umiejętności w Umiejętnościach Skradanie się oraz Zdolności Magiczne, jeżeli jesteś w miejscu niezmienionym przez jakąkolwiek inteligentną rasę."
|
||||
}
|
||||
}
|
||||
}
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
{
|
||||
"HBM": {
|
||||
"system": { "name": "Homebrew Magic: RPG v3" },
|
||||
"actor": { "character": "Character", "npc": "NPC / Creature" },
|
||||
"item": {
|
||||
"spell": "Spell",
|
||||
"gear": "Gear",
|
||||
"ability": "Ability",
|
||||
"class": "Class",
|
||||
"race": "Race",
|
||||
"discipline": "Magic Discipline",
|
||||
"talent": "Talent"
|
||||
},
|
||||
"details": {
|
||||
"race": "Race",
|
||||
"year": "Year",
|
||||
"discipline": "Discipline",
|
||||
"raceLink": "Linked Race entry",
|
||||
"classLink": "Linked Class entry",
|
||||
"classSchedule": "Class schedule",
|
||||
"personalDetails": "Personal details",
|
||||
"startingYear": "Starting Year",
|
||||
"currentYear": "Current Year"
|
||||
},
|
||||
"schedule": {
|
||||
"hours": "Hours",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"lunchBreak": "Lunch break",
|
||||
"dinnerBreak": "Dinner break",
|
||||
"supperBreak": "Supper break"
|
||||
},
|
||||
"attributes": {
|
||||
"body": "Body",
|
||||
"bodyAbbr": "BOD",
|
||||
"mind": "Mind",
|
||||
"mindAbbr": "MND",
|
||||
"soul": "Soul",
|
||||
"soulAbbr": "SOU",
|
||||
"magic": "Magic",
|
||||
"magicAbbr": "MAG",
|
||||
"actual": "Actual",
|
||||
"actualMagicHint": "Actual magic level including talents and modifiers."
|
||||
},
|
||||
"resources": {
|
||||
"health": "Health",
|
||||
"healthAbbr": "HP",
|
||||
"mana": "Mana",
|
||||
"manaAbbr": "MN",
|
||||
"zeal": "Zeal",
|
||||
"zealAbbr": "ZL",
|
||||
"magicalArmor": "Magical Armor",
|
||||
"magicalArmorAbbr": "MA",
|
||||
"magicalShield": "Magical Shield",
|
||||
"magicalShieldAbbr": "MS",
|
||||
"physicalArmor": "Physical Armor",
|
||||
"physicalArmorAbbr": "PA",
|
||||
"runicCounter": "Runic counter",
|
||||
"condition": "Condition",
|
||||
"initiative": "Initiative",
|
||||
"experience": "Experience",
|
||||
"experienceAbbr": "XP",
|
||||
"magicPowerLevel": "Magic Power Level",
|
||||
"maxManaPerSpell": "Max Mana per Spell",
|
||||
"blood": "Blood",
|
||||
"elixirTolerance": "Elixir Tolerance",
|
||||
"insanity": "Insanity",
|
||||
"max": "Max"
|
||||
},
|
||||
"skills": {
|
||||
"athletics": "Athletics",
|
||||
"disguise": "Disguise",
|
||||
"determination": "Determination",
|
||||
"empathy": "Empathy",
|
||||
"intuition": "Intuition",
|
||||
"animalHandling": "Animal Handling",
|
||||
"magicalAbilities": "Magical Abilities",
|
||||
"medicine": "Medicine",
|
||||
"strength": "Strength",
|
||||
"devotion": "Devotion",
|
||||
"persuasion": "Persuasion",
|
||||
"reflex": "Reflex",
|
||||
"craft": "Craft",
|
||||
"stealth": "Stealth",
|
||||
"perception": "Perception",
|
||||
"theology": "Theology",
|
||||
"ranged": "Ranged",
|
||||
"melee": "Melee",
|
||||
"magicLore": "Magic Lore",
|
||||
"natureLore": "Nature Lore",
|
||||
"generalLore": "General Lore",
|
||||
"endurance": "Endurance",
|
||||
"intimidation": "Intimidation",
|
||||
"agility": "Agility"
|
||||
},
|
||||
"spell": {
|
||||
"label": "Spell",
|
||||
"cast": "Cast spell",
|
||||
"circle": "Circle",
|
||||
"school": "School",
|
||||
"discipline": "Discipline",
|
||||
"difficulty": "Spell Threshold",
|
||||
"difficultyThreshold": "Threshold (T)",
|
||||
"difficultySuccesses": "Required successes (Y)",
|
||||
"components": "Components",
|
||||
"verbal": "Verbal (V)",
|
||||
"somatic": "Somatic (S)",
|
||||
"material": "Material (M)",
|
||||
"symbols": "Symbols / Glyphs",
|
||||
"castingTime": "Casting time",
|
||||
"castingTimeRounds": "Time (rounds)",
|
||||
"castingTimeMinutes": "Time (minutes)",
|
||||
"range": "Range",
|
||||
"targets": "Targets",
|
||||
"duration": "Duration",
|
||||
"manaCost": "Mana cost",
|
||||
"bloodCost": "Blood cost",
|
||||
"cost": "Cost & Difficulty",
|
||||
"timing": "Timing & Geometry",
|
||||
"overcasting": "Overcasting",
|
||||
"overcastOptions": "Overcast Options",
|
||||
"overcastDesc": "Effect description",
|
||||
"description": "Description",
|
||||
"higherCircles": "At higher circles",
|
||||
"complexityLevel": "Complexity Level",
|
||||
"deity": "Deity",
|
||||
"sourceBook": "Source book",
|
||||
"areaOfEffect": "Area of Effect",
|
||||
"aoeShape": "Shape",
|
||||
"aoeUnit": "Unit",
|
||||
"damage": "Damage",
|
||||
"damageBase": "Base damage",
|
||||
"damageType": "Damage type",
|
||||
"ignoresArmor": "Ignores armor",
|
||||
"statusEffects": "Status effects",
|
||||
"saveAttribute": "Save attribute",
|
||||
"saveSkill": "Save skill",
|
||||
"requirements": "Requirements",
|
||||
"requirementRace": "Races",
|
||||
"requirementTalent": "Talents",
|
||||
"requirementDiscipline": "Disciplines",
|
||||
"isSuperspell": "Superspell",
|
||||
"requiresGroupCast": "Group cast",
|
||||
"minCasters": "Min. casters",
|
||||
"nonCombatOnly": "Non-combat only",
|
||||
"triggers": "Triggers",
|
||||
"triggerEffect": "Trigger effect",
|
||||
"castingMode": {
|
||||
"label": "Casting Mode",
|
||||
"standard": "Standard",
|
||||
"sacred": "Sacred Magic",
|
||||
"witch": "Witch Magic",
|
||||
"blood": "Blood Magic",
|
||||
"hekate": "Hekate Hybrid"
|
||||
}
|
||||
},
|
||||
"spellSchool": {
|
||||
"general": "General",
|
||||
"alchemyTransmutation": "Alchemy - Transmutation",
|
||||
"alchemyBrewing": "Alchemy - Brewing",
|
||||
"botany": "Botany",
|
||||
"elementsAir": "Elemental Magic (Air)",
|
||||
"elementsWater": "Elemental Magic (Water)",
|
||||
"elementsFire": "Elemental Magic (Fire)",
|
||||
"elementsEarth": "Elemental Magic (Earth)",
|
||||
"artifice": "Artifice",
|
||||
"golemancy": "Golemancy",
|
||||
"runes": "Runic Magic",
|
||||
"manaSourceMage": "Mana Source",
|
||||
"illusion": "Illusion",
|
||||
"sacred": "Sacred Magic",
|
||||
"sacredExorcism": "Sacred Magic — Exorcism",
|
||||
"witch": "Witch Magic",
|
||||
"necromancy": "Necromancy",
|
||||
"blood": "Blood Magic",
|
||||
"crimson": "Crimson Magic",
|
||||
"abyssAspects": "Abyss — Aspects",
|
||||
"abyssPrimal": "Abyss — Primal",
|
||||
"wildWitch": "Wild Witch"
|
||||
},
|
||||
"deity": {
|
||||
"common": "Common Prayers",
|
||||
"jahwe": "YHWH",
|
||||
"zeus": "Zeus",
|
||||
"demeter": "Demeter",
|
||||
"artemis": "Artemis",
|
||||
"hekate": "Hekate",
|
||||
"aphrodite": "Aphrodite",
|
||||
"eros": "Eros"
|
||||
},
|
||||
"sourceBook": {
|
||||
"core-rules": "PG",
|
||||
"magic-book": "KM",
|
||||
"arcanum-sanguinis": "AS",
|
||||
"crimson-cult": "CS",
|
||||
"abyss-curse": "KO",
|
||||
"humanity-guide": "PLM",
|
||||
"bestiary": "B",
|
||||
"gold-steel-magic": "ZSM"
|
||||
},
|
||||
"aoeShape": {
|
||||
"point": "Point",
|
||||
"square": "Square",
|
||||
"rectangle": "Rectangle",
|
||||
"cone": "Cone",
|
||||
"sphere": "Sphere",
|
||||
"line": "Line"
|
||||
},
|
||||
"damageType": {
|
||||
"physical": "Physical",
|
||||
"magical": "Magical",
|
||||
"physicalMagical": "Physical-Magical",
|
||||
"pure": "Pure"
|
||||
},
|
||||
"triggerEvent": {
|
||||
"killWithWeapon": "Kill with weapon",
|
||||
"targetCastsSpell": "Target casts spell",
|
||||
"damageTaken": "Damage taken",
|
||||
"turnStart": "Turn start"
|
||||
},
|
||||
"gear": {
|
||||
"category": "Category",
|
||||
"weapon": "Weapon",
|
||||
"armor": "Armor",
|
||||
"equipment": "Equipment",
|
||||
"damage": "Damage",
|
||||
"damageType": "Damage Type",
|
||||
"physical": "Physical",
|
||||
"magical": "Magical",
|
||||
"environmental": "Environmental",
|
||||
"properties": "Properties",
|
||||
"damageReduction": "Damage Reduction",
|
||||
"damageReductionBonus": "DR Bonus",
|
||||
"damageReductionBonusHint": "Extra damage reduction (e.g. from talents). Only applies when armor is broken.",
|
||||
"condition": "Condition",
|
||||
"conditionMax": "Max Condition",
|
||||
"equipped": "Equipped / Active",
|
||||
"repair": "Repair",
|
||||
"armorType": "Armor Type",
|
||||
"light": "Light",
|
||||
"medium": "Medium",
|
||||
"heavy": "Heavy",
|
||||
"shield": "Shield",
|
||||
"stealthDisadvantage": "Stealth Disadvantage",
|
||||
"strengthRequirement": "Strength Requirement",
|
||||
"rarity": "Rarity",
|
||||
"value": "Value",
|
||||
"weight": "Weight",
|
||||
"quantity": "Quantity",
|
||||
"description": "Description"
|
||||
},
|
||||
"ability": {
|
||||
"type": "Type",
|
||||
"passive": "Passive",
|
||||
"active": "Active",
|
||||
"reaction": "Reaction",
|
||||
"freeAction": "Free Action",
|
||||
"prerequisite": "Prerequisite",
|
||||
"description": "Description",
|
||||
"mechanics": "Mechanics",
|
||||
"cost": "Cost"
|
||||
},
|
||||
"class": {
|
||||
"year": "Year",
|
||||
"attributePoints": "Attribute Points",
|
||||
"skillPoints": "Skill Points",
|
||||
"talents": "Talents",
|
||||
"spells": "Spells",
|
||||
"features": "Features",
|
||||
"description": "Description"
|
||||
},
|
||||
"race": {
|
||||
"availableDisciplines": "Available Disciplines",
|
||||
"attributePoints": "Starting Attribute Points",
|
||||
"skillPoints": "Starting Skill Points",
|
||||
"freeTalents": "Free Talents",
|
||||
"racialAbilities": "Racial Abilities",
|
||||
"traits": "Traits",
|
||||
"description": "Description",
|
||||
"physicalDescription": "Physical Description"
|
||||
},
|
||||
"discipline": {
|
||||
"color": "Color",
|
||||
"suggestedAttribute": "Suggested Attribute",
|
||||
"suggestedSkills": "Suggested Skills",
|
||||
"passiveAbility": "Passive Ability",
|
||||
"signatureSpells": "Signature Spells",
|
||||
"availableSpells": "Available Spells",
|
||||
"description": "Description"
|
||||
},
|
||||
"talent": {
|
||||
"requirements": "Requirements",
|
||||
"requirementRace": "Race",
|
||||
"requirementAttribute": "Attribute",
|
||||
"requirementSkill": "Skill",
|
||||
"requirementTalent": "Talent",
|
||||
"requirementTitle": "Title",
|
||||
"requirementDiscipline": "Discipline",
|
||||
"description": "Description",
|
||||
"effect": "Effect",
|
||||
"cost": "Cost",
|
||||
"multiSelect": "Can be selected multiple times"
|
||||
},
|
||||
"npc": {
|
||||
"type": "Type",
|
||||
"size": "Size",
|
||||
"alignment": "Alignment",
|
||||
"challengeRating": "Challenge Rating",
|
||||
"challengeRatingAbbr": "CR",
|
||||
"xp": "XP",
|
||||
"speed": "Speed",
|
||||
"senses": "Senses",
|
||||
"languages": "Languages",
|
||||
"attacks": "Attacks",
|
||||
"specialAbilities": "Special Abilities",
|
||||
"reactions": "Reactions",
|
||||
"legendaryActions": "Legendary Actions",
|
||||
"savingThrows": "Saving Throws",
|
||||
"damageResistances": "Damage Resistances",
|
||||
"damageImmunities": "Damage Immunities",
|
||||
"conditionImmunities": "Condition Immunities",
|
||||
"habitat": "Habitat",
|
||||
"behavior": "Behavior",
|
||||
"history": "History"
|
||||
},
|
||||
"roll": {
|
||||
"title": "TS Roll",
|
||||
"roll": "Roll",
|
||||
"pool": "Dice pool",
|
||||
"threshold": "Threshold (T)",
|
||||
"successes": "Successes",
|
||||
"required": "Required",
|
||||
"result": "Result",
|
||||
"success": "Success",
|
||||
"failure": "Failure",
|
||||
"criticalSuccess": "Critical Success",
|
||||
"criticalFailure": "Critical Failure",
|
||||
"rollSkill": "Roll: {skill}",
|
||||
"rollAttribute": "Roll: {attribute}",
|
||||
"modifier": "Modifier",
|
||||
"attributeOverride": "Attribute (override)",
|
||||
"configTitle": "TS Roll Configuration",
|
||||
"blocked": "Cannot roll: {reason}",
|
||||
"thresholdAdjusted": "Threshold modified by conditions"
|
||||
},
|
||||
"spellCast": {
|
||||
"title": "Casting spell",
|
||||
"manaSpent": "Mana spent",
|
||||
"zealSpent": "Zeal spent",
|
||||
"bloodSpent": "Blood spent",
|
||||
"hekateMode": "Hekate mode (Sacred + Witch hybrid)",
|
||||
"groupCasters": "Group caster IDs",
|
||||
"bypassSuperspell": "Bypass superspell warning",
|
||||
"bypassNonCombatBlock": "Bypass non-combat block",
|
||||
"cast": "Cast",
|
||||
"notEnoughMana": "Not enough mana.",
|
||||
"notEnoughZeal": "Not enough zeal.",
|
||||
"notEnoughBlood": "Not enough blood.",
|
||||
"raceLocked": "Spell requires race: {races}.",
|
||||
"talentMissing": "Missing required talent: {talent}.",
|
||||
"disciplineMissing": "Requires one of disciplines: {disciplines}.",
|
||||
"deityMismatch": "Spell deity ({spell}) differs from devoted deity ({actor}).",
|
||||
"groupCastRequired": "Requires {min} co-casters; have {provided}.",
|
||||
"nonCombatOnly": "Spell cannot be cast in combat.",
|
||||
"superspellInCombat": "Casting superspell during combat is generally inadvisable.",
|
||||
"overcast": "Overcast",
|
||||
"overcastTest": "Overcast Test",
|
||||
"sacredCost": "Cost: 1 Zeal",
|
||||
"witchSymbols": "Symbols: {used} / {max}",
|
||||
"cantOvercastBeyondLevel": "Cannot overcast beyond Magic Power Level.",
|
||||
"overcastWarning": "This spell exceeds Max Mana per Spell.",
|
||||
"success": "Success",
|
||||
"failure": "Failure",
|
||||
"damage": "Damage",
|
||||
"statusEffects": "Status effects",
|
||||
"saveTarget": "Target save",
|
||||
"triggers": "Triggers",
|
||||
"applyToTargets": "Click an effect to apply it to selected tokens.",
|
||||
"targetSomeone": "Select targets to show damage-apply buttons.",
|
||||
"noTargets": "No tokens / targets selected.",
|
||||
"variableSuccesses": "Required successes",
|
||||
"groupCastersFromCanvas": "Co-casters (select tokens on the canvas)"
|
||||
},
|
||||
"damage": {
|
||||
"title": "Apply Damage",
|
||||
"amount": "Damage amount",
|
||||
"type": "Damage type",
|
||||
"ignoreMagicalArmor": "Bypass Magical Armor",
|
||||
"ignoreMagicalShield": "Bypass Magical Shield",
|
||||
"ignorePhysicalArmor": "Bypass Physical Armor",
|
||||
"damageArmor": "Damage armor (-1 condition)",
|
||||
"apply": "Apply",
|
||||
"report": {
|
||||
"title": "Damage: {actor}",
|
||||
"magicalArmorAbsorbed": "Magical Armor absorbed: {n}",
|
||||
"magicalShieldAbsorbed": "Magical Shield absorbed: {n}",
|
||||
"physicalArmorReduced": "Physical Armor reduced: {n}",
|
||||
"healthLost": "Health lost: {n}",
|
||||
"armorBroken": "Armor broken!",
|
||||
"runeTriggered": "Runes triggered — Magical Armor -1"
|
||||
}
|
||||
},
|
||||
"combat": {
|
||||
"newRound": "New round — Mana reset",
|
||||
"newTurn": "New turn — Zeal +1",
|
||||
"turnSkipped": "Turn skipped (condition)",
|
||||
"deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)"
|
||||
},
|
||||
"conditions": {
|
||||
"przewrocony": "Prone",
|
||||
"unieruchomiony": "Restrained",
|
||||
"obezwladniony": "Incapacitated",
|
||||
"nieprzytomny": "Unconscious",
|
||||
"umierajacy": "Dying",
|
||||
"ogluszony": "Deafened",
|
||||
"oslepiony": "Blinded",
|
||||
"oszolomiony": "Stunned",
|
||||
"zatruty": "Poisoned",
|
||||
"podpalony": "Burning",
|
||||
"spowolniony": "Slowed",
|
||||
"przeklety": "Cursed",
|
||||
"zauroczony": "Charmed",
|
||||
"koncentracja": "Concentrating",
|
||||
"przerazony": "Frightened"
|
||||
},
|
||||
"ui": {
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"name": "Name",
|
||||
"notes": "Notes",
|
||||
"biography": "Biography",
|
||||
"dropItems": "Drop items onto this sheet",
|
||||
"unknownItemType": "Unknown item type",
|
||||
"advancement": "Advancement",
|
||||
"pointsAvailable": "Points available",
|
||||
"spend": "Spend",
|
||||
"attacks": "Attacks",
|
||||
"specialAbilities": "Special Abilities",
|
||||
"reactions": "Reactions",
|
||||
"legendaryActions": "Legendary Actions",
|
||||
"actionsTitle": "Quick Actions",
|
||||
"customEquipment": "Custom Equipment",
|
||||
"attributePoints": "Attr. Points",
|
||||
"skillPoints": "Skill Points",
|
||||
"freeTalents": "Free Talents",
|
||||
"money": "Money",
|
||||
"recalculate": "Convert currencies",
|
||||
"moneyConverterTitle": "Currency Converter",
|
||||
"changeImageHint": "Click to change image",
|
||||
"itemAlreadyOwned": "'{name}' is already on this character sheet.",
|
||||
"dropTalentsHint": "Drag talents from the compendium onto this sheet.",
|
||||
"physicalArmorBonus": "Armor bonus",
|
||||
"selectTalentParamTitle": "Talent Specialization",
|
||||
"selectTalentParamDesc": "This talent requires a choice of specialization. Enter a value for ({param}):",
|
||||
"tabs": {
|
||||
"stats": "Stats",
|
||||
"actions": "Actions",
|
||||
"skills": "Skills",
|
||||
"main": "Sheet",
|
||||
"spells": "Spells",
|
||||
"inventory": "Inventory",
|
||||
"talents": "Talents",
|
||||
"effects": "Effects",
|
||||
"biography": "Biography"
|
||||
}
|
||||
},
|
||||
"rest": {
|
||||
"takeBreather": "Take a Breather",
|
||||
"shortRest": "Short Rest",
|
||||
"longRest": "Long Rest",
|
||||
"restored": "Restored",
|
||||
"hpReset": "Hit Points",
|
||||
"manaReset": "Mana",
|
||||
"zealReset": "Zeal",
|
||||
"bloodReset": "Blood Pool",
|
||||
"toleranceRecovered": "Elixir tolerance recovered",
|
||||
"effectsCleared": "Expired effects cleared"
|
||||
},
|
||||
"initiative": {
|
||||
"label": "Initiative",
|
||||
"formula": "2d6 + Initiative",
|
||||
"roll": "Roll Initiative"
|
||||
},
|
||||
"talentCategory": {
|
||||
"core": "Core Rules",
|
||||
"blood": "Blood Magic",
|
||||
"abyss": "Curse of the Abyss",
|
||||
"crimson": "Crimson Cult",
|
||||
"npc": "NPC"
|
||||
},
|
||||
"macro": {
|
||||
"poolRoll": "d6 Pool Roll",
|
||||
"poolRollHint": "Roll a d6 pool with selected target threshold (TS).",
|
||||
"attributeCheck": "Attribute Check",
|
||||
"attributeCheckHint": "Roll an attribute check for the selected character.",
|
||||
"applyCondition": "Apply Condition",
|
||||
"applyConditionHint": "Apply a condition to selected/targeted tokens.",
|
||||
"takeBreather": "Take a Breather",
|
||||
"takeBreatherHint": "Take a breather for the selected character.",
|
||||
"shortRest": "Short Rest",
|
||||
"shortRestHint": "Short rest for the selected character.",
|
||||
"longRest": "Long Rest",
|
||||
"longRestHint": "Long rest for the selected character.",
|
||||
"groupCastHelper": "Group Cast Helper",
|
||||
"groupCastHelperHint": "Sums mana and magic pool of selected co-casters.",
|
||||
"refillZeal": "Refill Zeal",
|
||||
"refillZealHint": "Adds 1 Zeal to the selected character (debug)."
|
||||
},
|
||||
"activeEffect": {
|
||||
"title": "Active Effects",
|
||||
"addEffect": "Add Effect",
|
||||
"deleteEffect": "Delete",
|
||||
"edit": "Edit",
|
||||
"toggle": "Enable/disable",
|
||||
"newEffect": "New Effect",
|
||||
"transferToActor": "Transferred to actor",
|
||||
"fromItem": "From item",
|
||||
"changes": "changes",
|
||||
"empty": "No effects. Add an effect to grant attribute bonuses or system flags.",
|
||||
"emptyActor": "This actor has no active effects."
|
||||
},
|
||||
"racesList": {
|
||||
"czlowiek": "Human",
|
||||
"elf": "Elf",
|
||||
"lamia": "Lamia",
|
||||
"feles": "Feles",
|
||||
"aniol": "Angel"
|
||||
},
|
||||
"talentsList": {
|
||||
"arcymag-dziedzina": "Archmage (Discipline)",
|
||||
"blogoslawienstwo-bostwo": "Blessing (Deity)",
|
||||
"blogoslawione-pochodzenie": "Blessed Heritage",
|
||||
"cios-w-plecy": "Backstab",
|
||||
"czuly-zmysl": "Acute Sense",
|
||||
"dyplomata": "Diplomat",
|
||||
"finta": "Feint",
|
||||
"gadanina": "Babble",
|
||||
"gotowosc-do-walki": "Combat Readiness",
|
||||
"jednosc-z-magia": "Unity with Magic",
|
||||
"jednosc-z-natura": "Unity with Nature",
|
||||
"magiczna-dyscyplina": "Magical Discipline",
|
||||
"magiczny-zmysl-zmysl": "Magical Sense (Sense)",
|
||||
"mistrz-magii-dziedzina": "Master of Magic (Discipline)",
|
||||
"niezniszczalne-zaklecia": "Indestructible Spells",
|
||||
"obureczny": "Ambidextrous",
|
||||
"odpornosc-na-magie": "Magic Resistance",
|
||||
"odpornosc-na-trucizny": "Poison Resistance",
|
||||
"oportunista": "Opportunist",
|
||||
"pancerz-wiary": "Armor of Faith",
|
||||
"parowanie": "Parrying",
|
||||
"powietrzne-uniki": "Aerial Evasion",
|
||||
"riposta": "Riposte",
|
||||
"rozplatanie-zaklec": "Spell Unraveling",
|
||||
"sen-na-jawie": "Daydreaming",
|
||||
"technologia-jest-wsrod-nas": "Technology is Among Us",
|
||||
"urok-osobisty": "Personal Charm",
|
||||
"zakazana-wiedza-dziedzina": "Forbidden Knowledge (Discipline)",
|
||||
"zawisanie-w-powietrzu": "Hovering"
|
||||
}
|
||||
}
|
||||
}
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
{
|
||||
"HBM": {
|
||||
"system": {
|
||||
"name": "Homebrew Magic: RPG v3"
|
||||
},
|
||||
"actor": {
|
||||
"character": "Postać",
|
||||
"npc": "BN / Stwór"
|
||||
},
|
||||
"item": {
|
||||
"spell": "Zaklęcie",
|
||||
"gear": "Przedmiot",
|
||||
"ability": "Zdolność",
|
||||
"class": "Klasa",
|
||||
"race": "Rasa",
|
||||
"discipline": "Dziedzina Magii",
|
||||
"talent": "Talent"
|
||||
},
|
||||
"details": {
|
||||
"race": "Rasa",
|
||||
"year": "Rok",
|
||||
"discipline": "Dziedzina",
|
||||
"raceLink": "Powiązana karta rasy",
|
||||
"classLink": "Powiązana karta klasy",
|
||||
"classSchedule": "Plan zajęć",
|
||||
"personalDetails": "Dane osobowe",
|
||||
"startingYear": "Rok rozpoczęcia",
|
||||
"currentYear": "Bieżący rok"
|
||||
},
|
||||
"schedule": {
|
||||
"hours": "Godziny",
|
||||
"monday": "Poniedziałek",
|
||||
"tuesday": "Wtorek",
|
||||
"wednesday": "Środa",
|
||||
"thursday": "Czwartek",
|
||||
"friday": "Piątek",
|
||||
"saturday": "Sobota",
|
||||
"lunchBreak": "Przerwa lunchowa",
|
||||
"dinnerBreak": "Przerwa obiadowa",
|
||||
"supperBreak": "Przerwa kolacyjna"
|
||||
},
|
||||
"attributes": {
|
||||
"body": "Ciało",
|
||||
"bodyAbbr": "CIA",
|
||||
"mind": "Umysł",
|
||||
"mindAbbr": "UMY",
|
||||
"soul": "Dusza",
|
||||
"soulAbbr": "DUS",
|
||||
"magic": "Magia",
|
||||
"magicAbbr": "MAG",
|
||||
"actual": "Rzeczywista",
|
||||
"actualMagicHint": "Rzeczywisty poziom magii z uwzględnieniem talentów i modyfikatorów."
|
||||
},
|
||||
"resources": {
|
||||
"health": "Żywotność",
|
||||
"healthAbbr": "ŻYW",
|
||||
"mana": "Mana",
|
||||
"manaAbbr": "MN",
|
||||
"zeal": "Zapał",
|
||||
"zealAbbr": "ZP",
|
||||
"magicalArmor": "Pancerz Magiczny",
|
||||
"magicalArmorAbbr": "PM",
|
||||
"magicalShield": "Tarcza Magiczna",
|
||||
"magicalShieldAbbr": "TM",
|
||||
"physicalArmor": "Pancerz Fizyczny",
|
||||
"physicalArmorAbbr": "PF",
|
||||
"runicCounter": "Licznik runiczny",
|
||||
"condition": "Stan",
|
||||
"initiative": "Inicjatywa",
|
||||
"experience": "Punkty Doświadczenia",
|
||||
"experienceAbbr": "PD",
|
||||
"magicPowerLevel": "Poziom Mocy Magicznej",
|
||||
"maxManaPerSpell": "Maks. Many na zaklęcie",
|
||||
"blood": "Krew",
|
||||
"elixirTolerance": "Tolerancja Eliksirów",
|
||||
"insanity": "Szaleństwo",
|
||||
"max": "Maks."
|
||||
},
|
||||
"skills": {
|
||||
"athletics": "Atletyka",
|
||||
"disguise": "Charakteryzacja",
|
||||
"determination": "Determinacja",
|
||||
"empathy": "Empatia",
|
||||
"intuition": "Intuicja",
|
||||
"animalHandling": "Opieka nad Zwierzętami",
|
||||
"magicalAbilities": "Zdolności Magiczne",
|
||||
"medicine": "Medycyna",
|
||||
"strength": "Siła",
|
||||
"devotion": "Oddanie",
|
||||
"persuasion": "Przekonywanie",
|
||||
"reflex": "Refleks",
|
||||
"craft": "Rzemiosło",
|
||||
"stealth": "Skradanie się",
|
||||
"perception": "Spostrzegawczość",
|
||||
"theology": "Teologia",
|
||||
"ranged": "Umiejętności Strzeleckie",
|
||||
"melee": "Walka Wręcz",
|
||||
"magicLore": "Wiedza Magiczna",
|
||||
"natureLore": "Wiedza o Naturze",
|
||||
"generalLore": "Wiedza Ogólna",
|
||||
"endurance": "Wytrzymałość",
|
||||
"intimidation": "Zastraszanie",
|
||||
"agility": "Zręczność"
|
||||
},
|
||||
"spell": {
|
||||
"label": "Zaklęcie",
|
||||
"cast": "Rzuć zaklęcie",
|
||||
"circle": "Krąg",
|
||||
"school": "Szkoła",
|
||||
"discipline": "Dziedzina",
|
||||
"difficulty": "Próg zaklęcia",
|
||||
"difficultyThreshold": "Trudność (T)",
|
||||
"difficultySuccesses": "Wymagane sukcesy (Y)",
|
||||
"components": "Komponenty",
|
||||
"verbal": "Werbalna (W)",
|
||||
"somatic": "Somatyczna (S)",
|
||||
"material": "Materialne (M)",
|
||||
"symbols": "Symbole / Znaki",
|
||||
"castingTime": "Czas rzucania",
|
||||
"castingTimeRounds": "Czas (rundy)",
|
||||
"castingTimeMinutes": "Czas (minuty)",
|
||||
"range": "Zasięg",
|
||||
"targets": "Cele",
|
||||
"duration": "Czas trwania",
|
||||
"manaCost": "Koszt Many",
|
||||
"bloodCost": "Koszt Krwi",
|
||||
"cost": "Koszt i Trudność",
|
||||
"timing": "Czas i Geometria",
|
||||
"overcasting": "Nadczarowanie",
|
||||
"overcastOptions": "Opcje Nadczarowania",
|
||||
"overcastDesc": "Opis efektu",
|
||||
"description": "Opis",
|
||||
"higherCircles": "Na wyższych kręgach",
|
||||
"complexityLevel": "Poziom Skomplikowania",
|
||||
"deity": "Bóstwo",
|
||||
"sourceBook": "Podręcznik źródłowy",
|
||||
"areaOfEffect": "Obszar działania",
|
||||
"aoeShape": "Kształt",
|
||||
"aoeUnit": "Jednostka",
|
||||
"damage": "Obrażenia",
|
||||
"damageBase": "Obrażenia bazowe",
|
||||
"damageType": "Typ obrażeń",
|
||||
"ignoresArmor": "Ignoruje pancerz",
|
||||
"statusEffects": "Efekty statusu",
|
||||
"saveAttribute": "Atrybut RO",
|
||||
"saveSkill": "Umiejętność RO",
|
||||
"requirements": "Wymagania",
|
||||
"requirementRace": "Rasy",
|
||||
"requirementTalent": "Talenty",
|
||||
"requirementDiscipline": "Dziedziny",
|
||||
"isSuperspell": "Superczar",
|
||||
"requiresGroupCast": "Czar grupowy",
|
||||
"minCasters": "Min. czarujących",
|
||||
"nonCombatOnly": "Tylko poza walką",
|
||||
"triggers": "Wyzwalacze",
|
||||
"triggerEffect": "Efekt wyzwalacza",
|
||||
"castingMode": {
|
||||
"label": "Tryb rzucania",
|
||||
"standard": "Standardowy",
|
||||
"sacred": "Magia Sakralna",
|
||||
"witch": "Wiedźmia Magia",
|
||||
"blood": "Magia Krwi",
|
||||
"hekate": "Hybryda Hekate"
|
||||
}
|
||||
},
|
||||
"spellSchool": {
|
||||
"general": "Ogólna",
|
||||
"alchemyTransmutation": "Alchemia - Transmutacja",
|
||||
"alchemyBrewing": "Alchemia - Warzenie Eliksirów",
|
||||
"botany": "Botanika",
|
||||
"elementsAir": "Magia Żywiołów (Powietrze)",
|
||||
"elementsWater": "Magia Żywiołów (Woda)",
|
||||
"elementsFire": "Magia Żywiołów (Ogień)",
|
||||
"elementsEarth": "Magia Żywiołów (Ziemia)",
|
||||
"artifice": "Rzemiosło Artefaktów",
|
||||
"golemancy": "Golemancja",
|
||||
"runes": "Magia Runiczna",
|
||||
"manaSourceMage": "Źródło Mocy",
|
||||
"illusion": "Magia Iluzji",
|
||||
"sacred": "Magia Sakralna",
|
||||
"sacredExorcism": "Magia Sakralna — Egzorcyzmy",
|
||||
"witch": "Wiedźmia Magia",
|
||||
"necromancy": "Nekromancja",
|
||||
"blood": "Magia Krwi",
|
||||
"crimson": "Magia Szkarłatu",
|
||||
"abyssAspects": "Magia Otchłani — Aspekty",
|
||||
"abyssPrimal": "Magia Otchłani — Pierwotna Magia",
|
||||
"wildWitch": "Dzika Wiedźmia Magia"
|
||||
},
|
||||
"deity": {
|
||||
"common": "Modlitwy Powszechne",
|
||||
"jahwe": "Jahwe",
|
||||
"zeus": "Zeus",
|
||||
"demeter": "Demeter",
|
||||
"artemis": "Artemida",
|
||||
"hekate": "Hekate",
|
||||
"aphrodite": "Afrodyta",
|
||||
"eros": "Eros"
|
||||
},
|
||||
"sourceBook": {
|
||||
"core-rules": "PG",
|
||||
"magic-book": "KM",
|
||||
"arcanum-sanguinis": "AS",
|
||||
"crimson-cult": "CS",
|
||||
"abyss-curse": "KO",
|
||||
"humanity-guide": "PLM",
|
||||
"bestiary": "B",
|
||||
"gold-steel-magic": "ZSM"
|
||||
},
|
||||
"aoeShape": {
|
||||
"point": "Punkt",
|
||||
"square": "Kwadrat",
|
||||
"rectangle": "Prostokąt",
|
||||
"cone": "Stożek",
|
||||
"sphere": "Sfera",
|
||||
"line": "Linia"
|
||||
},
|
||||
"damageType": {
|
||||
"physical": "Fizyczne",
|
||||
"magical": "Magiczne",
|
||||
"physicalMagical": "Fizyczno-Magiczne",
|
||||
"pure": "Czyste"
|
||||
},
|
||||
"triggerEvent": {
|
||||
"killWithWeapon": "Zabicie bronią",
|
||||
"targetCastsSpell": "Cel rzuca zaklęcie",
|
||||
"damageTaken": "Otrzymanie obrażeń",
|
||||
"turnStart": "Początek tury"
|
||||
},
|
||||
"spellCast": {
|
||||
"manaSpent": "Wydana Mana",
|
||||
"zealSpent": "Wydany Zapał",
|
||||
"bloodSpent": "Wydana Krew",
|
||||
"hekateMode": "Tryb Hekate (hybryda Sakralna+Wiedźmia)",
|
||||
"groupCasters": "ID czarujących (grupowo)",
|
||||
"bypassSuperspell": "Pomiń ostrzeżenie o superczarze",
|
||||
"bypassNonCombatBlock": "Pomiń blokadę poza walką",
|
||||
"success": "Sukces",
|
||||
"failure": "Niepowodzenie",
|
||||
"damage": "Obrażenia",
|
||||
"statusEffects": "Efekty stanów",
|
||||
"saveTarget": "Test obronny celu",
|
||||
"triggers": "Wyzwalacze",
|
||||
"applyToTargets": "Kliknij efekt, aby nałożyć na zaznaczone tokeny.",
|
||||
"targetSomeone": "Wybierz cele, aby pokazać przyciski zastosowania obrażeń.",
|
||||
"noTargets": "Brak zaznaczonych tokenów / celów.",
|
||||
"variableSuccesses": "Wymagana liczba sukcesów",
|
||||
"groupCastersFromCanvas": "Współczarujący (zaznacz tokeny na mapie)"
|
||||
},
|
||||
"gear": {
|
||||
"category": "Kategoria",
|
||||
"weapon": "Broń",
|
||||
"armor": "Zbroja",
|
||||
"equipment": "Ekwipunek",
|
||||
"damage": "Obrażenia",
|
||||
"damageType": "Typ Obrażeń",
|
||||
"physical": "Fizyczne",
|
||||
"magical": "Magiczne",
|
||||
"environmental": "Środowiskowe",
|
||||
"properties": "Właściwości",
|
||||
"damageReduction": "Redukcja Obrażeń",
|
||||
"damageReductionBonus": "Premia do Redukcji",
|
||||
"damageReductionBonusHint": "Dodatkowa redukcja obrażeń (np. od talentów lub przedmiotów). Liczy się tylko gdy zbroja zostanie zniszczona.",
|
||||
"condition": "Stan",
|
||||
"conditionMax": "Maks. Stan",
|
||||
"equipped": "Założone / Aktywne",
|
||||
"repair": "Napraw",
|
||||
"armorType": "Typ Zbroi",
|
||||
"light": "Lekka",
|
||||
"medium": "Średnia",
|
||||
"heavy": "Ciężka",
|
||||
"shield": "Tarcza",
|
||||
"stealthDisadvantage": "Utrudnia Skradanie",
|
||||
"strengthRequirement": "Wymóg Siły",
|
||||
"rarity": "Rzadkość",
|
||||
"value": "Wartość",
|
||||
"weight": "Waga",
|
||||
"quantity": "Ilość",
|
||||
"description": "Opis"
|
||||
},
|
||||
"ability": {
|
||||
"type": "Typ",
|
||||
"passive": "Pasywna",
|
||||
"active": "Aktywna",
|
||||
"reaction": "Reakcja",
|
||||
"freeAction": "Akcja Darmowa",
|
||||
"prerequisite": "Wymóg",
|
||||
"description": "Opis",
|
||||
"mechanics": "Mechanika",
|
||||
"cost": "Koszt"
|
||||
},
|
||||
"class": {
|
||||
"year": "Rok",
|
||||
"attributePoints": "Punkty Atrybutów",
|
||||
"skillPoints": "Punkty Umiejętności",
|
||||
"talents": "Talenty",
|
||||
"spells": "Zaklęcia",
|
||||
"features": "Cechy",
|
||||
"description": "Opis"
|
||||
},
|
||||
"race": {
|
||||
"availableDisciplines": "Dostępne Dziedziny Magii",
|
||||
"attributePoints": "Startowe Punkty Atrybutów",
|
||||
"skillPoints": "Startowe Punkty Umiejętności",
|
||||
"freeTalents": "Darmowe Talenty",
|
||||
"racialAbilities": "Zdolności Rasowe",
|
||||
"traits": "Cechy Rasowe",
|
||||
"description": "Opis",
|
||||
"physicalDescription": "Opis Fizyczny"
|
||||
},
|
||||
"discipline": {
|
||||
"color": "Kolor",
|
||||
"suggestedAttribute": "Sugerowany Atrybut",
|
||||
"suggestedSkills": "Sugerowane Umiejętności",
|
||||
"passiveAbility": "Umiejętność Pasywna",
|
||||
"signatureSpells": "Zaklęcia Sygnaturowe",
|
||||
"availableSpells": "Dostępne Zaklęcia",
|
||||
"description": "Opis"
|
||||
},
|
||||
"talent": {
|
||||
"requirements": "Wymagania",
|
||||
"requirementRace": "Rasa",
|
||||
"requirementAttribute": "Atrybut",
|
||||
"requirementSkill": "Umiejętność",
|
||||
"requirementTalent": "Talent",
|
||||
"requirementTitle": "Tytuł",
|
||||
"requirementDiscipline": "Dziedzina",
|
||||
"description": "Opis",
|
||||
"effect": "Efekt",
|
||||
"cost": "Koszt",
|
||||
"multiSelect": "Można wybrać wielokrotnie"
|
||||
},
|
||||
"npc": {
|
||||
"type": "Typ",
|
||||
"size": "Rozmiar",
|
||||
"alignment": "Charakter",
|
||||
"challengeRating": "Poziom Wyzwania",
|
||||
"challengeRatingAbbr": "PW",
|
||||
"xp": "PD",
|
||||
"speed": "Szybkość",
|
||||
"senses": "Zmysły",
|
||||
"languages": "Języki",
|
||||
"attacks": "Ataki",
|
||||
"specialAbilities": "Zdolności Specjalne",
|
||||
"reactions": "Reakcje",
|
||||
"legendaryActions": "Akcje Legendarne",
|
||||
"savingThrows": "Rzuty Obronne",
|
||||
"damageResistances": "Odporności na Obrażenia",
|
||||
"damageImmunities": "Niewrażliwości na Obrażenia",
|
||||
"conditionImmunities": "Niewrażliwości na Stany",
|
||||
"habitat": "Habitat",
|
||||
"behavior": "Zachowanie",
|
||||
"history": "Historia"
|
||||
},
|
||||
"roll": {
|
||||
"title": "Rzut TS",
|
||||
"roll": "Rzuć",
|
||||
"pool": "Pula kości",
|
||||
"threshold": "Trudność (T)",
|
||||
"successes": "Sukcesy",
|
||||
"required": "Wymagane",
|
||||
"result": "Wynik",
|
||||
"success": "Sukces",
|
||||
"failure": "Porażka",
|
||||
"criticalSuccess": "Krytyczny sukces",
|
||||
"criticalFailure": "Krytyczna porażka",
|
||||
"rollSkill": "Rzuć: {skill}",
|
||||
"rollAttribute": "Rzuć: {attribute}",
|
||||
"modifier": "Modyfikator",
|
||||
"attributeOverride": "Atrybut (zmiana)",
|
||||
"configTitle": "Konfiguracja rzutu TS",
|
||||
"blocked": "Nie można wykonać rzutu: {reason}",
|
||||
"thresholdAdjusted": "Trudność zmodyfikowana stanami"
|
||||
},
|
||||
"spellCast": {
|
||||
"title": "Rzucanie zaklęcia",
|
||||
"manaSpent": "Wydana mana",
|
||||
"cast": "Rzuć",
|
||||
"notEnoughMana": "Niewystarczająca mana.",
|
||||
"notEnoughZeal": "Niewystarczający Zapał.",
|
||||
"notEnoughBlood": "Niewystarczająca Krew.",
|
||||
"raceLocked": "Zaklęcie wymaga rasy: {races}.",
|
||||
"talentMissing": "Brak wymaganego talentu: {talent}.",
|
||||
"disciplineMissing": "Wymagana jedna z dziedzin: {disciplines}.",
|
||||
"deityMismatch": "Bóstwo zaklęcia ({spell}) różni się od wybranego bóstwa ({actor}).",
|
||||
"groupCastRequired": "Wymaga {min} współczarujących; jest {provided}.",
|
||||
"nonCombatOnly": "Tego zaklęcia nie można rzucać w walce.",
|
||||
"superspellInCombat": "Rzucanie superczaru w czasie walki jest zazwyczaj niewskazane.",
|
||||
"overcast": "Nadczarowanie",
|
||||
"overcastTest": "Test Nadczarowania",
|
||||
"sacredCost": "Koszt: 1 Zapał",
|
||||
"witchSymbols": "Symbole: {used} / {max}",
|
||||
"cantOvercastBeyondLevel": "Nie można nadczarowywać powyżej limitu Mocy Magicznej.",
|
||||
"overcastWarning": "To zaklęcie przekracza limit many na zaklęcie."
|
||||
},
|
||||
"damage": {
|
||||
"title": "Zadaj obrażenia",
|
||||
"amount": "Ilość obrażeń",
|
||||
"type": "Typ obrażeń",
|
||||
"ignoreMagicalArmor": "Pomiń Pancerz Magiczny",
|
||||
"ignoreMagicalShield": "Pomiń Tarczę Magiczną",
|
||||
"ignorePhysicalArmor": "Pomiń Pancerz Fizyczny",
|
||||
"damageArmor": "Uszkodź zbroję (-1 stan)",
|
||||
"apply": "Zastosuj",
|
||||
"report": {
|
||||
"title": "Obrażenia: {actor}",
|
||||
"magicalArmorAbsorbed": "Pancerz Magiczny pochłonął: {n}",
|
||||
"magicalShieldAbsorbed": "Tarcza Magiczna pochłonęła: {n}",
|
||||
"physicalArmorReduced": "Pancerz Fizyczny zredukował: {n}",
|
||||
"healthLost": "Utracona Żywotność: {n}",
|
||||
"armorBroken": "Zbroja zniszczona!",
|
||||
"runeTriggered": "Runy zadziałały — Pancerz Magiczny -1"
|
||||
}
|
||||
},
|
||||
"combat": {
|
||||
"newRound": "Nowa runda — Mana zresetowana",
|
||||
"newTurn": "Nowa tura — Zapał +1",
|
||||
"turnSkipped": "Tura pominięta (stan)",
|
||||
"deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)"
|
||||
},
|
||||
"conditions": {
|
||||
"przewrocony": "Przewrócony",
|
||||
"unieruchomiony": "Unieruchomiony",
|
||||
"obezwladniony": "Obezwładniony",
|
||||
"nieprzytomny": "Nieprzytomny",
|
||||
"umierajacy": "Umierający",
|
||||
"ogluszony": "Ogłuszony",
|
||||
"oslepiony": "Oślepiony",
|
||||
"oszolomiony": "Oszołomiony",
|
||||
"zatruty": "Zatruty",
|
||||
"podpalony": "Podpalony",
|
||||
"spowolniony": "Spowolniony",
|
||||
"przeklety": "Przeklęty",
|
||||
"zauroczony": "Zauroczony",
|
||||
"koncentracja": "Koncentracja",
|
||||
"przerazony": "Przerażony"
|
||||
},
|
||||
"ui": {
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń",
|
||||
"add": "Dodaj",
|
||||
"cancel": "Anuluj",
|
||||
"confirm": "Potwierdź",
|
||||
"name": "Nazwa",
|
||||
"notes": "Notatki",
|
||||
"biography": "Biografia",
|
||||
"dropItems": "Przeciągnij przedmioty na tę kartę",
|
||||
"unknownItemType": "Nieznany typ przedmiotu",
|
||||
"advancement": "Rozwój",
|
||||
"pointsAvailable": "Dostępne punkty",
|
||||
"spend": "Wydaj",
|
||||
"attacks": "Ataki",
|
||||
"specialAbilities": "Zdolności Specjalne",
|
||||
"reactions": "Reakcje",
|
||||
"legendaryActions": "Akcje Legendarne",
|
||||
"actionsTitle": "Szybkie Akcje",
|
||||
"customEquipment": "Własny ekwipunek",
|
||||
"attributePoints": "P. Atrybutów",
|
||||
"skillPoints": "P. Umiejętności",
|
||||
"freeTalents": "W. Talenty",
|
||||
"money": "Gotówka",
|
||||
"recalculate": "Kalkulator walut",
|
||||
"moneyConverterTitle": "Kalkulator Walut",
|
||||
"changeImageHint": "Kliknij, aby zmienić obrazek",
|
||||
"itemAlreadyOwned": "'{name}' jest już na tej karcie postaci.",
|
||||
"dropTalentsHint": "Przeciągnij talenty z kompendium na tę kartę.",
|
||||
"physicalArmorBonus": "Premia do pancerza",
|
||||
"selectTalentParamTitle": "Specjalizacja Talentu",
|
||||
"selectTalentParamDesc": "Ten talent wymaga określenia specjalizacji/parametru. Wpisz wartość dla parametru ({param}):",
|
||||
"tabs": {
|
||||
"stats": "Statystyki",
|
||||
"actions": "Akcje",
|
||||
"skills": "Umiejętności",
|
||||
"main": "Karta",
|
||||
"spells": "Zaklęcia",
|
||||
"inventory": "Ekwipunek",
|
||||
"talents": "Talenty",
|
||||
"effects": "Efekty",
|
||||
"biography": "Biografia"
|
||||
}
|
||||
},
|
||||
"rest": {
|
||||
"takeBreather": "Chwila Wytchnienia",
|
||||
"shortRest": "Krótki Odpoczynek",
|
||||
"longRest": "Długi Odpoczynek",
|
||||
"restored": "Przywrócono",
|
||||
"hpReset": "Punkty Wytrzymałości",
|
||||
"manaReset": "Mana",
|
||||
"zealReset": "Zapał",
|
||||
"bloodReset": "Pula Krwi",
|
||||
"toleranceRecovered": "Odzyskana tolerancja",
|
||||
"effectsCleared": "Wygasłe efekty wyczyszczone"
|
||||
},
|
||||
"initiative": {
|
||||
"label": "Inicjatywa",
|
||||
"formula": "2k6 + Inicjatywa",
|
||||
"roll": "Rzuć Inicjatywę"
|
||||
},
|
||||
"talentCategory": {
|
||||
"core": "Podręcznik Gry",
|
||||
"blood": "Magia Krwi",
|
||||
"abyss": "Klątwa Otchłani",
|
||||
"crimson": "Szkarłatny Kult",
|
||||
"npc": "NPC"
|
||||
},
|
||||
"macro": {
|
||||
"poolRoll": "Rzut Puli d6",
|
||||
"poolRollHint": "Rzut puli d6 z wybranym progiem sukcesu (TS).",
|
||||
"attributeCheck": "Test Atrybutu",
|
||||
"attributeCheckHint": "Test atrybutu wybranej postaci.",
|
||||
"applyCondition": "Nałóż Przypadłość",
|
||||
"applyConditionHint": "Nałóż wybraną przypadłość na zaznaczone/nacelowane tokeny.",
|
||||
"takeBreather": "Chwila Wytchnienia",
|
||||
"takeBreatherHint": "Chwila wytchnienia dla wybranej postaci.",
|
||||
"shortRest": "Krótki Odpoczynek",
|
||||
"shortRestHint": "Krótki odpoczynek dla wybranej postaci.",
|
||||
"longRest": "Długi Odpoczynek",
|
||||
"longRestHint": "Długi odpoczynek dla wybranej postaci.",
|
||||
"groupCastHelper": "Pomocnik Rzucania Grupowego",
|
||||
"groupCastHelperHint": "Sumuje manę i pulę magii dla zaznaczonych współrzucających.",
|
||||
"refillZeal": "Doładuj Zapał",
|
||||
"refillZealHint": "Dodaje 1 Zapał do wybranej postaci (debug)."
|
||||
},
|
||||
"activeEffect": {
|
||||
"title": "Efekty (Active Effects)",
|
||||
"addEffect": "Dodaj Efekt",
|
||||
"deleteEffect": "Usuń",
|
||||
"edit": "Edytuj",
|
||||
"toggle": "Włącz/wyłącz",
|
||||
"newEffect": "Nowy Efekt",
|
||||
"transferToActor": "Przenoszony na postać",
|
||||
"fromItem": "Z przedmiotu",
|
||||
"changes": "zmian",
|
||||
"empty": "Brak efektów. Dodaj efekt aby nadać premie do atrybutów lub flag systemowych.",
|
||||
"emptyActor": "Ta postać nie ma żadnych efektów."
|
||||
},
|
||||
"racesList": {
|
||||
"czlowiek": "Człowiek",
|
||||
"elf": "Elf",
|
||||
"lamia": "Lamia",
|
||||
"feles": "Feles",
|
||||
"aniol": "Anioł"
|
||||
},
|
||||
"talentsList": {
|
||||
"arcymag-dziedzina": "Arcymag (Dyscyplina)",
|
||||
"blogoslawienstwo-bostwo": "Błogosławieństwo (Bóstwo)",
|
||||
"blogoslawione-pochodzenie": "Błogosławione Pochodzenie",
|
||||
"cios-w-plecy": "Cios w plecy",
|
||||
"czuly-zmysl": "Czuły zmysł",
|
||||
"dyplomata": "Dyplomata",
|
||||
"finta": "Finta",
|
||||
"gadanina": "Gadanina",
|
||||
"gotowosc-do-walki": "Gotowość do walki",
|
||||
"jednosc-z-magia": "Jedność z magią",
|
||||
"jednosc-z-natura": "Jedność z naturą",
|
||||
"magiczna-dyscyplina": "Magiczna dyscyplina",
|
||||
"magiczny-zmysl-zmysl": "Magiczny zmysł (Zmysł)",
|
||||
"mistrz-magii-dziedzina": "Mistrz magii (Dziedzina)",
|
||||
"niezniszczalne-zaklecia": "Niezniszczalne zaklęcia",
|
||||
"obureczny": "Oburęczny",
|
||||
"odpornosc-na-magie": "Odporność na magię",
|
||||
"odpornosc-na-trucizny": "Odporność na trucizny",
|
||||
"oportunista": "Oportunista",
|
||||
"pancerz-wiary": "Pancerz wiary",
|
||||
"parowanie": "Parowanie",
|
||||
"powietrzne-uniki": "Powietrzne uniki",
|
||||
"riposta": "Riposta",
|
||||
"rozplatanie-zaklec": "Rozplatanie zaklęć",
|
||||
"sen-na-jawie": "Sen na jawie",
|
||||
"technologia-jest-wsrod-nas": "Technologia jest wśród nas",
|
||||
"urok-osobisty": "Urok osobisty",
|
||||
"zakazana-wiedza-dziedzina": "Zakazana wiedza (Dziedzina)",
|
||||
"zawisanie-w-powietrzu": "Zawisanie w powietrzu"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "hbm-rpg-v3-foundry",
|
||||
"version": "1.3.12",
|
||||
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build": "vite build",
|
||||
"bump": "bun scripts/bump-version.ts",
|
||||
"bump:minor": "bun scripts/bump-version.ts minor",
|
||||
"bump:major": "bun scripts/bump-version.ts major",
|
||||
"build:packs": "bun scripts/build-packs.ts && bun scripts/generate-babele-translations.ts",
|
||||
"build:packs:strict": "bun scripts/build-packs.ts --strict",
|
||||
"lint:uuid-refs": "bun scripts/lint-uuid-refs.ts",
|
||||
"package": "bun scripts/bump-version.ts && bun run build:packs && bun run build && bun scripts/package.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"push": "bun scripts/push-package.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@foundryvtt/foundryvtt-cli": "^3.0.3",
|
||||
"@league-of-foundry-developers/foundry-vtt-types": "^13.0.0",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"archiver": "^7.0.1",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"unified": "^11.0.5",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-static-copy": "^1.0.6",
|
||||
"vitest": "^2.1.0",
|
||||
"happy-dom": "^15.7.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"license": "SEE LICENSE IN LICENSE.txt"
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Build LevelDB compendium packs from parsed book content.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Load id overrides + label mappings.
|
||||
* 2. Run all enabled parsers; collect ParsedDoc[].
|
||||
* 3. Group by pack id; convert each ParsedDoc into a Foundry document JSON.
|
||||
* 4. Pipe into `compilePack` (LevelDB) one pack at a time.
|
||||
* 5. Emit a manifest summary to stdout.
|
||||
*
|
||||
* Run with: `bun scripts/build-packs.ts` from `.src/foundry-system/`.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compilePack } from '@foundryvtt/foundryvtt-cli';
|
||||
import type { ParsedDoc, ParserContext } from './parsers/types';
|
||||
import { parseSpells } from './parsers/spell-parser';
|
||||
import { parseTalents } from './parsers/talent-parser';
|
||||
import { parseRaces } from './parsers/race-parser';
|
||||
import { parseDisciplines } from './parsers/discipline-parser';
|
||||
import { parseMacros } from './parsers/macro-parser';
|
||||
import { parseGear } from './parsers/gear-parser';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const systemRoot = resolve(__dirname, '..');
|
||||
const repoRoot = resolve(systemRoot, '..', '..');
|
||||
const packsSrcDir = resolve(systemRoot, 'packs-src');
|
||||
const packsOutDir = resolve(systemRoot, 'packs');
|
||||
|
||||
function loadJson<T>(path: string): T {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
||||
}
|
||||
|
||||
const ctx: ParserContext = {
|
||||
repoRoot,
|
||||
strict: process.argv.includes('--strict'),
|
||||
idOverrides: loadJson(resolve(__dirname, 'parsers', '_id-overrides.json')),
|
||||
labelMappings: loadJson(resolve(__dirname, 'parsers', '_label-mappings.json')),
|
||||
};
|
||||
|
||||
console.log(`[build-packs] repo root: ${repoRoot}`);
|
||||
console.log(`[build-packs] strict mode: ${ctx.strict ?? false}`);
|
||||
|
||||
// 1-2. Run parsers.
|
||||
const allDocs: ParsedDoc[] = [];
|
||||
allDocs.push(...(await parseSpells(ctx)));
|
||||
allDocs.push(...(await parseTalents(ctx)));
|
||||
allDocs.push(...(await parseRaces(ctx)));
|
||||
allDocs.push(...(await parseDisciplines(ctx)));
|
||||
allDocs.push(...(await parseMacros(ctx)));
|
||||
allDocs.push(...(await parseGear(ctx)));
|
||||
|
||||
console.log(`[build-packs] parsed ${allDocs.length} documents total`);
|
||||
|
||||
// 3. Group by pack and emit one JSON per doc into packs-src/<pack>/.
|
||||
if (existsSync(packsSrcDir)) rmSync(packsSrcDir, { recursive: true });
|
||||
const byPack = new Map<string, ParsedDoc[]>();
|
||||
for (const doc of allDocs) {
|
||||
if (ctx.idOverrides[doc.id]) {
|
||||
doc.id = ctx.idOverrides[doc.id];
|
||||
}
|
||||
const list = byPack.get(doc.pack) ?? [];
|
||||
list.push(doc);
|
||||
byPack.set(doc.pack, list);
|
||||
}
|
||||
|
||||
function getFolderHierarchy(doc: ParsedDoc): { name: string; parentName?: string }[] | null {
|
||||
if (doc.subType === 'spell') {
|
||||
const system = doc.system as Record<string, any>;
|
||||
const school = system.school;
|
||||
const deity = system.deity;
|
||||
const isSuper = system.isSuperspell;
|
||||
|
||||
if (doc.pack === 'spells-academic') {
|
||||
if (school === 'elementsAir') {
|
||||
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Powietrza', parentName: 'Magia Żywiołów' }];
|
||||
}
|
||||
if (school === 'elementsWater') {
|
||||
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Wody', parentName: 'Magia Żywiołów' }];
|
||||
}
|
||||
if (school === 'elementsFire') {
|
||||
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ognia', parentName: 'Magia Żywiołów' }];
|
||||
}
|
||||
if (school === 'elementsEarth') {
|
||||
return [{ name: 'Magia Żywiołów' }, { name: 'Magia Ziemi', parentName: 'Magia Żywiołów' }];
|
||||
}
|
||||
if (school === 'alchemyTransmutation') {
|
||||
return [{ name: 'Alchemia' }, { name: 'Alchemia - Transmutacja', parentName: 'Alchemia' }];
|
||||
}
|
||||
if (school === 'alchemyBrewing') {
|
||||
return [{ name: 'Alchemia' }, { name: 'Alchemia - Warzenie Eliksirów', parentName: 'Alchemia' }];
|
||||
}
|
||||
if (school === 'artifice') {
|
||||
return [{ name: 'Rzemiosło Artefaktów' }];
|
||||
}
|
||||
if (school === 'golemancy') {
|
||||
return [{ name: 'Golemancja' }];
|
||||
}
|
||||
if (school === 'runes') {
|
||||
return [{ name: 'Magia Runiczna' }];
|
||||
}
|
||||
if (school === 'illusion') {
|
||||
return [{ name: 'Magia Iluzji' }];
|
||||
}
|
||||
if (school === 'witch') {
|
||||
return [{ name: 'Wiedźmia Magia' }];
|
||||
}
|
||||
if (school === 'necromancy') {
|
||||
return [{ name: 'Nekromancja' }];
|
||||
}
|
||||
if (school === 'botany') {
|
||||
return [{ name: 'Botanika' }];
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.pack === 'spells-sacred') {
|
||||
const DEITY_NAMES: Record<string, string> = {
|
||||
common: 'Modlitwy Ogólne',
|
||||
jahwe: 'Bóg (Jedyny)',
|
||||
zeus: 'Zeus (Jowisz)',
|
||||
demeter: 'Demeter (Ceres)',
|
||||
artemis: 'Artemida (Diana)',
|
||||
hekate: 'Hekate',
|
||||
aphrodite: 'Afrodyta (Wenus)',
|
||||
eros: 'Amor (Eros)',
|
||||
};
|
||||
const name = DEITY_NAMES[deity] ?? 'Inne Modlitwy';
|
||||
return [{ name }];
|
||||
}
|
||||
|
||||
if (doc.pack === 'spells-abyss') {
|
||||
if (school === 'abyssAspects') {
|
||||
return [{ name: 'Magia Aspektów' }];
|
||||
}
|
||||
if (school === 'abyssPrimal') {
|
||||
return [{ name: 'Pierwotna Magia' }];
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.pack === 'spells-general') {
|
||||
if (isSuper) {
|
||||
return [{ name: 'Superzaklęcia' }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.subType === 'discipline') {
|
||||
if (doc.pack === 'disciplines') {
|
||||
if (doc.id === 'alchemyTransmutation' || doc.id === 'alchemyBrewing') {
|
||||
return [{ name: 'Alchemia' }];
|
||||
}
|
||||
if (doc.id.startsWith('elements')) {
|
||||
return [{ name: 'Magia Żywiołów' }];
|
||||
}
|
||||
if (doc.id.startsWith('sacred')) {
|
||||
return [{ name: 'Magia Sakralna' }];
|
||||
}
|
||||
}
|
||||
if (doc.pack === 'disciplines-forbidden') {
|
||||
if (doc.id.startsWith('abyss')) {
|
||||
return [{ name: 'Magia Otchłani' }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [pack, docs] of byPack) {
|
||||
const dir = resolve(packsSrcDir, pack);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const foldersMap = new Map<string, { _id: string; name: string; type: string; folder: string | null; sort: number; flags: any }>();
|
||||
|
||||
for (const doc of docs) {
|
||||
let folderId: string | null = null;
|
||||
const hierarchy = getFolderHierarchy(doc);
|
||||
|
||||
if (hierarchy) {
|
||||
for (const step of hierarchy) {
|
||||
const stepId = makeFoundryId(`folder-${pack}-${step.parentName ? step.parentName + '-' : ''}${step.name}`, 16);
|
||||
let parentId: string | null = null;
|
||||
if (step.parentName) {
|
||||
parentId = makeFoundryId(`folder-${pack}-${step.parentName}`, 16);
|
||||
}
|
||||
if (!foldersMap.has(stepId)) {
|
||||
foldersMap.set(stepId, {
|
||||
_id: stepId,
|
||||
name: step.name,
|
||||
type: 'Item',
|
||||
folder: parentId,
|
||||
sort: 0,
|
||||
flags: {}
|
||||
});
|
||||
}
|
||||
folderId = stepId;
|
||||
}
|
||||
}
|
||||
|
||||
const foundryDoc = toFoundryDoc(doc, folderId);
|
||||
writeFileSync(resolve(dir, `${doc.id}.json`), `${JSON.stringify(foundryDoc, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
for (const [folderId, folderDoc] of foldersMap) {
|
||||
const folderFoundryDoc = {
|
||||
_key: `!folders!${folderId}`,
|
||||
...folderDoc
|
||||
};
|
||||
writeFileSync(resolve(dir, `_folder-${folderId}.json`), `${JSON.stringify(folderFoundryDoc, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
console.log(` · ${pack}: ${docs.length} docs → packs-src/${pack}/`);
|
||||
}
|
||||
|
||||
// 4. Compile each pack into LevelDB under packs/.
|
||||
if (existsSync(packsOutDir)) rmSync(packsOutDir, { recursive: true });
|
||||
for (const pack of byPack.keys()) {
|
||||
const src = resolve(packsSrcDir, pack);
|
||||
const dest = resolve(packsOutDir, pack);
|
||||
await compilePack(src, dest, { recursive: false, log: false });
|
||||
console.log(` ✓ compiled ${pack}`);
|
||||
}
|
||||
|
||||
console.log(`[build-packs] done — ${byPack.size} packs in packs/`);
|
||||
|
||||
/** Convert a ParsedDoc into a Foundry document JSON object suitable for compilePack. */
|
||||
function toFoundryDoc(doc: ParsedDoc, folderId: string | null = null): Record<string, unknown> {
|
||||
const fId = makeFoundryId(doc.id);
|
||||
// Macro documents have a different shape than Items.
|
||||
if (doc.subType === 'macro') {
|
||||
const m = doc.system as { command: string; img?: string; scope?: string; type?: string };
|
||||
return {
|
||||
_key: `!macros!${fId}`,
|
||||
_id: fId,
|
||||
name: doc.name,
|
||||
type: m.type ?? 'script',
|
||||
scope: m.scope ?? 'global',
|
||||
command: m.command,
|
||||
img: m.img ?? 'icons/svg/dice-target.svg',
|
||||
author: null,
|
||||
folder: folderId,
|
||||
sort: 0,
|
||||
flags: {
|
||||
'hbm-rpg-v3': {
|
||||
slug: doc.id,
|
||||
sourceBook: doc.source.book,
|
||||
...(doc.flags ?? {}),
|
||||
},
|
||||
},
|
||||
_stats: { systemId: 'hbm-rpg-v3' },
|
||||
};
|
||||
}
|
||||
return {
|
||||
_key: `!items!${fId}`,
|
||||
_id: fId,
|
||||
name: doc.name,
|
||||
type: doc.subType,
|
||||
img: 'icons/svg/book.svg',
|
||||
system: doc.system,
|
||||
folder: folderId,
|
||||
sort: 0,
|
||||
flags: {
|
||||
'hbm-rpg-v3': {
|
||||
slug: doc.id,
|
||||
sourceBook: doc.source.book,
|
||||
sourceChapter: doc.source.chapter ?? '',
|
||||
sourceLine: doc.source.line ?? 0,
|
||||
...(doc.flags ?? {}),
|
||||
},
|
||||
},
|
||||
_stats: { systemId: 'hbm-rpg-v3' },
|
||||
};
|
||||
}
|
||||
|
||||
/** Foundry doc IDs must be 12 chars [A-Za-z0-9] (16 chars for Folders). Hash slug to a deterministic id.
|
||||
* Uses two independent FNV-1a 32-bit hashes to avoid collisions on long
|
||||
* common-prefix slugs (e.g. talent-page-0 vs talent-page-1). */
|
||||
function makeFoundryId(slug: string, length = 12): string {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0x4b9ace3f;
|
||||
for (let i = 0; i < slug.length; i++) {
|
||||
const c = slug.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
|
||||
h2 = Math.imul(h2 ^ (c + i + 1), 0x01000193) >>> 0;
|
||||
}
|
||||
let out = '';
|
||||
let lo = h1;
|
||||
let hi = h2;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const combined = (i % 2 === 0 ? lo : hi) >>> 0;
|
||||
out += alphabet[combined % alphabet.length];
|
||||
lo = Math.imul(lo, 1664525) + 1013904223 >>> 0;
|
||||
hi = Math.imul(hi, 22695477) + 1013904223 >>> 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Bumps the patch version in package.json and system.json in lock-step.
|
||||
* Run automatically as part of `bun run package`.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/bump-version.ts # patch bump (0.1.0 → 0.1.1)
|
||||
* bun scripts/bump-version.ts minor # minor bump (0.1.0 → 0.2.0)
|
||||
* bun scripts/bump-version.ts major # major bump (0.1.0 → 1.0.0)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
type BumpType = 'major' | 'minor' | 'patch';
|
||||
const bump = (process.argv[2] ?? 'patch') as BumpType;
|
||||
|
||||
function bumpVersion(version: string, type: BumpType): string {
|
||||
const [major, minor, patch] = version.split('.').map(Number);
|
||||
if (type === 'major') return `${major + 1}.0.0`;
|
||||
if (type === 'minor') return `${major}.${minor + 1}.0`;
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
// --- package.json ---
|
||||
const pkgPath = resolve(root, 'package.json');
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string };
|
||||
const oldVersion = pkg.version;
|
||||
const newVersion = bumpVersion(oldVersion, bump);
|
||||
pkg.version = newVersion;
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
|
||||
// --- system.json ---
|
||||
const sysPath = resolve(root, 'system.json');
|
||||
const sys = JSON.parse(readFileSync(sysPath, 'utf8')) as { version: string; download: string };
|
||||
sys.version = newVersion;
|
||||
// Update the versioned filename in the download URL if it follows the vX.Y.Z pattern
|
||||
sys.download = sys.download.replace(/-v[\d.]+\.zip$/, `-v${newVersion}.zip`);
|
||||
writeFileSync(sysPath, JSON.stringify(sys, null, 2) + '\n');
|
||||
|
||||
console.log(`✓ Version bumped ${oldVersion} → ${newVersion}`);
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
function checkBOM(filePath: string) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
|
||||
console.log(`[BOM DETECTED] File starts with UTF-8 BOM: ${filePath}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`[CLEAN] No BOM detected: ${filePath} (starts with bytes: ${Array.from(buffer.slice(0, 5)).map(b => '0x' + b.toString(16).toUpperCase()).join(', ')})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
checkBOM('./templates/actor/character.hbs');
|
||||
checkBOM('./templates/actor/npc.hbs');
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
function analyzeTags(filePath: string) {
|
||||
console.log(`\n--- Analyzing tags for: ${filePath} ---`);
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// A regex to find HTML tags (opening, closing, self-closing) across newlines
|
||||
const tagRegex = /<(\/?[a-zA-Z0-9\-]+)(?:\s+[^>]*?)?>/gs;
|
||||
|
||||
const stack: { tag: string; line: number; index: number }[] = [];
|
||||
|
||||
// Helper to find line number from string index
|
||||
const getLineNumber = (index: number) => {
|
||||
return content.slice(0, index).split('\n').length;
|
||||
};
|
||||
|
||||
const selfClosingTags = new Set([
|
||||
'img', 'input', 'br', 'hr', 'meta', 'link'
|
||||
]);
|
||||
|
||||
// Clean Handlebars comments first as they might contain html
|
||||
let cleanedContent = content.replace(/\{\{!--[\s\S]*?--\}\}/g, '');
|
||||
|
||||
// Clean Handlebars blocks, but be careful of block helpers containing tags.
|
||||
// Actually, we can replace all {{...}} with space to preserve index positions
|
||||
cleanedContent = cleanedContent.replace(/\{\{[\s\S]*?\}\}/g, (match) => {
|
||||
return ' '.repeat(match.length);
|
||||
});
|
||||
|
||||
let match;
|
||||
let hasErrors = false;
|
||||
|
||||
while ((match = tagRegex.exec(cleanedContent)) !== null) {
|
||||
const tag = match[1];
|
||||
const isClosing = tag.startsWith('/');
|
||||
const tagName = isClosing ? tag.slice(1).toLowerCase() : tag.toLowerCase();
|
||||
const line = getLineNumber(match.index);
|
||||
|
||||
if (selfClosingTags.has(tagName) || match[0].endsWith('/>')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isClosing) {
|
||||
stack.push({ tag: tagName, line, index: match.index });
|
||||
} else {
|
||||
if (stack.length === 0) {
|
||||
console.log(`[Error] Extra closing tag </${tagName}> on line ${line}`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
const last = stack.pop()!;
|
||||
if (last.tag !== tagName) {
|
||||
console.log(`[Mismatch] Expected </${last.tag}> (opened on line ${last.line}), but found </${tagName}> on line ${line}`);
|
||||
hasErrors = true;
|
||||
// Push back last to try to recover
|
||||
stack.push(last);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
console.log(`[Error] Unclosed tags at end of file:`);
|
||||
stack.forEach(item => {
|
||||
console.log(` - <${item.tag}> opened on line ${item.line}`);
|
||||
});
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (!hasErrors) {
|
||||
console.log(`[Success] All non-self-closing tags are perfectly balanced!`);
|
||||
}
|
||||
}
|
||||
|
||||
analyzeTags('./templates/actor/character.hbs');
|
||||
analyzeTags('./templates/actor/npc.hbs');
|
||||
@@ -0,0 +1,116 @@
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { parseSpells } from './parsers/spell-parser.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const systemRoot = resolve(__dirname, '..');
|
||||
const repoRoot = resolve(systemRoot, '..', '..');
|
||||
const spellsOutDir = resolve(repoRoot, 'ObsidianNotes', 'spells');
|
||||
|
||||
async function run() {
|
||||
const ctx = {
|
||||
repoRoot,
|
||||
strict: false,
|
||||
idOverrides: JSON.parse(readFileSync(resolve(__dirname, 'parsers', '_id-overrides.json'), 'utf8')),
|
||||
labelMappings: JSON.parse(readFileSync(resolve(__dirname, 'parsers', '_label-mappings.json'), 'utf8')),
|
||||
};
|
||||
|
||||
const allDocs = await parseSpells(ctx);
|
||||
console.log(`Parsed ${allDocs.length} spells.`);
|
||||
|
||||
let created = 0;
|
||||
|
||||
for (const doc of allDocs) {
|
||||
if (doc.subType !== 'spell') continue;
|
||||
|
||||
const sys = doc.system as any;
|
||||
|
||||
// Determine directory structure
|
||||
let category = 'academic';
|
||||
let subcategory = sys.school;
|
||||
|
||||
if (sys.isSuperspell) {
|
||||
category = 'general';
|
||||
subcategory = 'superspells';
|
||||
} else if (sys.castingMode === 'sacred') {
|
||||
category = 'sacred';
|
||||
subcategory = sys.deity || 'common-prayers';
|
||||
if (subcategory === 'common') subcategory = 'common-prayers';
|
||||
} else if (sys.school === 'abyss' || sys.school === 'abyssAspects' || sys.school === 'abyssPrimal' || sys.school === 'eldritch' || sys.school === 'eldritchAspects' || sys.school === 'eldritchPrimal' || sys.sourceBook === 'klatwa-otchlani') {
|
||||
category = 'eldritch';
|
||||
subcategory = '';
|
||||
sys.school = 'eldritch';
|
||||
} else if (sys.school === 'crimson' || sys.sourceBook === 'crimson-cult') {
|
||||
category = 'crimson';
|
||||
subcategory = '';
|
||||
sys.school = 'crimson';
|
||||
} else if (sys.castingMode === 'blood' || sys.school === 'blood') {
|
||||
category = 'blood';
|
||||
subcategory = '';
|
||||
} else if (sys.school === 'general') {
|
||||
category = 'general';
|
||||
subcategory = '';
|
||||
} else {
|
||||
// academic mappings
|
||||
const academicMappings: Record<string, string> = {
|
||||
'elementsAir': 'air-magic',
|
||||
'elementsWater': 'water-magic',
|
||||
'elementsFire': 'fire-magic',
|
||||
'elementsEarth': 'earth-magic',
|
||||
'alchemyTransmutation': 'alchemy-transmutation',
|
||||
'alchemyBrewing': 'alchemy-brewing',
|
||||
'artifice': 'artifice',
|
||||
'golemancy': 'golemancy',
|
||||
'runes': 'rune-magic',
|
||||
'illusion': 'illusion-magic',
|
||||
'witch': 'witch-magic',
|
||||
'necromancy': 'necromancy',
|
||||
'botany': 'botany',
|
||||
'general': 'general-magic',
|
||||
'manaSourceMage': 'mana-source-mage',
|
||||
'wildWitch': 'wild-witch-magic'
|
||||
};
|
||||
subcategory = academicMappings[sys.school] || sys.school;
|
||||
}
|
||||
|
||||
let spellName = doc.name;
|
||||
if (spellName === 'Superzaklęcie - Requiem') spellName = 'Requiem';
|
||||
|
||||
// Sanitize filename to english kebab-case
|
||||
const baseSlug = spellName.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/ł/g, 'l').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const englishSlug = ctx.idOverrides[baseSlug] ?? baseSlug;
|
||||
const sanitizedName = englishSlug;
|
||||
|
||||
let targetDir = resolve(spellsOutDir, category);
|
||||
if (subcategory) {
|
||||
targetDir = resolve(targetDir, subcategory);
|
||||
}
|
||||
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Build Frontmatter
|
||||
const frontmatter = {
|
||||
tags: ['spell', category, subcategory].filter(Boolean),
|
||||
...sys
|
||||
};
|
||||
|
||||
const yamlStr = yaml.dump(frontmatter, { skipInvalid: true, noRefs: true });
|
||||
|
||||
const mdContent = `---
|
||||
${yamlStr}---
|
||||
# ${spellName}
|
||||
|
||||
${doc.description}
|
||||
`;
|
||||
|
||||
const filePath = resolve(targetDir, `${sanitizedName}.md`);
|
||||
writeFileSync(filePath, mdContent, 'utf8');
|
||||
created++;
|
||||
}
|
||||
|
||||
console.log(`Successfully extracted ${created} spells to ObsidianNotes/spells.`);
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -0,0 +1,267 @@
|
||||
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
const systemRoot = resolve(import.meta.dirname, '..');
|
||||
const packsSrcDir = resolve(systemRoot, 'packs-src');
|
||||
const outDir = resolve(systemRoot, 'lang', 'compendium', 'en');
|
||||
|
||||
// Load en.json for racesList and talentsList
|
||||
const enJsonPath = resolve(systemRoot, 'lang', 'en.json');
|
||||
const enJson = JSON.parse(readFileSync(enJsonPath, 'utf8'));
|
||||
const racesList = enJson.HBM?.racesList ?? {};
|
||||
const talentsList = enJson.HBM?.talentsList ?? {};
|
||||
|
||||
const nameTranslations: Record<string, string> = {
|
||||
// Spells
|
||||
"magiczny-pocisk": "Magic Missile",
|
||||
"magiczna-tarcza": "Magic Shield",
|
||||
"poblask": "Gleam",
|
||||
"telekineza": "Telekinesis",
|
||||
"przeskok": "Blink",
|
||||
"teleportacja": "Teleport",
|
||||
"przebicie-eteru": "Ether Pierce",
|
||||
"fala-energii": "Energy Wave",
|
||||
"przeblysk-prawdy": "Glimpse of Truth",
|
||||
"szybka-mysl": "Quick Thought",
|
||||
"latanie": "Flight",
|
||||
"powietrzna-fala": "Air Wave",
|
||||
"wyczucie-zagrozenia": "Danger Sense",
|
||||
"celnosc": "Accuracy",
|
||||
"szybki-jak-wiatr": "Swift as Wind",
|
||||
"tworzenie-i-kontrolowanie-wody": "Create and Control Water",
|
||||
"lodowa-podloga": "Ice Floor",
|
||||
"ukojenie": "Solace",
|
||||
"gradobicie": "Hailstorm",
|
||||
"mistyczna-mgla": "Mystic Fog",
|
||||
"podpalenie": "Ignite",
|
||||
"wzniecanie-ognia": "Kindle",
|
||||
"ognista-powloka": "Fire Cloak",
|
||||
"kula-ognia": "Fireball",
|
||||
"gorejaca-skora": "Burning Skin",
|
||||
"skalista-tarcza": "Stone Shield",
|
||||
"ruchome-piaski": "Quicksand",
|
||||
"trzesienie-ziemi": "Earthquake",
|
||||
"zwirowa-zbroja": "Gravel Armor",
|
||||
"skalista-pulapka": "Stone Trap",
|
||||
"eteryczny-przewodnik": "Ether Guide",
|
||||
"labirynt-umyslu": "Mind Labyrinth",
|
||||
"madrosc-salomona": "Wisdom of Solomon",
|
||||
"magiczna-odpornosc": "Magic Resistance",
|
||||
"magiczne-kajdany": "Magic Shackles",
|
||||
"magnetyzm": "Magnetism",
|
||||
"negacja": "Negation",
|
||||
"niewidzialnosc": "Invisibility",
|
||||
"ozywienie-szkieletu": "Animate Skeleton",
|
||||
"przemiana-w-olow": "Transmutation to Lead",
|
||||
"rozmawianie-ze-zmarlymi": "Speak with Dead",
|
||||
"rozsypanie": "Shatter",
|
||||
"uzdrowienie-chorych": "Heal Sick",
|
||||
"woal-snow": "Veil of Dreams",
|
||||
"zloto-glupcow": "Fool's Gold",
|
||||
"zwierciadlany-wizerunek": "Mirror Image",
|
||||
"blogoslawienstwo-zasiewow": "Blessing of Sowing",
|
||||
"blyskawice-lancuchowe": "Chain Lightning",
|
||||
"boska-celnosc": "Divine Accuracy",
|
||||
"boska-sprawiedliwosc": "Divine Justice",
|
||||
"boska-wlocznia": "Divine Spear",
|
||||
"chmara-insektow": "Swarm of Insects",
|
||||
"cykl-por-roku": "Cycle of Seasons",
|
||||
"egida": "Aegis",
|
||||
"gniew-matki-ziemi": "Wrath of Mother Earth",
|
||||
"grom-z-nieba": "Thunderbolt",
|
||||
"klatwa-hekate": "Curse of Hekate",
|
||||
"kradziez-many": "Mana Drain",
|
||||
"leczacy-dotyk": "Healing Touch",
|
||||
"magiczne-przyspieszenie": "Magic Haste",
|
||||
"moc-ziemi": "Earth Power",
|
||||
"modlitwa-o-opieke": "Prayer for Protection",
|
||||
"obecnosc-krola-bogow": "Presence of the King of Gods",
|
||||
"piekno-bogini": "Beauty of the Goddess",
|
||||
"plomien-namietnosci": "Flame of Passion",
|
||||
"pomost": "Bridge",
|
||||
"pozadanie-zmyslow": "Desire of Senses",
|
||||
"promien-harmonii": "Ray of Harmony",
|
||||
"przebudzenie": "Awakening",
|
||||
"przyspieszone-zniwa": "Accelerated Harvest",
|
||||
"sad-ostateczny": "Last Judgment",
|
||||
"strzala-kupidyna": "Cupid's Arrow",
|
||||
"strzala-pozadania": "Arrow of Desire",
|
||||
"swieta-stal": "Holy Steel",
|
||||
"tajemne-poznanie": "Arcane Cognition",
|
||||
"tesknota-serca": "Heart Longing",
|
||||
"urok-niewinnosci": "Charm of Innocence",
|
||||
"uswiecenie": "Sanctification",
|
||||
"wiez-serc": "Bond of Hearts",
|
||||
"wyladowanie-elektrostatyczne": "Electrostatic Discharge",
|
||||
"wzmocnienie-magii": "Magic Empowerment",
|
||||
"neutralizacja": "Neutralization",
|
||||
"pierwotna-kula": "Primal Ball",
|
||||
"tajemniczy-strumien": "Mysterious Stream",
|
||||
"zakazana-oslona": "Forbidden Shield",
|
||||
"klatwa-szkarlatu": "Crimson Curse",
|
||||
"szkarlatna-sprawiedliwosc": "Crimson Justice",
|
||||
"szkarlatne-plomienie": "Crimson Flames",
|
||||
"szkarlatny-pocisk": "Crimson Bolt",
|
||||
"szkarlatny-sztylet": "Crimson Dagger",
|
||||
"superzaklecie-requiem": "Superspell Requiem",
|
||||
"dar-otchlani": "Gift of the Abyss",
|
||||
"metamagia": "Metamagic",
|
||||
"swobodny-przeplyw-magii": "Free Flow of Magic",
|
||||
|
||||
// Disciplines
|
||||
"alchemytransmutation": "Alchemy - Transmutation",
|
||||
"alchemybrewing": "Alchemy - Brewing of Elixirs",
|
||||
"botany": "Botany",
|
||||
"elementsair": "Elemental Magic - Air",
|
||||
"elementswater": "Elemental Magic - Water",
|
||||
"elementsfire": "Elemental Magic - Fire",
|
||||
"elementsearth": "Elemental Magic - Earth",
|
||||
"artifice": "Crafting of Artifacts",
|
||||
"golemancy": "Golemancy",
|
||||
"runes": "Runic Magic",
|
||||
"manasourcemage": "Sources of Power",
|
||||
"illusion": "Magic of Illusion",
|
||||
"sacred": "Sacred Magic",
|
||||
"sacredexorcism": "Sacred Magic - Exorcisms",
|
||||
"witch": "Witch Magic",
|
||||
"necromancy": "Necromancy",
|
||||
"blood": "Blood Magic",
|
||||
"wildwitch": "Wild Witch Magic",
|
||||
};
|
||||
|
||||
const folderNameTranslations: Record<string, string> = {
|
||||
"Amor (Eros)": "Cupid (Eros)",
|
||||
"Zeus (Jowisz)": "Zeus (Jupiter)",
|
||||
"Afrodyta (Wenus)": "Aphrodite (Venus)",
|
||||
"Hekate": "Hecate",
|
||||
"Demeter (Ceres)": "Demeter (Ceres)",
|
||||
"Modlitwy Ogólne": "General Prayers",
|
||||
"Bóg (Jedyny)": "God (The One)",
|
||||
"Artemida (Diana)": "Artemis (Diana)",
|
||||
"Magia Żywiołów": "Elemental Magic",
|
||||
"Magia Powietrza": "Air Magic",
|
||||
"Magia Wody": "Water Magic",
|
||||
"Magia Ognia": "Fire Magic",
|
||||
"Magia Ziemi": "Earth Magic",
|
||||
"Alchemia": "Alchemy",
|
||||
"Alchemia - Transmutacja": "Alchemy - Transmutation",
|
||||
"Alchemia - Warzenie Eliksirów": "Alchemy - Brewing of Elixirs",
|
||||
"Rzemiosło Artefaktów": "Crafting of Artifacts",
|
||||
"Golemancja": "Golemancy",
|
||||
"Magia Runiczna": "Runic Magic",
|
||||
"Magia Iluzji": "Magic of Illusion",
|
||||
"Wiedźmia Magia": "Witch Magic",
|
||||
"Nekromancja": "Necromancy",
|
||||
"Botanika": "Botany",
|
||||
"Magia Aspektów": "Aspect Magic",
|
||||
"Pierwotna Magia": "Primal Magic",
|
||||
"Superzaklęcia": "Superspells",
|
||||
"Magia Otchłani": "Abyss Magic",
|
||||
"Magia Sakralna": "Sacred Magic"
|
||||
};
|
||||
|
||||
// Descriptions translation helper
|
||||
function translateDescription(desc: string): string {
|
||||
if (!desc) return '';
|
||||
return desc
|
||||
.replace(/\* \*\*Punkty Pancerza:\*\*/g, '* **Armor Points:**')
|
||||
.replace(/\* \*\*Pancerz:\*\*/g, '* **Armor:**')
|
||||
.replace(/\* \*\*Wymagania:\*\*/g, '* **Requirements:**')
|
||||
.replace(/\* \*\*Cechy:\*\*/g, '* **Traits:**')
|
||||
.replace(/\* \*\*Cena:\*\*/g, '* **Price:**')
|
||||
.replace(/\* \*\*Obrażenia:\*\*/g, '* **Damage:**')
|
||||
.replace(/\* \*\*Zasięg:\*\*/g, '* **Range:**')
|
||||
.replace(/Ciało/g, 'Body')
|
||||
.replace(/Umysł/g, 'Mind')
|
||||
.replace(/Dusza/g, 'Soul')
|
||||
.replace(/Magia/g, 'Magic')
|
||||
.replace(/Subtelny/g, 'Subtle')
|
||||
.replace(/Hałaśliwy/g, 'Noisy')
|
||||
.replace(/Tarcza, gdy jest używana, podnosi Obronę właściciela o jeden stopień/g, 'A shield, when used, increases the owner\'s Defense by one step.');
|
||||
}
|
||||
|
||||
function titleCase(slug: string): string {
|
||||
return slug
|
||||
.split('-')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Main logic
|
||||
if (!existsSync(outDir)) {
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
const packs = readdirSync(packsSrcDir);
|
||||
for (const pack of packs) {
|
||||
const packPath = join(packsSrcDir, pack);
|
||||
const files = readdirSync(packPath).filter(f => f.endsWith('.json'));
|
||||
|
||||
const translations: Record<string, any> = {};
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(packPath, file);
|
||||
const content = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
|
||||
const polishName = content.name;
|
||||
const slug = content.flags?.['hbm-rpg-v3']?.slug ?? file.replace('.json', '');
|
||||
|
||||
let englishName = '';
|
||||
|
||||
if (file.startsWith('_folder-')) {
|
||||
englishName = folderNameTranslations[polishName] ?? polishName;
|
||||
} else if (racesList[slug]) {
|
||||
englishName = racesList[slug];
|
||||
} else if (talentsList[slug]) {
|
||||
englishName = talentsList[slug];
|
||||
} else if (nameTranslations[slug]) {
|
||||
englishName = nameTranslations[slug];
|
||||
} else if (nameTranslations[slug.toLowerCase().replace(/[^a-z0-9]/g, '')]) {
|
||||
englishName = nameTranslations[slug.toLowerCase().replace(/[^a-z0-9]/g, '')];
|
||||
} else {
|
||||
englishName = titleCase(slug);
|
||||
}
|
||||
|
||||
const description = content.system?.description ?? '';
|
||||
const englishDesc = translateDescription(description);
|
||||
|
||||
translations[polishName] = {
|
||||
name: englishName,
|
||||
};
|
||||
|
||||
if (englishDesc) {
|
||||
translations[polishName].description = englishDesc;
|
||||
}
|
||||
}
|
||||
|
||||
const packLabels: Record<string, string> = {
|
||||
"spells-general": "Spells - General",
|
||||
"spells-sacred": "Spells - Sacred",
|
||||
"spells-academic": "Academic Disciplines",
|
||||
"spells-blood": "Blood Magic",
|
||||
"spells-crimson": "Crimson Cult Magic",
|
||||
"spells-eldritch": "Spells - Eldritch",
|
||||
"talents": "Talents",
|
||||
"talents-blood": "Talents - Blood Magic",
|
||||
"talents-eldritch": "Talents - Curse of the Eldritch",
|
||||
"talents-npc": "Talents (NPC)",
|
||||
"races": "Races",
|
||||
"disciplines": "Magic Disciplines",
|
||||
"disciplines-forbidden": "Forbidden Disciplines",
|
||||
"hbm-macros": "HbM Macros",
|
||||
"items-weapons": "Weapons",
|
||||
"items-armor": "Armor",
|
||||
"items-gear": "Equipment (Other)"
|
||||
};
|
||||
|
||||
const output = {
|
||||
label: packLabels[pack] ?? titleCase(pack),
|
||||
entries: translations
|
||||
};
|
||||
|
||||
const outPath = join(outDir, `${pack}.json`);
|
||||
writeFileSync(outPath, JSON.stringify(output, null, 2) + '\n', 'utf8');
|
||||
console.log(`Generated Babele translation for ${pack} -> ${outPath}`);
|
||||
}
|
||||
|
||||
console.log('Babele translation generation complete!');
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* UUID reference linter — scans parsed docs (packs-src/*) for any
|
||||
* `@UUID[...]` references that don't resolve within the generated set.
|
||||
*
|
||||
* Run after build-packs.ts: `bun scripts/lint-uuid-refs.ts`
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const systemRoot = resolve(__dirname, '..');
|
||||
const packsSrcDir = resolve(systemRoot, 'packs-src');
|
||||
|
||||
const allIds = new Set<string>();
|
||||
const refs: Array<{ from: string; uuid: string }> = [];
|
||||
|
||||
function walk(dir: string): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = resolve(dir, entry);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!full.endsWith('.json')) continue;
|
||||
const json = JSON.parse(readFileSync(full, 'utf8')) as { _id?: string; system?: unknown };
|
||||
if (json._id) allIds.add(json._id);
|
||||
|
||||
const text = readFileSync(full, 'utf8');
|
||||
const matches = text.matchAll(/@UUID\[([^\]]+)\]/g);
|
||||
for (const m of matches) refs.push({ from: full, uuid: m[1] });
|
||||
}
|
||||
}
|
||||
|
||||
walk(packsSrcDir);
|
||||
|
||||
const broken = refs.filter((r) => {
|
||||
// UUID format: Compendium.system.pack.Item.<id> — only validate Item refs.
|
||||
const m = r.uuid.match(/^Compendium\.[^.]+\.[^.]+\.Item\.(.+)$/);
|
||||
if (!m) return false;
|
||||
return !allIds.has(m[1]);
|
||||
});
|
||||
|
||||
if (broken.length === 0) {
|
||||
console.log(`[lint-uuid-refs] OK — ${refs.length} refs scanned, none broken`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`[lint-uuid-refs] ${broken.length} broken refs:`);
|
||||
for (const b of broken) {
|
||||
console.error(` · ${b.from}\n ${b.uuid}`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,16 @@
|
||||
// HbM Apply Condition — pick a status effect and toggle it on targeted/selected tokens.
|
||||
const targets = game.user.targets.size > 0 ? Array.from(game.user.targets) : canvas.tokens.controlled;
|
||||
if (targets.length === 0) return ui.notifications.warn('Zaznacz lub naceluj token(y).');
|
||||
const effects = CONFIG.statusEffects.filter((e) => e.id?.startsWith('hbm.'));
|
||||
const opts = effects.map((e) => `<option value="${e.id}">${game.i18n.localize(e.name ?? e.label ?? e.id)}</option>`).join('');
|
||||
const result = await Dialog.prompt({
|
||||
title: 'Nałóż przypadłość',
|
||||
content: `<form><div class="form-group"><label>Przypadłość:</label><select name="effect">${opts}</select></div></form>`,
|
||||
label: 'Nałóż',
|
||||
callback: (html) => String(new foundry.applications.ux.FormDataExtended(html.querySelector('form')).object.effect),
|
||||
});
|
||||
if (!result) return;
|
||||
for (const t of targets) {
|
||||
if (t.actor) await t.actor.toggleStatusEffect(result, { active: true });
|
||||
}
|
||||
ui.notifications.info(`Nałożono ${result} na ${targets.length} cel(e).`);
|
||||
@@ -0,0 +1,30 @@
|
||||
// HbM Attribute Check — pick attribute on selected token's actor, roll d6 pool.
|
||||
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
|
||||
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
|
||||
const attrs = ['body', 'mind', 'soul', 'magic'];
|
||||
const labels = { body: 'Ciało', mind: 'Umysł', soul: 'Dusza', magic: 'Magia' };
|
||||
const opts = attrs.map((a) => `<option value="${a}">${labels[a]} (${actor.system.attributes[a]?.value ?? 0})</option>`).join('');
|
||||
const result = await Dialog.prompt({
|
||||
title: `Test atrybutu — ${actor.name}`,
|
||||
content: `
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label>Atrybut:</label>
|
||||
<select name="attr">${opts}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Próg sukcesu (TS):</label>
|
||||
<input type="number" name="threshold" value="4" min="2" max="6"/>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
label: 'Rzuć',
|
||||
callback: (html) => {
|
||||
const fd = new foundry.applications.ux.FormDataExtended(html.querySelector('form')).object;
|
||||
return { attr: String(fd.attr), threshold: Number(fd.threshold) };
|
||||
},
|
||||
});
|
||||
if (!result) return;
|
||||
const pool = actor.system.attributes[result.attr]?.value ?? 1;
|
||||
const roll = await new Roll(`${pool}d6cs>=${result.threshold}`).evaluate();
|
||||
await roll.toMessage({ flavor: `${actor.name} — test ${labels[result.attr]} (TS ${result.threshold})`, speaker: ChatMessage.getSpeaker({ actor }) });
|
||||
@@ -0,0 +1,24 @@
|
||||
// HbM Group Cast Helper — sum mana of controlled token actors (potential co-casters).
|
||||
const tokens = canvas.tokens.controlled;
|
||||
if (tokens.length < 2) return ui.notifications.warn('Zaznacz co najmniej 2 tokeny współrzucających.');
|
||||
const lines = [];
|
||||
let totalMana = 0;
|
||||
let totalPool = 0;
|
||||
for (const t of tokens) {
|
||||
const a = t.actor;
|
||||
if (!a) continue;
|
||||
const mana = a.system.attributes?.mana?.value ?? 0;
|
||||
const magic = a.system.attributes?.magic?.value ?? 0;
|
||||
totalMana += mana;
|
||||
totalPool += magic;
|
||||
lines.push(`<li><strong>${a.name}</strong> — Mana ${mana}, Magia ${magic}</li>`);
|
||||
}
|
||||
const html = `
|
||||
<div class="hbm-group-cast">
|
||||
<p>Współrzucający (${tokens.length}):</p>
|
||||
<ul>${lines.join('')}</ul>
|
||||
<p><strong>Łączna mana: ${totalMana}</strong></p>
|
||||
<p><strong>Łączna pula d6: ${totalPool}</strong></p>
|
||||
</div>
|
||||
`;
|
||||
await ChatMessage.create({ content: html, speaker: ChatMessage.getSpeaker() });
|
||||
@@ -0,0 +1,6 @@
|
||||
// HbM Long Rest — invoke game.hbm.rest(actor, 'long') on selected token's actor.
|
||||
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
|
||||
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
|
||||
if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest — system niezainicjowany.');
|
||||
const result = await game.hbm.rest(actor, 'long');
|
||||
ui.notifications.info(`${actor.name}: długi odpoczynek — pełna regeneracja zasobów.`);
|
||||
@@ -0,0 +1,25 @@
|
||||
// HbM Pool Roll — prompts for pool size and threshold, then rolls a d6 pool.
|
||||
const result = await Dialog.prompt({
|
||||
title: 'Rzut puli d6',
|
||||
content: `
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label>Pula (liczba kości):</label>
|
||||
<input type="number" name="pool" value="3" min="1" max="20"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Próg sukcesu (TS):</label>
|
||||
<input type="number" name="threshold" value="4" min="2" max="6"/>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
label: 'Rzuć',
|
||||
callback: (html) => {
|
||||
const fd = new foundry.applications.ux.FormDataExtended(html.querySelector('form')).object;
|
||||
return { pool: Number(fd.pool), threshold: Number(fd.threshold) };
|
||||
},
|
||||
});
|
||||
if (!result) return;
|
||||
const formula = `${result.pool}d6cs>=${result.threshold}`;
|
||||
const roll = await new Roll(formula).evaluate();
|
||||
await roll.toMessage({ flavor: `Pula d6 (TS ${result.threshold})` });
|
||||
@@ -0,0 +1,8 @@
|
||||
// HbM Refill Zeal — bumps zeal by 1 on selected token's actor (debug helper).
|
||||
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
|
||||
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
|
||||
const cur = actor.system.attributes?.zeal?.value ?? 0;
|
||||
const max = actor.system.attributes?.zeal?.max ?? 0;
|
||||
const next = Math.min(max, cur + 1);
|
||||
await actor.update({ 'system.attributes.zeal.value': next });
|
||||
ui.notifications.info(`${actor.name}: Zapał ${cur} → ${next}/${max}.`);
|
||||
@@ -0,0 +1,6 @@
|
||||
// HbM Short Rest — invoke game.hbm.rest(actor, 'short') on selected token's actor.
|
||||
const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
|
||||
if (!actor) return ui.notifications.warn('Wybierz token postaci.');
|
||||
if (!game.hbm?.rest) return ui.notifications.error('Brak game.hbm.rest — system niezainicjowany.');
|
||||
const result = await game.hbm.rest(actor, 'short');
|
||||
ui.notifications.info(`${actor.name}: krótki odpoczynek — przywrócono ${result?.healed ?? 0} PW.`);
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Package the built dist/ into a versioned zip ready to drop into a
|
||||
* Foundry VTT instance's Data/systems/ directory (or to upload as a release).
|
||||
*/
|
||||
|
||||
import { createWriteStream, readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import archiver from 'archiver';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
const distDir = resolve(root, 'dist');
|
||||
const systemJson = JSON.parse(readFileSync(resolve(distDir, 'system.json'), 'utf8')) as { id: string; version: string };
|
||||
const outFile = resolve(root, `${systemJson.id}-v${systemJson.version}.zip`);
|
||||
|
||||
await new Promise<void>((resolveAll, rejectAll) => {
|
||||
const output = createWriteStream(outFile);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
output.on('close', () => {
|
||||
console.log(`✓ Packaged ${outFile} (${archive.pointer()} bytes)`);
|
||||
resolveAll();
|
||||
});
|
||||
archive.on('warning', (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') console.warn(err);
|
||||
else rejectAll(err);
|
||||
});
|
||||
archive.on('error', rejectAll);
|
||||
|
||||
archive.pipe(output);
|
||||
// Place dist/* inside a folder named after the system id (Foundry expects this)
|
||||
archive.directory(distDir, systemJson.id, (entry) => {
|
||||
if (entry.name.endsWith('LOCK')) return false;
|
||||
return entry;
|
||||
});
|
||||
archive.finalize();
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"_comment": "Override auto-generated slugs to use English IDs internally for database entries and filenames.",
|
||||
"aniol": "angel",
|
||||
"czlowiek": "human",
|
||||
"magiczny-pocisk": "magic-missile",
|
||||
"magiczna-tarcza": "magic-shield",
|
||||
"poblask": "gleam",
|
||||
"telekineza": "telekinesis",
|
||||
"przeskok": "blink",
|
||||
"teleportacja": "teleport",
|
||||
"przebicie-eteru": "ether-pierce",
|
||||
"fala-energii": "energy-wave",
|
||||
"przeblysk-prawdy": "truth-glimpse",
|
||||
"szybka-mysl": "quick-thought",
|
||||
"latanie": "flight",
|
||||
"powietrzna-fala": "air-wave",
|
||||
"wyczucie-zagrozenia": "danger-sense",
|
||||
"celnosc": "accuracy",
|
||||
"szybki-jak-wiatr": "wind-swift",
|
||||
"tworzenie-i-kontrolowanie-wody": "water-create",
|
||||
"lodowa-podloga": "ice-floor",
|
||||
"ukojenie": "solace",
|
||||
"gradobicie": "hailstorm",
|
||||
"mistyczna-mgla": "mystic-fog",
|
||||
"podpalenie": "ignition",
|
||||
"wzniecanie-ognia": "kindle",
|
||||
"ognista-powloka": "fire-cloak",
|
||||
"kula-ognia": "fireball",
|
||||
"gorejaca-skora": "burning-skin",
|
||||
"skalista-tarcza": "stone-shield",
|
||||
"ruchome-piaski": "quicksand",
|
||||
"trzesienie-ziemi": "earthquake",
|
||||
"zwirowa-zbroja": "gravel-armor",
|
||||
"skalista-pulapka": "stone-trap",
|
||||
"eteryczny-przewodnik": "ether-guide",
|
||||
"labirynt-umyslu": "mind-labyrinth",
|
||||
"madrosc-salomona": "wisdom-of-solomon",
|
||||
"magiczna-odpornosc": "magic-resistance",
|
||||
"magiczne-kajdany": "magic-shackles",
|
||||
"magnetyzm": "magnetism",
|
||||
"negacja": "negation",
|
||||
"niewidzialnosc": "invisibility",
|
||||
"ozywienie-szkieletu": "animate-skeleton",
|
||||
"przemiana-w-olow": "lead-transmutation",
|
||||
"rozmawianie-ze-zmarlymi": "speak-with-dead",
|
||||
"rozsypanie": "shatter",
|
||||
"uzdrowienie-chorych": "heal-sick",
|
||||
"woal-snow": "dream-veil",
|
||||
"wymagania-rasa-czlowiek": "requirements-race-human",
|
||||
"zloto-glupcow": "fools-gold",
|
||||
"zwierciadlany-wizerunek": "mirror-image",
|
||||
"blogoslawienstwo-zasiewow": "sowing-blessing",
|
||||
"blyskawice-lancuchowe": "chain-lightning",
|
||||
"boska-celnosc": "divine-accuracy",
|
||||
"boska-sprawiedliwosc": "divine-justice",
|
||||
"boska-wlocznia": "divine-spear",
|
||||
"chmara-insektow": "insect-swarm",
|
||||
"cykl-por-roku": "seasons-cycle",
|
||||
"egida": "aegis",
|
||||
"gniew-matki-ziemi": "wrath-of-mother-earth",
|
||||
"grom-z-nieba": "thunderbolt",
|
||||
"klatwa-hekate": "curse-of-hekate",
|
||||
"kradziez-many": "mana-drain",
|
||||
"leczacy-dotyk": "healing-touch",
|
||||
"magiczne-przyspieszenie": "magic-haste",
|
||||
"moc-ziemi": "earth-power",
|
||||
"modlitwa-o-opieke": "prayer-for-protection",
|
||||
"obecnosc-krola-bogow": "presence-of-the-king-of-gods",
|
||||
"piekno-bogini": "beauty-of-the-goddess",
|
||||
"plomien-namietnosci": "flame-of-passion",
|
||||
"pomost": "bridge",
|
||||
"pozadanie-zmyslow": "senses-desire",
|
||||
"promien-harmonii": "ray-of-harmony",
|
||||
"przebudzenie": "awakening",
|
||||
"przyspieszone-zniwa": "accelerated-harvest",
|
||||
"sad-ostateczny": "last-judgment",
|
||||
"strzala-kupidyna": "cupids-arrow",
|
||||
"strzala-pozadania": "arrow-of-desire",
|
||||
"swieta-stal": "holy-steel",
|
||||
"tajemne-poznanie": "arcane-cognition",
|
||||
"tesknota-serca": "heart-longing",
|
||||
"urok-niewinnosci": "charm-of-innocence",
|
||||
"uswiecenie": "sanctification",
|
||||
"wiez-serc": "bond-of-hearts",
|
||||
"wyladowanie-elektrostatyczne": "electrostatic-discharge",
|
||||
"wymagania-rasa-aniol": "requirements-race-angel",
|
||||
"wzmocnienie-magii": "magic-empowerment",
|
||||
"neutralizacja": "neutralization",
|
||||
"pierwotna-kula": "primal-orb",
|
||||
"tajemniczy-strumien": "mysterious-stream",
|
||||
"zakazana-oslona": "forbidden-shield",
|
||||
"klatwa-szkarlatu": "crimson-curse",
|
||||
"szkarlatna-sprawiedliwosc": "crimson-justice",
|
||||
"szkarlatne-plomienie": "crimson-flames",
|
||||
"szkarlatny-pocisk": "crimson-bolt",
|
||||
"szkarlatny-sztylet": "crimson-dagger",
|
||||
"superzaklecie-requiem": "superspell-requiem",
|
||||
"arcymag-dziedzina": "archmage",
|
||||
"blogoslawienstwo-bostwo": "deity-blessing",
|
||||
"blogoslawione-pochodzenie": "blessed-origin",
|
||||
"cios-w-plecy": "backstab",
|
||||
"czuly-zmysl": "keen-sense",
|
||||
"czuly-zmysl-zmysl": "keen-sense",
|
||||
"darmowy-talent": "free-talent",
|
||||
"dyplomata": "diplomat",
|
||||
"finta": "feint",
|
||||
"gadanina": "babble",
|
||||
"gotowosc-do-walki": "battle-readiness",
|
||||
"jednosc-z-magia": "unity-with-magic",
|
||||
"jednosc-z-natura": "unity-with-nature",
|
||||
"magiczna-dyscyplina": "magic-discipline",
|
||||
"magiczny-zmysl-zmysl": "magical-sense",
|
||||
"mistrz-magii-dziedzina": "master-of-magic",
|
||||
"niezniszczalne-zaklecia": "indestructible-spells",
|
||||
"obureczny": "ambidextrous",
|
||||
"odpornosc-na-magie": "magic-resistance",
|
||||
"odpornosc-na-trucizny": "poison-resistance",
|
||||
"oportunista": "opportunist",
|
||||
"pancerz-wiary": "armor-of-faith",
|
||||
"parowanie": "parry",
|
||||
"powietrzne-uniki": "air-evasion",
|
||||
"riposta": "riposte",
|
||||
"rozplatanie-zaklec": "spell-shattering",
|
||||
"sen-na-jawie": "daydream",
|
||||
"technologia-jest-wsrod-nas": "technology-is-among-us",
|
||||
"urok-osobisty": "personal-charm",
|
||||
"zakazana-wiedza-dziedzina": "forbidden-knowledge",
|
||||
"zawisanie-w-powietrzu": "levitation",
|
||||
"medyk-polowy": "field-medic",
|
||||
"olimpijczyk": "olympian",
|
||||
"czlowiek-z-zelaza": "man-of-iron",
|
||||
"pasjonat-zwierzat": "animal-enthusiast",
|
||||
"jestes-magiem-krwi-i-rozumiesz-jak-cenne-jest-zycie": "blood-mage-precious-life",
|
||||
"oltarz-krwi": "blood-altar",
|
||||
"szacunek-do-zycia": "respect-for-life",
|
||||
"dar-otchlani": "gift-of-the-abyss",
|
||||
"magowie-aspektow-moga-sie-ukrywac-jako-magowie-swojej-pierwotnej-dziedziny": "aspect-mages-disguise",
|
||||
"metamagia": "metamagic",
|
||||
"swobodny-przeplyw-magii": "free-flow-of-magic",
|
||||
"gear-bicz": "whip",
|
||||
"gear-bron-dwureczna": "two-handed-weapon",
|
||||
"gear-bron-jednoreczna-lub-poltorareczna": "one-handed-or-bastard-weapon",
|
||||
"gear-laska-quarterstaff": "quarterstaff",
|
||||
"gear-luk-bloczkowy": "compound-bow",
|
||||
"gear-luk-elfi": "elven-bow",
|
||||
"gear-luk-klasyczny": "classic-bow",
|
||||
"gear-sztylet": "dagger",
|
||||
"gear-wlocznia": "spear",
|
||||
"gear-pancerz-ciezki": "heavy-armor",
|
||||
"gear-pancerz-lekki": "light-armor",
|
||||
"gear-pancerz-sredni": "medium-armor",
|
||||
"gear-tarcza": "shield",
|
||||
"gear-artefakt-przechowujacy-mane": "mana-storage-artifact",
|
||||
"gear-artefakt-skupiajacy-magie": "magic-focus-artifact",
|
||||
"gear-karty-do-gry": "playing-cards",
|
||||
"gear-kociolek": "cauldron",
|
||||
"gear-miecz-swietlny": "lightsaber",
|
||||
"gear-miecz-treningowy": "training-sword",
|
||||
"gear-przedmioty-do-wrozenia": "divination-tools",
|
||||
"gear-przybory-do-golemancji": "golemancy-tools",
|
||||
"gear-przybory-do-tworzenia-artefatow": "artifact-crafting-tools",
|
||||
"gear-shinai": "shinai",
|
||||
"gear-szklana-fiolka": "glass-vial",
|
||||
"gear-tablica-ouija": "ouija-board",
|
||||
"gear-teleskop": "telescope",
|
||||
"gear-zestaw-do-rytualow": "ritual-kit",
|
||||
"ostrze-dawida": "blade-of-david",
|
||||
"fanatyczne-poswiecenie": "fanatical-sacrifice",
|
||||
"wykrycie-zmian": "detect-changes",
|
||||
"destrukcja": "destruction",
|
||||
"prawdziwa-dezintegracja": "true-disintegration",
|
||||
"zaglada": "doom",
|
||||
"magiczna-plaga": "magic-plague",
|
||||
"aegis": "aegis",
|
||||
"transmutacja-krajobrazu": "landscape-transmutation",
|
||||
"bramy-wymiarow": "dimension-gates",
|
||||
"reanimacja": "reanimation",
|
||||
"plomienna-strzala": "flaming-arrow",
|
||||
"tarcza-eteryczna": "ethereal-shield",
|
||||
"echo-zmierzchu": "twilight-echo",
|
||||
"requiem": "requiem",
|
||||
"przywolanie-istoty-z-otchlani": "summon-eldritch-entity",
|
||||
"nic-przeznaczenia": "thread-of-destiny"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_comment": "Polish (book) label → English internal id used in constants.ts. Lowercased for case-insensitive lookup.",
|
||||
"schools": {
|
||||
"magia ogólna": "general",
|
||||
"magia żywiołów": "elements",
|
||||
"magia żywiołów — powietrze": "elementsAir",
|
||||
"magia żywiołów (powietrze)": "elementsAir",
|
||||
"magia powietrza": "elementsAir",
|
||||
"magia żywiołów — woda": "elementsWater",
|
||||
"magia żywiołów (woda)": "elementsWater",
|
||||
"magia wody": "elementsWater",
|
||||
"magia żywiołów — ogień": "elementsFire",
|
||||
"magia żywiołów (ogień)": "elementsFire",
|
||||
"magia ognia": "elementsFire",
|
||||
"magia żywiołów — ziemia": "elementsEarth",
|
||||
"magia żywiołów (ziemia)": "elementsEarth",
|
||||
"magia ziemi": "elementsEarth",
|
||||
"magia sakralna": "sacred",
|
||||
"magia sakralna — egzorcyzmy": "sacredExorcism",
|
||||
"magia sakralna - egzorcyzmy": "sacredExorcism",
|
||||
"egzorcyzmy": "sacredExorcism",
|
||||
"wiedźmia magia": "witch",
|
||||
"magia iluzji": "illusion",
|
||||
"alchemia transmutacji": "alchemyTransmutation",
|
||||
"magia alchemii transmutacji": "alchemyTransmutation",
|
||||
"alchemia - transmutacja": "alchemyTransmutation",
|
||||
"warzenie eliksirów": "alchemyBrewing",
|
||||
"alchemia - warzenie eliksirów": "alchemyBrewing",
|
||||
"rzemiosło artefaktów": "artifice",
|
||||
"golemancja": "golemancy",
|
||||
"magia runiczna": "runes",
|
||||
"źródła mocy": "manaSourceMage",
|
||||
"źródło mocy": "manaSourceMage",
|
||||
"botanika": "botany",
|
||||
"nekromancja": "necromancy",
|
||||
"magia krwi": "blood",
|
||||
"magia szkarłatu": "crimson",
|
||||
"magia otchłani — magia aspektów": "eldritchAspects",
|
||||
"magia otchłani — pierwotna magia": "eldritchPrimal",
|
||||
"dzika wiedźmia magia": "wildWitch"
|
||||
},
|
||||
"disciplines": {
|
||||
"alchemia transmutacji": "alchemyTransmutation",
|
||||
"magia alchemii transmutacji": "alchemyTransmutation",
|
||||
"alchemia - transmutacja": "alchemyTransmutation",
|
||||
"warzenie eliksirów": "alchemyBrewing",
|
||||
"alchemia - warzenie eliksirów": "alchemyBrewing",
|
||||
"botanika": "botany",
|
||||
"magia żywiołów — powietrze": "elementsAir",
|
||||
"magia żywiołów (powietrze)": "elementsAir",
|
||||
"magia żywiołów — woda": "elementsWater",
|
||||
"magia żywiołów (woda)": "elementsWater",
|
||||
"magia żywiołów — ogień": "elementsFire",
|
||||
"magia żywiołów (ogień)": "elementsFire",
|
||||
"magia żywiołów — ziemia": "elementsEarth",
|
||||
"magia żywiołów (ziemia)": "elementsEarth",
|
||||
"rzemiosło artefaktów": "artifice",
|
||||
"golemancja": "golemancy",
|
||||
"magia runiczna": "runes",
|
||||
"źródła mocy": "manaSourceMage",
|
||||
"źródło mocy": "manaSourceMage",
|
||||
"magia iluzji": "illusion",
|
||||
"magia sakralna": "sacred",
|
||||
"magia sakralna — egzorcyzmy": "sacredExorcism",
|
||||
"magia sakralna - egzorcyzmy": "sacredExorcism",
|
||||
"egzorcyzmy": "sacredExorcism",
|
||||
"wiedźmia magia": "witch",
|
||||
"nekromancja": "necromancy",
|
||||
"magia krwi": "blood",
|
||||
"magia szkarłatu": "crimson",
|
||||
"magia otchłani": "eldritch",
|
||||
"dzika wiedźmia magia": "wildWitch"
|
||||
},
|
||||
"deities": {
|
||||
"modlitwy powszechne": "common",
|
||||
"bóg-jahwe-jedyny": "jahwe",
|
||||
"jahwe": "jahwe",
|
||||
"zeus-jowisz": "zeus",
|
||||
"zeus": "zeus",
|
||||
"demeter-ceres": "demeter",
|
||||
"demeter": "demeter",
|
||||
"artemida-diana": "artemis",
|
||||
"artemida": "artemis",
|
||||
"hekate": "hekate",
|
||||
"afrodyta-wenus": "aphrodite",
|
||||
"afrodyta": "aphrodite",
|
||||
"amor-eros": "eros",
|
||||
"eros": "eros"
|
||||
},
|
||||
"symbols": {
|
||||
"potentia": "Potentia",
|
||||
"tutamen": "Tutamen",
|
||||
"lux": "Lux",
|
||||
"motus": "Motus",
|
||||
"iter": "Iter",
|
||||
"vacuos": "Vacuos",
|
||||
"vitium": "Vitium",
|
||||
"praecantatio": "Praecantatio",
|
||||
"aer": "Aer",
|
||||
"aqua": "Aqua",
|
||||
"gelum": "Gelum",
|
||||
"ignis": "Ignis",
|
||||
"terra": "Terra",
|
||||
"cognitio": "Cognitio",
|
||||
"alienis": "Alienis",
|
||||
"illusio": "Illusio",
|
||||
"somnium": "Somnium",
|
||||
"tenebrae": "Tenebrae",
|
||||
"auram": "Auram",
|
||||
"vinculum": "Vinculum",
|
||||
"telum": "Telum",
|
||||
"sensus": "Sensus",
|
||||
"perditio": "Perditio",
|
||||
"perfodio": "Perfodio",
|
||||
"sano": "Sano",
|
||||
"volatus": "Volatus",
|
||||
"tempestas": "Tempestas"
|
||||
},
|
||||
"attributes": {
|
||||
"ciało": "body",
|
||||
"umysł": "mind",
|
||||
"dusza": "soul",
|
||||
"magia": "magic"
|
||||
},
|
||||
"skills": {
|
||||
"zdolności magiczne": "magicalAbilities",
|
||||
"oddanie": "devotion"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Lightweight markdown walker tuned to the HbM book layout.
|
||||
*
|
||||
* Supports two formats:
|
||||
*
|
||||
* 1. Legacy Google-Docs export (plain text, `_books/`):
|
||||
* ## Chapter heading
|
||||
* Section heading (plain text / bold)
|
||||
* Item Name
|
||||
* * Field: value
|
||||
*
|
||||
* 2. Native Obsidian/repo files (`rules/`, `disciplines/`, etc.):
|
||||
* --- ← YAML frontmatter (skipped)
|
||||
* ...
|
||||
* ---
|
||||
* ## Rozdział I - ... ← chapter (any heading level)
|
||||
* ### Sub-section ← section / sub-section
|
||||
* #### Spell Name ← item name
|
||||
* - [[#Anchor|Label]] ← Obsidian TOC bullet (skipped)
|
||||
* * Field: value
|
||||
*
|
||||
* The walker emits one `BookBlock` per item it can find. Section context
|
||||
* is carried through so parsers know e.g. which discipline a spell lives
|
||||
* under.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export interface BookBlock {
|
||||
/** The line directly preceding the bullet block — usually the item name. */
|
||||
name: string;
|
||||
/** Bullet keys/values, preserved in order. */
|
||||
fields: Array<{ key: string; value: string }>;
|
||||
/** Free-form paragraph text following the bullet block (description). */
|
||||
description: string;
|
||||
/** Most recent chapter (any `#+ ...`) heading. */
|
||||
chapter: string;
|
||||
/** Most recent non-bullet, non-empty line above the item name (sub-section). */
|
||||
section: string;
|
||||
/** 1-based line number of the item name in the source file. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
const SEPARATOR = /^_{3,}$/;
|
||||
const BULLET = /^\*\s+([^:]+):\s*(.+)$/;
|
||||
// Match any markdown heading level (1-6 `#` chars).
|
||||
const HEADING = /^#{1,6}\s+(.+)$/;
|
||||
// Chapter headings are the two highest heading levels we encounter — heuristic:
|
||||
// treat ## headings as "chapter" and ### headings as "section".
|
||||
const CHAPTER_HEADING = /^#{1,2}\s+(.+)$/;
|
||||
const SECTION_HEADING = /^#{3,4}\s+(.+)$/;
|
||||
// Obsidian TOC bullets: `- [[#Anchor|Label]]` or `* [[#Anchor|Label]]`
|
||||
const OBSIDIAN_TOC_BULLET = /^[-*]\s+\[\[#/;
|
||||
// Frontmatter fence
|
||||
const FRONTMATTER_FENCE = /^---\s*$/;
|
||||
|
||||
/** Strip leading markdown heading markers and bold `**` from a raw line. */
|
||||
function stripHeadingMarkers(line: string): string {
|
||||
return line.replace(/^#{1,6}\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').trim();
|
||||
}
|
||||
|
||||
/** Skip YAML frontmatter block at start of file. Returns the index of the first non-frontmatter line. */
|
||||
function skipFrontmatter(lines: string[]): number {
|
||||
if (lines.length === 0 || !FRONTMATTER_FENCE.test(lines[0].trim())) return 0;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (FRONTMATTER_FENCE.test(lines[i].trim())) return i + 1;
|
||||
}
|
||||
return 0; // malformed — start from 0
|
||||
}
|
||||
|
||||
export interface WalkOptions {
|
||||
/**
|
||||
* Predicate that returns `true` when the line (after stripping heading markers)
|
||||
* is a known section header (e.g. matches a discipline / school / deity label).
|
||||
* Used by the walker to track the current section across separators and prose.
|
||||
*/
|
||||
isSectionHeader?: (line: string) => boolean;
|
||||
}
|
||||
|
||||
export function walkBook(path: string, opts: WalkOptions = {}): BookBlock[] {
|
||||
const text = readFileSync(path, 'utf8');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const blocks: BookBlock[] = [];
|
||||
|
||||
let chapter = '';
|
||||
let section = '';
|
||||
const isSectionHeader = opts.isSectionHeader ?? (() => false);
|
||||
|
||||
const startIdx = skipFrontmatter(lines);
|
||||
|
||||
// Look ahead for bullet-block starts; track the line above as the name.
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const rawLine = lines[i].trimEnd();
|
||||
|
||||
// Skip Obsidian TOC bullets — they look like `- [[#Section|Label]]`.
|
||||
if (OBSIDIAN_TOC_BULLET.test(rawLine.trim())) continue;
|
||||
|
||||
// Chapter headings: ## or # level (the two highest we honour).
|
||||
const chMatch = rawLine.match(CHAPTER_HEADING);
|
||||
if (chMatch) {
|
||||
chapter = stripHeadingMarkers(rawLine);
|
||||
// Reset section only for top-level (##) headings.
|
||||
if (rawLine.startsWith('## ') || rawLine.startsWith('# ')) section = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section sub-headings: ### or #### level become section markers.
|
||||
const secMatch = rawLine.match(SECTION_HEADING);
|
||||
if (secMatch) {
|
||||
const bare = stripHeadingMarkers(rawLine);
|
||||
const isL3 = rawLine.startsWith('### ');
|
||||
if (isL3 || isSectionHeader(bare)) {
|
||||
section = bare;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Track plain-text section headers (legacy format).
|
||||
const trimmed = rawLine.trim();
|
||||
if (trimmed && !SEPARATOR.test(trimmed) && !BULLET.test(trimmed) && isSectionHeader(trimmed)) {
|
||||
section = trimmed;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BULLET.test(rawLine)) {
|
||||
// Walk up to find the name line (first non-empty, non-separator, non-heading line above).
|
||||
let nameLine = '';
|
||||
let nameIdx = i - 1;
|
||||
while (nameIdx >= startIdx) {
|
||||
const candidate = lines[nameIdx].trim();
|
||||
// Skip empty lines, separators, TOC links, list items, and requirements lines
|
||||
if (
|
||||
candidate &&
|
||||
!SEPARATOR.test(candidate) &&
|
||||
!OBSIDIAN_TOC_BULLET.test(candidate) &&
|
||||
!/^[*-]\s+/.test(candidate) &&
|
||||
!/^(?:[*-]\s+)?Wymagania?:/i.test(candidate) &&
|
||||
!/^>/.test(candidate)
|
||||
) {
|
||||
// Strip any heading markers; skip pure chapter/section headings as the name
|
||||
// (they are already captured in `chapter`/`section`).
|
||||
nameLine = stripHeadingMarkers(candidate);
|
||||
break;
|
||||
}
|
||||
nameIdx--;
|
||||
}
|
||||
|
||||
// Collect the bullet block.
|
||||
const fields: Array<{ key: string; value: string }> = [];
|
||||
let j = i;
|
||||
while (j < lines.length) {
|
||||
const m = lines[j].trimEnd().match(BULLET);
|
||||
if (!m) break;
|
||||
fields.push({ key: m[1].trim(), value: m[2].trim() });
|
||||
j++;
|
||||
}
|
||||
|
||||
// Description = following lines until next bullet block, separator, or heading.
|
||||
const descLines: string[] = [];
|
||||
let k = j;
|
||||
while (k < lines.length) {
|
||||
const cur = lines[k].trimEnd();
|
||||
if (SEPARATOR.test(cur)) break;
|
||||
if (HEADING.test(cur)) break;
|
||||
if (BULLET.test(cur)) break;
|
||||
if (OBSIDIAN_TOC_BULLET.test(cur.trim())) break;
|
||||
descLines.push(cur);
|
||||
k++;
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
name: nameLine,
|
||||
fields,
|
||||
description: descLines.join('\n').trim(),
|
||||
chapter,
|
||||
section,
|
||||
line: nameIdx + 1,
|
||||
});
|
||||
|
||||
i = k - 1; // advance past description
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Discipline parser. Disciplines are identified by the entries in
|
||||
* `_label-mappings.json#disciplines`; we emit one stub Item per discipline
|
||||
* with name, source book, and an empty description (filled in later).
|
||||
*
|
||||
* Many disciplines also have a "passive ability" mentioned right after the
|
||||
* discipline header in Podręcznik Gry — we capture the next non-empty line
|
||||
* if it looks like a short ability description.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
|
||||
import { normalizeKey, slugify } from './helpers';
|
||||
|
||||
interface DisciplineSeed {
|
||||
id: string;
|
||||
polishName: string;
|
||||
pack: string;
|
||||
book: SourceBookId;
|
||||
}
|
||||
|
||||
const DISCIPLINE_SEEDS: DisciplineSeed[] = [
|
||||
{ id: 'alchemyTransmutation', polishName: 'Alchemia - Transmutacja', pack: 'disciplines', book: 'podrecznik-gry' },
|
||||
{ id: 'alchemyBrewing', polishName: 'Alchemia - Warzenie Eliksirów', pack: 'disciplines', book: 'podrecznik-gry' },
|
||||
{ id: 'botany', polishName: 'Botanika', pack: 'disciplines', book: 'podrecznik-gry' },
|
||||
{ id: 'elementsAir', polishName: 'Magia Żywiołów (Powietrze)', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'elementsWater', polishName: 'Magia Żywiołów (Woda)', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'elementsFire', polishName: 'Magia Żywiołów (Ogień)', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'elementsEarth', polishName: 'Magia Żywiołów (Ziemia)', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'artifice', polishName: 'Rzemiosło Artefaktów', pack: 'disciplines', book: 'podrecznik-gry' },
|
||||
{ id: 'golemancy', polishName: 'Golemancja', pack: 'disciplines', book: 'podrecznik-gry' },
|
||||
{ id: 'runes', polishName: 'Magia Runiczna', pack: 'disciplines', book: 'podrecznik-gry' },
|
||||
{ id: 'manaSourceMage', polishName: 'Źródło Mocy', pack: 'disciplines', book: 'podrecznik-gry' },
|
||||
{ id: 'illusion', polishName: 'Magia Iluzji', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'sacred', polishName: 'Magia Sakralna', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'sacredExorcism', polishName: 'Magia Sakralna — Egzorcyzmy', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'witch', polishName: 'Wiedźmia Magia', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'necromancy', polishName: 'Nekromancja', pack: 'disciplines', book: 'ksiega-magii' },
|
||||
{ id: 'blood', polishName: 'Magia Krwi', pack: 'disciplines-forbidden', book: 'arcanum-sanguinis' },
|
||||
{ id: 'crimson', polishName: 'Magia Szkarłatu', pack: 'disciplines-forbidden', book: 'crimson-cult' },
|
||||
{ id: 'abyssAspects', polishName: 'Magia Otchłani — Magia Aspektów', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
|
||||
{ id: 'abyssPrimal', polishName: 'Magia Otchłani — Pierwotna Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
|
||||
{ id: 'wildWitch', polishName: 'Dzika Wiedźmia Magia', pack: 'disciplines-forbidden', book: 'klatwa-otchlani' },
|
||||
];
|
||||
|
||||
const SUGGESTED_ATTRIBUTE: Record<string, string> = {
|
||||
sacred: 'soul',
|
||||
blood: 'soul',
|
||||
crimson: 'soul',
|
||||
};
|
||||
|
||||
const SUGGESTED_SKILLS: Record<string, string[]> = {
|
||||
sacred: ['devotion'],
|
||||
blood: ['magicalAbilities'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull a one-paragraph discipline description out of Podręcznik Gry by
|
||||
* matching the header line and grabbing the next non-empty paragraph.
|
||||
*/
|
||||
/** Strip markdown heading markers from a line. */
|
||||
function stripHeadingMarkers(line: string): string {
|
||||
return line.replace(/^#{1,6}\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').trim();
|
||||
}
|
||||
|
||||
/** Skip YAML frontmatter (--- ... ---) at file start. */
|
||||
function skipFrontmatter(lines: string[]): number {
|
||||
if (lines.length === 0 || !/^---\s*$/.test(lines[0].trim())) return 0;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (/^---\s*$/.test(lines[i].trim())) return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function lookupDescription(repoRoot: string, polishName: string): string {
|
||||
// Try split-book sources first (disciplines from Księga Magii or Klątwa Otchłani
|
||||
// now live in rules/01.* and rules/02.*).
|
||||
const splitSources = [
|
||||
resolve(repoRoot, 'ObsidianNotes/rules/01. Księga Magii.md'),
|
||||
resolve(repoRoot, 'ObsidianNotes/rules/02. Klątwa Otchłani.md'),
|
||||
resolve(repoRoot, 'ObsidianNotes/rules/00. Podręcznik Gry.md'),
|
||||
];
|
||||
const target = normalizeKey(polishName.split('—')[0]);
|
||||
|
||||
for (const file of splitSources) {
|
||||
let text = '';
|
||||
try {
|
||||
text = readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const rawLines = text.split(/\r?\n/);
|
||||
const start = skipFrontmatter(rawLines);
|
||||
const lines = rawLines.slice(start);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const bare = normalizeKey(stripHeadingMarkers(lines[i].trim()));
|
||||
if (bare === target) {
|
||||
// Capture next paragraph (until blank line).
|
||||
const buf: string[] = [];
|
||||
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
||||
const cur = lines[j].trim();
|
||||
if (cur === '' && buf.length > 0) break;
|
||||
if (cur && !/^_{3,}$/.test(cur) && !/^#{1,6}\s/.test(cur)) buf.push(cur);
|
||||
}
|
||||
if (buf.length > 0) return buf.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export const parseDisciplines: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
|
||||
const docs: ParsedDoc[] = [];
|
||||
for (const seed of DISCIPLINE_SEEDS) {
|
||||
const baseSlug = ctx.idOverrides[slugify(seed.polishName)] ?? seed.id;
|
||||
const description = lookupDescription(ctx.repoRoot, seed.polishName);
|
||||
docs.push({
|
||||
id: baseSlug,
|
||||
name: seed.polishName,
|
||||
documentType: 'Item',
|
||||
subType: 'discipline',
|
||||
pack: seed.pack,
|
||||
source: { book: seed.book },
|
||||
system: {
|
||||
color: '',
|
||||
suggestedAttribute: SUGGESTED_ATTRIBUTE[seed.id] ?? 'magic',
|
||||
suggestedSkills: SUGGESTED_SKILLS[seed.id] ?? ['magicalAbilities'],
|
||||
passiveAbility: '',
|
||||
availableSpells: [],
|
||||
description,
|
||||
},
|
||||
description,
|
||||
});
|
||||
}
|
||||
return docs;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { resolve, join, extname, basename } from 'node:path';
|
||||
import type { ParsedDoc, ParserContext, ParserFn } from './types';
|
||||
import { slugify, parseList } from './helpers';
|
||||
|
||||
export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
|
||||
const docs: ParsedDoc[] = [];
|
||||
const itemsDir = resolve(ctx.repoRoot, 'ObsidianNotes', 'items');
|
||||
|
||||
if (!existsSync(itemsDir)) {
|
||||
console.warn(`[gear-parser] Directory not found: ${itemsDir}`);
|
||||
return docs;
|
||||
}
|
||||
|
||||
const subdirs = [
|
||||
{ name: 'weapons', pack: 'items-weapons', category: 'weapon' },
|
||||
{ name: 'armor', pack: 'items-armor', category: 'armor' },
|
||||
{ name: 'gear', pack: 'items-gear', category: 'equipment' },
|
||||
];
|
||||
|
||||
for (const dir of subdirs) {
|
||||
const fullDirPath = resolve(itemsDir, dir.name);
|
||||
if (!existsSync(fullDirPath)) continue;
|
||||
|
||||
const files = readdirSync(fullDirPath).filter((f) => extname(f) === '.md');
|
||||
for (const file of files) {
|
||||
const filePath = join(fullDirPath, file);
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
|
||||
const { frontmatter, content } = extractMarkdown(raw);
|
||||
const name = basename(file, '.md').replace(/_/g, ' ');
|
||||
|
||||
const doc = buildGearDoc(name, frontmatter, content, dir.category, dir.pack);
|
||||
docs.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
return docs;
|
||||
};
|
||||
|
||||
function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
|
||||
const fm: Record<string, string> = {};
|
||||
let content = raw;
|
||||
|
||||
if (raw.startsWith('---')) {
|
||||
const endIdx = raw.indexOf('---', 3);
|
||||
if (endIdx > -1) {
|
||||
const fmText = raw.slice(3, endIdx).trim();
|
||||
content = raw.slice(endIdx + 3).trim();
|
||||
|
||||
const lines = fmText.split('\n');
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > -1) {
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let val = line.slice(colonIdx + 1).trim();
|
||||
if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
fm[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { frontmatter: fm, content };
|
||||
}
|
||||
|
||||
function buildGearDoc(name: string, fm: Record<string, string>, content: string, category: string, pack: string): ParsedDoc {
|
||||
// Common properties
|
||||
const priceStr = fm.price || fm.cena || '0';
|
||||
const priceMatch = priceStr.match(/\d+/);
|
||||
const value = priceMatch ? parseInt(priceMatch[0], 10) : 0;
|
||||
|
||||
const description = content
|
||||
.split('\n')
|
||||
.filter(l => !l.startsWith('# ') && !l.startsWith('* **Cena:**') && !l.startsWith('* **Obrażenia:**') && !l.startsWith('* **Cechy:**') && !l.startsWith('* **Pancerz:**'))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
// HTML format description by wrapping paragraphs if needed
|
||||
const htmlDescription = description ? `<p>${description.replace(/\n\n/g, '</p><p>')}</p>` : '';
|
||||
|
||||
const system: any = {
|
||||
category,
|
||||
rarity: 'common',
|
||||
quantity: 1,
|
||||
weight: 0,
|
||||
value,
|
||||
equipped: false,
|
||||
damageReductionBonus: 0,
|
||||
description: htmlDescription,
|
||||
weapon: {
|
||||
damage: '',
|
||||
damageType: 'physical',
|
||||
properties: [],
|
||||
},
|
||||
armor: {
|
||||
damageReduction: 0,
|
||||
condition: 1,
|
||||
conditionMax: 1,
|
||||
armorType: 'light',
|
||||
stealthDisadvantage: false,
|
||||
strengthRequirement: 0,
|
||||
}
|
||||
};
|
||||
|
||||
if (category === 'weapon') {
|
||||
system.weapon.damage = fm.damage || fm.obrazenia || '';
|
||||
if (fm.traits || fm.cechy) {
|
||||
system.weapon.properties = parseList(fm.traits || fm.cechy);
|
||||
}
|
||||
} else if (category === 'armor') {
|
||||
const isShield = name.toLowerCase().includes('tarcza') || name.toLowerCase().includes('shield');
|
||||
const drStr = fm.armorClass || fm.pancerz || fm.armor_points || (isShield ? '1' : '0');
|
||||
const drMatch = drStr.match(/\d+/);
|
||||
const dr = drMatch ? parseInt(drMatch[0], 10) : 0;
|
||||
system.armor.damageReduction = dr;
|
||||
|
||||
const conditionStr = fm.wytrzymalosc || fm.condition || fm.durability;
|
||||
let cond = dr;
|
||||
if (conditionStr) {
|
||||
const condMatch = conditionStr.match(/\d+/);
|
||||
if (condMatch) {
|
||||
cond = parseInt(condMatch[0], 10);
|
||||
}
|
||||
}
|
||||
if (cond < 1) cond = 1;
|
||||
system.armor.condition = cond;
|
||||
system.armor.conditionMax = cond;
|
||||
|
||||
if (isShield) {
|
||||
system.armor.armorType = 'shield';
|
||||
} else if (dr === 3) {
|
||||
system.armor.armorType = 'heavy';
|
||||
} else if (dr === 2) {
|
||||
system.armor.armorType = 'medium';
|
||||
} else {
|
||||
system.armor.armorType = 'light';
|
||||
}
|
||||
|
||||
const traitsStr = fm.traits || fm.cechy || '';
|
||||
if (traitsStr.toLowerCase().includes('hałaśliwy') || traitsStr.toLowerCase().includes('noisy')) {
|
||||
system.armor.stealthDisadvantage = true;
|
||||
}
|
||||
|
||||
const reqStr = fm.requirements || fm.wymagania || '';
|
||||
const reqMatch = reqStr.match(/(?:ciało|body)\s*>=\s*(\d+)/i);
|
||||
if (reqMatch) {
|
||||
system.armor.strengthRequirement = parseInt(reqMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: slugify(`gear-${name}`),
|
||||
name,
|
||||
documentType: 'Item',
|
||||
subType: 'gear',
|
||||
pack,
|
||||
source: { book: 'core-rules' },
|
||||
system,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Polish-aware slug helper. Lowercases, strips diacritics, replaces
|
||||
* whitespace and punctuation with hyphens, collapses repeats.
|
||||
*/
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/ł/g, 'l')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
/** Strip Polish diacritics for case-insensitive map lookups. */
|
||||
export function normalizeKey(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.normalize('NFC');
|
||||
}
|
||||
|
||||
/** Parse "5:2" -> { threshold: 5, successes: 2 }. */
|
||||
export function parseDifficulty(s: string): { threshold: number; successes: number } | null {
|
||||
const m = s.trim().match(/^(\d+)\s*:\s*([\d\w]+)$/);
|
||||
if (!m) return null;
|
||||
const successes = isNaN(Number(m[2])) ? 1 : Number(m[2]);
|
||||
return { threshold: Number(m[1]), successes };
|
||||
}
|
||||
|
||||
/** Parse "1 punkt", "3 punkty", "5 punktów" → 1/3/5. Returns 0 on failure. */
|
||||
export function parsePoints(s: string): number {
|
||||
const m = s.trim().match(/^(\d+)/);
|
||||
return m ? Number(m[1]) : 0;
|
||||
}
|
||||
|
||||
/** Parse a comma- or whitespace-separated symbol list. */
|
||||
export function parseList(s: string): string[] {
|
||||
return s
|
||||
.split(/[,;]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => x.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Polish range string into structure.
|
||||
* "Na siebie" → { kind: 'self' }
|
||||
* "Dotyk" → { kind: 'touch' }
|
||||
* "15 m" → { kind: 'distance', value: 15, unit: 'm' }
|
||||
* anything else → { kind: 'special', text }
|
||||
*/
|
||||
export function parseRange(s: string): { kind: string; value?: number; unit?: string; text?: string } {
|
||||
const t = s.trim();
|
||||
if (/^na siebie$/i.test(t)) return { kind: 'self' };
|
||||
if (/^dotyk$/i.test(t)) return { kind: 'touch' };
|
||||
const m = t.match(/^(\d+)\s*(m|km|cm)\b/i);
|
||||
if (m) return { kind: 'distance', value: Number(m[1]), unit: m[2].toLowerCase() };
|
||||
return { kind: 'special', text: t };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse area-of-effect text into structured shape.
|
||||
* "3×8 m" → rectangle 3×8
|
||||
* "promień 5 m" → sphere radius 5
|
||||
* "stożek 10 m" → cone length 10
|
||||
* "linia 20 m" → line length 20
|
||||
*/
|
||||
export function parseAreaOfEffect(s: string): { shape: string; x?: number; y?: number; unit?: string; text?: string } | null {
|
||||
const t = s.trim();
|
||||
let m = t.match(/(\d+)\s*[x×]\s*(\d+)\s*(m|km|cm)?/i);
|
||||
if (m) return { shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: (m[3] ?? 'm').toLowerCase() };
|
||||
m = t.match(/promień\s+(\d+)\s*(m|km|cm)?/i);
|
||||
if (m) return { shape: 'sphere', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
|
||||
m = t.match(/stożek\s+(\d+)\s*(m|km|cm)?/i);
|
||||
if (m) return { shape: 'cone', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
|
||||
m = t.match(/linia\s+(\d+)\s*(m|km|cm)?/i);
|
||||
if (m) return { shape: 'line', x: Number(m[1]), unit: (m[2] ?? 'm').toLowerCase() };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Macro parser. Reads hand-coded macro source files from `scripts/macros/*.js`
|
||||
* and emits ParsedDoc[] for the build pipeline. Macros are not parsed from
|
||||
* books — they're a curated set of GM/player utilities.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { ParsedDoc, ParserContext, ParserFn } from './types';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const macrosDir = resolve(__dirname, '..', 'macros');
|
||||
|
||||
interface MacroDef {
|
||||
id: string;
|
||||
name: string;
|
||||
file: string;
|
||||
img: string;
|
||||
}
|
||||
|
||||
const MACROS: MacroDef[] = [
|
||||
{ id: 'pool-roll', name: 'Rzut Puli d6', file: 'pool-roll.js', img: 'icons/svg/d20.svg' },
|
||||
{ id: 'attribute-check', name: 'Test Atrybutu', file: 'attribute-check.js', img: 'icons/svg/dice-target.svg' },
|
||||
{ id: 'apply-condition', name: 'Nałóż Przypadłość', file: 'apply-condition.js', img: 'icons/svg/aura.svg' },
|
||||
{ id: 'short-rest', name: 'Krótki Odpoczynek', file: 'short-rest.js', img: 'icons/svg/regen.svg' },
|
||||
{ id: 'long-rest', name: 'Długi Odpoczynek', file: 'long-rest.js', img: 'icons/svg/sun.svg' },
|
||||
{ id: 'group-cast-helper', name: 'Pomocnik Rzucania Grupowego', file: 'group-cast-helper.js', img: 'icons/svg/upgrade.svg' },
|
||||
{ id: 'refill-zeal', name: 'Doładuj Zapał', file: 'refill-zeal.js', img: 'icons/svg/lightning.svg' },
|
||||
];
|
||||
|
||||
export const parseMacros: ParserFn = async (_ctx: ParserContext): Promise<ParsedDoc[]> => {
|
||||
const docs: ParsedDoc[] = [];
|
||||
for (const m of MACROS) {
|
||||
let command = '';
|
||||
try {
|
||||
command = readFileSync(resolve(macrosDir, m.file), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn(`[macro-parser] could not read ${m.file}: ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
docs.push({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
documentType: 'Item',
|
||||
subType: 'macro',
|
||||
pack: 'hbm-macros',
|
||||
source: { book: 'podrecznik-gry' },
|
||||
system: {
|
||||
type: 'script',
|
||||
scope: 'global',
|
||||
command,
|
||||
img: m.img,
|
||||
},
|
||||
});
|
||||
}
|
||||
return docs;
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Race parser. Races in HbM live in Podręcznik Gry, Rozdział II.
|
||||
*
|
||||
* Format:
|
||||
* Race Name (optional /Variant)
|
||||
* <flavor paragraphs>
|
||||
*
|
||||
* Cechy Rasowe:
|
||||
* * Dostępne Dziedziny Magii:
|
||||
* * <discipline 1>
|
||||
* * <discipline 2>
|
||||
* * N Punktów Atrybutów
|
||||
* * Darmowe Talenty:
|
||||
* * <talent 1>
|
||||
* * <talent 2>
|
||||
* * 1 Punkt Umiejętności w <skill>
|
||||
* * N Punktów Umiejętności
|
||||
* ________________
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ParsedDoc, ParserContext, ParserFn } from './types';
|
||||
import { normalizeKey, slugify } from './helpers';
|
||||
|
||||
const SEPARATOR = /^_{3,}$/;
|
||||
const RACE_FEATURES_HEADER = /^Cechy Rasowe:\s*$/;
|
||||
|
||||
const SOURCE_FILE = '_books/HbM RPG v3 - Podręcznik Gry.md';
|
||||
const KNOWN_RACES = new Set([
|
||||
'człowiek',
|
||||
'elf',
|
||||
'feles (kotowate)',
|
||||
'feles',
|
||||
'lamia (naga)',
|
||||
'lamia',
|
||||
'anioł/anielica',
|
||||
'anioł',
|
||||
'krasnolud',
|
||||
'demon',
|
||||
'malferianin',
|
||||
]);
|
||||
|
||||
export const parseRaces: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
|
||||
const docs: ParsedDoc[] = [];
|
||||
const path = resolve(ctx.repoRoot, SOURCE_FILE);
|
||||
let lines: string[] = [];
|
||||
try {
|
||||
lines = readFileSync(path, 'utf8').split(/\r?\n/);
|
||||
} catch {
|
||||
return docs;
|
||||
}
|
||||
|
||||
// Find each `Cechy Rasowe:` block; the race name is the most recent
|
||||
// non-empty, non-separator line above the block that matches a known race.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!RACE_FEATURES_HEADER.test(lines[i].trim())) continue;
|
||||
|
||||
// Walk up looking for the race name + flavor paragraphs.
|
||||
let nameLine = '';
|
||||
let nameIdx = i - 1;
|
||||
while (nameIdx >= 0) {
|
||||
const cur = lines[nameIdx].trim();
|
||||
if (cur && !SEPARATOR.test(cur)) {
|
||||
// The name is the FIRST line above that matches KNOWN_RACES; flavor lines come between.
|
||||
if (KNOWN_RACES.has(normalizeKey(cur))) {
|
||||
nameLine = cur;
|
||||
break;
|
||||
}
|
||||
}
|
||||
nameIdx--;
|
||||
}
|
||||
if (!nameLine) continue;
|
||||
|
||||
// Flavor description = lines between race name and `Cechy Rasowe:`.
|
||||
const flavorLines: string[] = [];
|
||||
for (let j = nameIdx + 1; j < i; j++) {
|
||||
const cur = lines[j].trimEnd();
|
||||
if (cur === '' || SEPARATOR.test(cur.trim())) continue;
|
||||
flavorLines.push(cur);
|
||||
}
|
||||
const physicalDescription = flavorLines.join('\n').trim();
|
||||
|
||||
// Walk down through the bullet block until next separator/blank-paragraph.
|
||||
const bulletLines: string[] = [];
|
||||
let k = i + 1;
|
||||
while (k < lines.length) {
|
||||
const cur = lines[k].trimEnd();
|
||||
if (SEPARATOR.test(cur.trim())) break;
|
||||
bulletLines.push(cur);
|
||||
k++;
|
||||
}
|
||||
|
||||
const parsed = parseRaceFeatures(bulletLines);
|
||||
|
||||
const baseSlug = slugify(stripVariant(nameLine));
|
||||
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
|
||||
|
||||
docs.push({
|
||||
id,
|
||||
name: stripVariant(nameLine),
|
||||
documentType: 'Item',
|
||||
subType: 'race',
|
||||
pack: 'races',
|
||||
source: { book: 'podrecznik-gry', chapter: 'Tworzenie Postaci', line: nameIdx + 1 },
|
||||
system: {
|
||||
availableDisciplines: parsed.disciplines,
|
||||
attributePoints: parsed.attributePoints,
|
||||
skillPoints: parsed.skillPoints,
|
||||
freeTalents: parsed.freeTalents,
|
||||
racialAbilities: [],
|
||||
physicalDescription,
|
||||
description: physicalDescription,
|
||||
},
|
||||
description: physicalDescription,
|
||||
});
|
||||
}
|
||||
|
||||
return docs;
|
||||
};
|
||||
|
||||
function stripVariant(name: string): string {
|
||||
// "Anioł/Anielica" → "Anioł"; "Feles (Kotowate)" → "Feles"
|
||||
return name.split('/')[0].split('(')[0].trim();
|
||||
}
|
||||
|
||||
interface ParsedRaceFeatures {
|
||||
disciplines: string[];
|
||||
attributePoints: number;
|
||||
skillPoints: number;
|
||||
freeTalents: string[];
|
||||
}
|
||||
|
||||
function parseRaceFeatures(lines: string[]): ParsedRaceFeatures {
|
||||
const result: ParsedRaceFeatures = {
|
||||
disciplines: [],
|
||||
attributePoints: 0,
|
||||
skillPoints: 0,
|
||||
freeTalents: [],
|
||||
};
|
||||
|
||||
// Detect "* <Header>:" then collect indented child bullets that follow.
|
||||
let mode: 'none' | 'disciplines' | 'talents' = 'none';
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (line === '') continue;
|
||||
|
||||
// Top-level bullet: `* xxx`
|
||||
const top = line.match(/^\*\s+(.+)$/);
|
||||
if (top) {
|
||||
const text = top[1].trim();
|
||||
if (/^Dostępne Dziedziny Magii:/i.test(text)) {
|
||||
mode = 'disciplines';
|
||||
continue;
|
||||
}
|
||||
if (/^Darmowe Talenty:/i.test(text)) {
|
||||
mode = 'talents';
|
||||
continue;
|
||||
}
|
||||
mode = 'none';
|
||||
|
||||
// Standalone counters.
|
||||
const attr = text.match(/^(\d+)\s+(?:Punkt|Punkty|Punktów)\s+Atrybutów/i);
|
||||
if (attr) result.attributePoints = Number(attr[1]);
|
||||
const skill = text.match(/^(\d+)\s+(?:Punkt|Punkty|Punktów)\s+Umiejętności(?:\s+do\s+rozdania)?/i);
|
||||
if (skill && !/Umiejętności\s+w\s+/i.test(text)) result.skillPoints = Number(skill[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indented child bullet ` * xxx` (Google Docs export uses tabs/spaces).
|
||||
const child = line.match(/^\s+\*\s+(.+)$/);
|
||||
if (!child) continue;
|
||||
const text = child[1].trim();
|
||||
|
||||
if (mode === 'disciplines') result.disciplines.push(text);
|
||||
else if (mode === 'talents') result.freeTalents.push(text);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { resolve, basename } from 'node:path';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
|
||||
import { slugify } from './helpers';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
/** Looser zod schema — mirrors `SpellData` defaults; build pipeline converts to system payload. */
|
||||
const SpellSystemSchema = z.object({
|
||||
castingMode: z.enum(['standard', 'sacred', 'witch', 'blood']),
|
||||
school: z.string(),
|
||||
deity: z.string().default(''),
|
||||
sourceBook: z.string(),
|
||||
manaCost: z.number().int().min(0),
|
||||
bloodCost: z.number().int().min(0).default(0),
|
||||
complexityLevel: z.number().int().min(0).default(0),
|
||||
isSuperspell: z.boolean().default(false),
|
||||
requiresGroupCast: z.boolean().default(false),
|
||||
minCasters: z.number().int().min(1).default(1),
|
||||
nonCombatOnly: z.boolean().default(false),
|
||||
damageBase: z.string().default(''),
|
||||
damageType: z.string().default(''),
|
||||
ignoresArmor: z.boolean().default(false),
|
||||
statusEffects: z.array(z.string()).default([]),
|
||||
saveAttribute: z.string().default(''),
|
||||
saveSkill: z.string().default(''),
|
||||
triggers: z.array(z.object({ event: z.string(), effect: z.string() })).default([]),
|
||||
components: z.object({
|
||||
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(''),
|
||||
});
|
||||
|
||||
function getAllFiles(dirPath: string, arrayOfFiles: string[] = []) {
|
||||
const files = readdirSync(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = resolve(dirPath, file);
|
||||
if (statSync(fullPath).isDirectory()) {
|
||||
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
|
||||
} else if (file.endsWith('.md')) {
|
||||
arrayOfFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return arrayOfFiles;
|
||||
}
|
||||
|
||||
export const parseSpells: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
|
||||
const docs: ParsedDoc[] = [];
|
||||
const spellsDir = resolve(ctx.repoRoot, 'ObsidianNotes', 'spells');
|
||||
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = getAllFiles(spellsDir);
|
||||
} catch (e) {
|
||||
console.warn('[spell-parser] No ObsidianNotes/spells directory found or unreadable.');
|
||||
return docs;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const match = text.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [_, frontmatterRaw, bodyRaw] = match;
|
||||
let frontmatter: any;
|
||||
try {
|
||||
frontmatter = yaml.load(frontmatterRaw);
|
||||
} catch (e) {
|
||||
if (ctx.strict) throw new Error(`[spell-parser] Invalid YAML in ${file}: ${e}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = { ...frontmatter, description: bodyRaw.trim() };
|
||||
|
||||
const nameMatch = payload.description.match(/^#\s+(.+)$/m);
|
||||
const spellName = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
|
||||
payload.description = payload.description.replace(/^#\s+.+$/m, '').trim();
|
||||
|
||||
const parsed = SpellSystemSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
if (ctx.strict) throw new Error(`[spell-parser] Validation failed for "${file}": ${parsed.error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseId = basename(file, '.md');
|
||||
|
||||
let pack = 'spells-academic';
|
||||
const relativePath = file.substring(spellsDir.length).replace(/\\/g, '/');
|
||||
if (relativePath.includes('/general/')) pack = 'spells-general';
|
||||
else if (relativePath.includes('/sacred/')) pack = 'spells-sacred';
|
||||
else if (relativePath.includes('/eldritch/')) pack = 'spells-eldritch';
|
||||
else if (relativePath.includes('/crimson/')) pack = 'spells-crimson';
|
||||
else if (relativePath.includes('/blood/')) pack = 'spells-blood';
|
||||
|
||||
docs.push({
|
||||
id: baseId,
|
||||
name: spellName,
|
||||
documentType: 'Item',
|
||||
subType: 'spell',
|
||||
pack,
|
||||
source: { book: parsed.data.sourceBook, chapter: 'Spells', line: 1 },
|
||||
system: parsed.data,
|
||||
description: payload.description,
|
||||
});
|
||||
}
|
||||
|
||||
return docs;
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Talent parser. Talents in HbM are formatted as plain-text headers:
|
||||
*
|
||||
* Talent Name
|
||||
* Wymagania: req1, req2 … (optional; single line)
|
||||
* <description paragraphs>
|
||||
* ________________
|
||||
*
|
||||
* They live in:
|
||||
* - Podręcznik Gry, "Rozdział IV - Talenty"
|
||||
* - Bestiariusz, "Rozdział III - Talenty" (NPC-only talents)
|
||||
*
|
||||
* The walker can't help us here — we segment by `________________`
|
||||
* separators within the talent chapter and look for a "Wymagania:" line.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ParsedDoc, ParserContext, ParserFn, SourceBookId } from './types';
|
||||
import { slugify } from './helpers';
|
||||
|
||||
const SEPARATOR = /^_{3,}$/;
|
||||
const REQUIREMENTS_PREFIX = /^(?:\*\*|\*)?Wymagania:(?:\*\*|\*)?\s*(.+)$/i;
|
||||
|
||||
interface TalentSource {
|
||||
book: SourceBookId;
|
||||
file: string;
|
||||
/** Inclusive line range (1-based) of the talent chapter. */
|
||||
startMarker: RegExp;
|
||||
endMarker: RegExp;
|
||||
/** Pack id to write into. */
|
||||
pack: string;
|
||||
/** Optional source-module flag stamped onto each talent. */
|
||||
sourceModule?: 'arcanum-sanguinis' | 'abyss-curse' | 'crimson-cult' | null;
|
||||
}
|
||||
|
||||
const TALENT_SOURCES: TalentSource[] = [
|
||||
{
|
||||
book: 'podrecznik-gry',
|
||||
file: 'ObsidianNotes/rules/00. Podręcznik Gry.md',
|
||||
startMarker: /^Rozdział IV - Talenty\s*$/,
|
||||
endMarker: /^Rozdział V\b/,
|
||||
pack: 'talents',
|
||||
sourceModule: null,
|
||||
},
|
||||
{
|
||||
book: 'bestiariusz',
|
||||
file: 'ObsidianNotes/rules/06. Bestiariusz.md',
|
||||
startMarker: /^Rozdział III - Talenty\s*$/,
|
||||
endMarker: /^Rozdział IV\b/,
|
||||
pack: 'talents-npc',
|
||||
sourceModule: null,
|
||||
},
|
||||
{
|
||||
// Arcanum Sanguinis: now lives in ObsidianNotes/rules/04. Arcanum Sanguinis.md.
|
||||
// Headings may be prefixed with `## ` or `### `.
|
||||
book: 'arcanum-sanguinis',
|
||||
file: 'ObsidianNotes/rules/04. Arcanum Sanguinis.md',
|
||||
startMarker: /^#{0,4}\s*Rozdział IV - Talenty\s*$/,
|
||||
endMarker: /^#{0,4}\s*(Rozdział V\b|Aneks\b)/,
|
||||
pack: 'talents-blood',
|
||||
sourceModule: 'arcanum-sanguinis',
|
||||
},
|
||||
{
|
||||
// Klątwa Otchłani: now lives in ObsidianNotes/rules/02. Klątwa Otchłani.md.
|
||||
book: 'klatwa-otchlani',
|
||||
file: 'ObsidianNotes/rules/02. Klątwa Otchłani.md',
|
||||
startMarker: /^#{0,4}\s*Rozdział II - Talenty\s*$/,
|
||||
endMarker: /^#{0,4}\s*Rozdział III\b/,
|
||||
pack: 'talents-eldritch',
|
||||
sourceModule: 'abyss-curse',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Talent chapters are noisy — split into segments by separator lines, then
|
||||
* within each segment find consecutive talent blocks. A talent block is:
|
||||
* - first non-empty line = name (short, no trailing punctuation, no colon)
|
||||
* - optional `Wymagania: ...` line
|
||||
* - subsequent lines until next name candidate or end of segment = description
|
||||
*
|
||||
* Heuristic for name detection: line is short (< 60 chars), no trailing `.`,
|
||||
* not a bullet, not a header (`Rozdział`, `Aneks`).
|
||||
*/
|
||||
export const parseTalents: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc[]> => {
|
||||
const docs: ParsedDoc[] = [];
|
||||
|
||||
for (const source of TALENT_SOURCES) {
|
||||
const path = resolve(ctx.repoRoot, source.file);
|
||||
let lines: string[] = [];
|
||||
try {
|
||||
lines = readFileSync(path, 'utf8').split(/\r?\n/);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startIdx = lines.findIndex((l) => source.startMarker.test(l.trim()));
|
||||
if (startIdx < 0) continue;
|
||||
const endIdx = lines.findIndex((l, i) => i > startIdx && source.endMarker.test(l.trim()));
|
||||
const slice = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length);
|
||||
|
||||
const hasMarkdownHeaders = slice.some((l) => l.trim().startsWith('### '));
|
||||
|
||||
// Walk the slice looking for talent blocks.
|
||||
let i = 0;
|
||||
while (i < slice.length) {
|
||||
// Skip blanks and separators.
|
||||
while (i < slice.length && (slice[i].trim() === '' || SEPARATOR.test(slice[i].trim()))) i++;
|
||||
if (i >= slice.length) break;
|
||||
|
||||
const rawLine = slice[i].trim();
|
||||
// Stop heuristic — bail out of obvious chapter changes.
|
||||
if (/^Rozdział\b/.test(rawLine) || /^Aneks\b/.test(rawLine) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(rawLine)) break;
|
||||
|
||||
let isName = false;
|
||||
let nameLine = '';
|
||||
if (hasMarkdownHeaders) {
|
||||
if (rawLine.startsWith('### ')) {
|
||||
isName = true;
|
||||
nameLine = rawLine.replace(/^###\s+/, '').trim();
|
||||
}
|
||||
} else {
|
||||
nameLine = rawLine.replace(/^#{1,6}\s+/, '').trim();
|
||||
if (isPlausibleName(nameLine)) {
|
||||
isName = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isName) {
|
||||
// Can't read this — skip the line and continue.
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
// Optional requirements line.
|
||||
let requirements = '';
|
||||
if (i < slice.length) {
|
||||
const m = slice[i].trim().match(REQUIREMENTS_PREFIX);
|
||||
if (m) {
|
||||
requirements = m[1].trim();
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Description until next plausible name OR separator OR chapter.
|
||||
const descLines: string[] = [];
|
||||
while (i < slice.length) {
|
||||
const cur = slice[i].trim();
|
||||
if (SEPARATOR.test(cur)) {
|
||||
i++; // consume separator and end this talent
|
||||
break;
|
||||
}
|
||||
if (/^Rozdział\b/.test(cur) || /^Aneks\b/.test(cur) || /^#{1,6}\s+(?:Rozdział|Aneks)\b/i.test(cur)) break;
|
||||
|
||||
if (hasMarkdownHeaders) {
|
||||
if (cur.startsWith('### ')) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// If we've already captured at least one description line and the
|
||||
// current line looks like a talent header (plausible name; next line
|
||||
// is Wymagania, blank, or another short line), treat as next talent.
|
||||
const cleanCur = cur.replace(/^#{1,6}\s+/, '').trim();
|
||||
if (descLines.length > 0 && cur && isPlausibleName(cleanCur)) {
|
||||
const next = i + 1 < slice.length ? slice[i + 1].trim() : '';
|
||||
if (REQUIREMENTS_PREFIX.test(next) || next === '' || isProseOrReq(next)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
descLines.push(slice[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
let finalName = nameLine;
|
||||
if (nameLine === 'Czuły Zmysł') {
|
||||
finalName = 'Czuły Zmysł (Zmysł)';
|
||||
}
|
||||
const baseSlug = slugify(finalName);
|
||||
const id = ctx.idOverrides[baseSlug] ?? baseSlug;
|
||||
const description = descLines.join('\n').trim();
|
||||
|
||||
// Detect "(Dziedzina)" / "(Bóstwo)" / similar parameterised talents.
|
||||
const multiSelect = /\((Dziedzina|Bóstwo|Zmysł|Atrybut|Umiejętność)\)$/i.test(finalName);
|
||||
|
||||
docs.push({
|
||||
id,
|
||||
name: finalName,
|
||||
documentType: 'Item',
|
||||
subType: 'talent',
|
||||
pack: source.pack,
|
||||
source: { book: source.book, chapter: 'Talenty', line: startIdx + 1 },
|
||||
system: {
|
||||
requirements: parseRequirements(requirements, ctx),
|
||||
multiSelect,
|
||||
cost: '',
|
||||
damageReductionBonus: 0,
|
||||
description,
|
||||
effect: '',
|
||||
},
|
||||
description,
|
||||
flags: source.sourceModule ? { sourceModule: source.sourceModule } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return docs;
|
||||
};
|
||||
|
||||
/** A talent name is short, doesn't end with `.`, no `:`, not a bullet, no digits-only. */
|
||||
function isPlausibleName(line: string): boolean {
|
||||
if (!line || line.length > 80) return false;
|
||||
if (line.endsWith('.') || line.endsWith(',')) return false;
|
||||
if (line.includes(':')) return false;
|
||||
if (/^[*\-•]/.test(line)) return false;
|
||||
if (/^\d+$/.test(line)) return false;
|
||||
if (/^Wymagania\b/i.test(line)) return false;
|
||||
// Must have at least one capital letter (talent names are Title Case).
|
||||
if (!/[A-ZŻŹĆĄŚĘŁÓŃ]/.test(line)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isProseOrReq(line: string): boolean {
|
||||
if (!line) return false;
|
||||
if (/^Wymagania\b/i.test(line)) return true;
|
||||
// Proseish: long, ends with punctuation, contains lowercase mid-sentence words.
|
||||
return line.length > 60 || /[.!?]$/.test(line);
|
||||
}
|
||||
|
||||
function parseRequirements(text: string, _ctx: ParserContext): Record<string, unknown> {
|
||||
// Keep raw + crude extraction. Detailed parsing can come later.
|
||||
if (!text) {
|
||||
return { race: '', attribute: '', skill: '', talent: '', title: '', discipline: '' };
|
||||
}
|
||||
return {
|
||||
race: extractParen(text, /Rasa\s*\(([^)]+)\)/i),
|
||||
attribute: '',
|
||||
skill: '',
|
||||
talent: '',
|
||||
title: extractParen(text, /Tytuł\s*\(([^)]+)\)/i),
|
||||
discipline: extractParen(text, /Dziedzina\s+Magii\s*\(([^)]+)\)/i),
|
||||
raw: text,
|
||||
};
|
||||
}
|
||||
|
||||
function extractParen(text: string, re: RegExp): string {
|
||||
const m = text.match(re);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared parser types — what each parser emits before being handed off
|
||||
* to the pack builder.
|
||||
*
|
||||
* Every parsed document has a stable id (slug) and a sourceBook ref so
|
||||
* we can deduplicate, lint, and re-link references later.
|
||||
*/
|
||||
|
||||
export type SourceBookId =
|
||||
| 'podrecznik-gry'
|
||||
| 'ksiega-magii'
|
||||
| 'bestiariusz'
|
||||
| 'przewodnik'
|
||||
| 'arcanum-sanguinis'
|
||||
| 'crimson-cult'
|
||||
| 'klatwa-otchlani'
|
||||
| 'zlote-stal-magia';
|
||||
|
||||
export interface ParsedDoc {
|
||||
/** Compendium-stable slug (kebab-case). */
|
||||
id: string;
|
||||
/** Display name (Polish, as in book). */
|
||||
name: string;
|
||||
/** Foundry document type — `Item` or `Actor`. */
|
||||
documentType: 'Item' | 'Actor';
|
||||
/** Foundry sub-type for the data model (e.g. `spell`, `talent`). */
|
||||
subType: string;
|
||||
/** Pack id this doc belongs to (e.g. `spells-general`). */
|
||||
pack: string;
|
||||
/** Source book + chapter for traceability. */
|
||||
source: { book: SourceBookId; chapter?: string; line?: number };
|
||||
/** The `system.*` payload that will be written into the pack. */
|
||||
system: Record<string, unknown>;
|
||||
/** Optional rich-text description (markdown → HTML). */
|
||||
description?: string;
|
||||
/** Optional extra flags merged into `flags['hbm-rpg-v3']`. */
|
||||
flags?: Record<string, unknown>;
|
||||
/** Non-fatal parser warnings (missing fields, ambiguous values, etc.). */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface ParserContext {
|
||||
/** Absolute path to repository root (so parsers can resolve `_books/`). */
|
||||
repoRoot: string;
|
||||
/** Strict mode aborts on first warning; default `false`. */
|
||||
strict?: boolean;
|
||||
/** ID overrides loaded from `_id-overrides.json`. */
|
||||
idOverrides: Record<string, string>;
|
||||
/** Label → constant mapping (Polish UI label → English internal id). */
|
||||
labelMappings: {
|
||||
schools: Record<string, string>;
|
||||
disciplines: Record<string, string>;
|
||||
deities: Record<string, string>;
|
||||
symbols: Record<string, string>;
|
||||
attributes: Record<string, string>;
|
||||
skills: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ParserFn = (ctx: ParserContext) => Promise<ParsedDoc[]>;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// 1. Resolve paths and load package manifest
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
let id = '';
|
||||
let version = '';
|
||||
|
||||
if (existsSync(resolve(root, 'module.json'))) {
|
||||
const moduleJson = JSON.parse(readFileSync(resolve(root, 'module.json'), 'utf8'));
|
||||
id = moduleJson.id;
|
||||
version = moduleJson.version;
|
||||
} else if (existsSync(resolve(root, 'system.json'))) {
|
||||
const systemJson = JSON.parse(readFileSync(resolve(root, 'system.json'), 'utf8'));
|
||||
id = systemJson.id;
|
||||
version = systemJson.version;
|
||||
} else if (existsSync(resolve(root, 'dist', 'system.json'))) {
|
||||
const systemJson = JSON.parse(readFileSync(resolve(root, 'dist', 'system.json'), 'utf8'));
|
||||
id = systemJson.id;
|
||||
version = systemJson.version;
|
||||
} else {
|
||||
console.error("Error: Could not find system.json or module.json manifest!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const zipPath = resolve(root, `${id}-v${version}.zip`);
|
||||
|
||||
if (!existsSync(zipPath)) {
|
||||
console.error(`Error: Packaged zip file not found at ${zipPath}`);
|
||||
console.error("Please run 'bun run package' first to generate the zip package.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Determine ingest URL and API token from environment variables
|
||||
const ingestUrl = process.env.FOUNDRY_INGEST_URL;
|
||||
const apiToken = process.env.FOUNDRY_INGEST_TOKEN;
|
||||
|
||||
if (!ingestUrl) {
|
||||
console.error("Error: FOUNDRY_INGEST_URL environment variable is not defined!");
|
||||
console.error("Please define FOUNDRY_INGEST_URL in your .env file.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!apiToken) {
|
||||
console.error("Error: FOUNDRY_INGEST_TOKEN environment variable is not defined!");
|
||||
console.error("Please define FOUNDRY_INGEST_TOKEN in your .env file.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Pushing package "${id}" version ${version}...`);
|
||||
console.log(`Target URL: ${ingestUrl}`);
|
||||
|
||||
// 3. Build multipart form data and perform upload
|
||||
const formData = new FormData();
|
||||
const fileBlob = Bun.file(zipPath);
|
||||
formData.append('file', fileBlob, `${id}-v${version}.zip`);
|
||||
|
||||
try {
|
||||
const response = await fetch(ingestUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-TOKEN': apiToken
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log("✓ Package successfully pushed and ingested!");
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.error(`Error: Ingest server returned status ${response.status} ${response.statusText}`);
|
||||
const text = await response.text();
|
||||
console.error(`Response details: ${text}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error connecting to ingest server:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Window } from 'happy-dom';
|
||||
import * as fs from 'fs';
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
const window = new Window();
|
||||
|
||||
// Mock Handlebars helpers used in templates
|
||||
Handlebars.registerHelper('eq', (a: any, b: any) => a === b);
|
||||
Handlebars.registerHelper('gt', (a: number, b: number) => a > b);
|
||||
Handlebars.registerHelper('lt', (a: number, b: number) => a < b);
|
||||
Handlebars.registerHelper('or', (...args: any[]) => (args.slice(0, -1) as any[]).some(Boolean));
|
||||
Handlebars.registerHelper('and', (...args: any[]) => (args.slice(0, -1) as any[]).every(Boolean));
|
||||
Handlebars.registerHelper('concat', (...args: any[]) => (args.slice(0, -1) as string[]).join(''));
|
||||
Handlebars.registerHelper('localize', (key: string) => `[localized:${key}]`);
|
||||
|
||||
// Register the actor effects partial as an empty template for testing
|
||||
Handlebars.registerPartial('systems/hbm-rpg-v3/templates/actor/_actor-effects.hbs', '<fieldset class="mock-effects"></fieldset>');
|
||||
|
||||
function testRender(filePath: string) {
|
||||
console.log(`\n--- Rendering Handlebars template: ${filePath} ---`);
|
||||
const templateSource = fs.readFileSync(filePath, 'utf8');
|
||||
const template = Handlebars.compile(templateSource);
|
||||
|
||||
// Mock context simulating what CharacterSheet / NpcSheet returns
|
||||
const context = {
|
||||
document: { name: 'Test Character', img: 'icons/svg/mystery-man.svg' },
|
||||
system: {
|
||||
details: { year: 1, race: 'czlowiek', discipline: 'alchemyTransmutation', customEquipment: 'Test gear' },
|
||||
attributes: {
|
||||
body: { value: 3 },
|
||||
mind: { value: 3 },
|
||||
soul: { value: 3 },
|
||||
magic: { value: 1 },
|
||||
health: { value: 10, max: 10 },
|
||||
mana: { value: 10, max: 10, maxPerSpell: 3 },
|
||||
zeal: { value: 0, max: 5 },
|
||||
insanity: 0,
|
||||
magicalArmor: { value: 0, max: 0, runicCounter: 0 },
|
||||
magicalShield: { value: 0 },
|
||||
physicalArmor: { value: 0, max: 5 },
|
||||
initiative: 3,
|
||||
},
|
||||
skills: {
|
||||
athletics: { value: 1 },
|
||||
melee: { value: 2 },
|
||||
},
|
||||
combat: {
|
||||
attacks: [
|
||||
{ name: 'Miecz', bonus: 2, damage: '2d6', description: 'Zwykły miecz' }
|
||||
],
|
||||
specialAbilities: [],
|
||||
reactions: [],
|
||||
legendaryActions: []
|
||||
},
|
||||
lore: { description: 'Test lore description' }
|
||||
},
|
||||
races: [{ key: 'czlowiek', label: 'Człowiek' }],
|
||||
disciplines: [{ key: 'alchemyTransmutation', label: 'Transmutacja' }],
|
||||
attributes: [
|
||||
{ key: 'body', value: 3, label: 'Budowa' },
|
||||
{ key: 'mind', value: 3, label: 'Umysł' },
|
||||
{ key: 'soul', value: 3, label: 'Dusza' },
|
||||
{ key: 'magic', value: 1, label: 'Magia' }
|
||||
],
|
||||
skills: [
|
||||
{ key: 'athletics', value: 1, label: 'Atletyka' },
|
||||
{ key: 'melee', value: 2, label: 'Walka wręcz' }
|
||||
],
|
||||
tabGroups: { primary: 'stats' },
|
||||
tabs: [
|
||||
{ id: 'stats', label: 'Statystyki', active: true, cssClass: 'active' },
|
||||
{ id: 'actions', label: 'Akcje', active: false, cssClass: '' },
|
||||
{ id: 'skills', label: 'Umiejętności', active: false, cssClass: '' },
|
||||
{ id: 'spells', label: 'Zaklęcia', active: false, cssClass: '' },
|
||||
{ id: 'talents', label: 'Talenty', active: false, cssClass: '' },
|
||||
{ id: 'inventory', label: 'Ekwipunek', active: false, cssClass: '' },
|
||||
{ id: 'effects', label: 'Efekty', active: false, cssClass: '' },
|
||||
{ id: 'biography', label: 'Biografia', active: false, cssClass: '' }
|
||||
],
|
||||
spells: [],
|
||||
spellSchoolGroups: [],
|
||||
hasBloodMagic: false,
|
||||
elixirCap: 4,
|
||||
gear: [],
|
||||
abilities: [],
|
||||
talents: [],
|
||||
actorEffects: []
|
||||
};
|
||||
|
||||
const renderedHtml = template(context);
|
||||
|
||||
const parser = new window.DOMParser();
|
||||
const doc = parser.parseFromString(renderedHtml, 'text/html');
|
||||
const body = doc.body;
|
||||
|
||||
const children = Array.from(body.childNodes).filter(node => {
|
||||
// Filter out whitespace-only text nodes
|
||||
if (node.nodeType === 3 && !node.textContent.trim()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`Rendered children count: ${children.length}`);
|
||||
children.forEach((node, i) => {
|
||||
console.log(`Child ${i}: type=${node.nodeType} (${node.nodeName}), textLength=${node.textContent.trim().length}`);
|
||||
if (children.length > 1) {
|
||||
console.log(`OuterHTML snippet:`, (node as any).outerHTML || node.textContent.slice(0, 100));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testRender('./templates/actor/character.hbs');
|
||||
testRender('./templates/actor/npc.hbs');
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Window } from 'happy-dom';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const window = new Window();
|
||||
|
||||
function testFile(filePath: string) {
|
||||
console.log(`\n--- Testing file: ${filePath} ---`);
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Basic Handlebars comments and blocks stripping to leave clean HTML
|
||||
content = content.replace(/\{\{!--[\s\S]*?--\}\}/g, '');
|
||||
content = content.replace(/\{\{[\s\S]*?\}\}/g, '');
|
||||
|
||||
const parser = new window.DOMParser();
|
||||
const doc = parser.parseFromString(content, 'text/html');
|
||||
const body = doc.body;
|
||||
|
||||
const children = Array.from(body.childNodes).filter(node => {
|
||||
// Mimic the likely behavior of Foundry: filter out empty text nodes
|
||||
if (node.nodeType === 3 && !node.textContent.trim()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`Parsed children count: ${children.length}`);
|
||||
children.forEach((node, i) => {
|
||||
console.log(`Child ${i}: type=${node.nodeType} (${node.nodeName}), textLength=${node.textContent.trim().length}`);
|
||||
if (children.length > 1) {
|
||||
console.log(`OuterHTML snippet:`, (node as any).outerHTML || node.textContent.slice(0, 100));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const characterPath = './templates/actor/character.hbs';
|
||||
const npcPath = './templates/actor/npc.hbs';
|
||||
|
||||
testFile(characterPath);
|
||||
testFile(npcPath);
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Shared constants for the HbM RPG v3 Foundry system.
|
||||
* All identifiers are English; UI labels live in lang/{pl,en}.json.
|
||||
*/
|
||||
|
||||
export const ATTRIBUTES = ['body', 'mind', 'soul', 'magic'] as const;
|
||||
export type AttributeKey = (typeof ATTRIBUTES)[number];
|
||||
|
||||
/** Skill tag categories used by the roll-modifier pipeline. */
|
||||
export type SkillTag = 'sight' | 'hearing' | 'social' | 'attack' | 'magic' | 'movement';
|
||||
|
||||
/**
|
||||
* Canonical 24 skills. Each maps to its default associated attribute,
|
||||
* but the GM may permit a different attribute for a specific roll
|
||||
* (handled at the roller level via attributeOverride).
|
||||
*/
|
||||
export const SKILLS: Readonly<Record<string, AttributeKey>> = Object.freeze({
|
||||
athletics: 'body',
|
||||
agility: 'body',
|
||||
strength: 'body',
|
||||
melee: 'body',
|
||||
ranged: 'body',
|
||||
stealth: 'body',
|
||||
endurance: 'body',
|
||||
reflex: 'body',
|
||||
|
||||
perception: 'mind',
|
||||
intuition: 'mind',
|
||||
craft: 'mind',
|
||||
medicine: 'mind',
|
||||
generalLore: 'mind',
|
||||
natureLore: 'mind',
|
||||
magicLore: 'mind',
|
||||
theology: 'mind',
|
||||
|
||||
empathy: 'soul',
|
||||
persuasion: 'soul',
|
||||
intimidation: 'soul',
|
||||
determination: 'soul',
|
||||
devotion: 'soul',
|
||||
disguise: 'soul',
|
||||
animalHandling: 'soul',
|
||||
|
||||
magicalAbilities: 'magic',
|
||||
});
|
||||
|
||||
/**
|
||||
* Skill → tag mapping consulted by Active-Effect-driven roll modifiers.
|
||||
* `blocksTags` on a condition prevents rolls of any skill containing that tag.
|
||||
*/
|
||||
export const SKILL_TAGS: Readonly<Record<string, readonly SkillTag[]>> = Object.freeze({
|
||||
athletics: ['movement'],
|
||||
agility: ['movement'],
|
||||
strength: [],
|
||||
melee: ['attack'],
|
||||
ranged: ['attack', 'sight'],
|
||||
stealth: ['movement', 'sight'],
|
||||
endurance: [],
|
||||
reflex: [],
|
||||
perception: ['sight', 'hearing'],
|
||||
intuition: ['social'],
|
||||
craft: [],
|
||||
medicine: [],
|
||||
generalLore: [],
|
||||
natureLore: [],
|
||||
magicLore: [],
|
||||
theology: [],
|
||||
empathy: ['social', 'hearing'],
|
||||
persuasion: ['social', 'hearing'],
|
||||
intimidation: ['social'],
|
||||
determination: [],
|
||||
devotion: [],
|
||||
disguise: ['social'],
|
||||
animalHandling: ['social'],
|
||||
magicalAbilities: ['magic'],
|
||||
});
|
||||
|
||||
export const SKILL_KEYS = Object.freeze(Object.keys(SKILLS)) as readonly string[];
|
||||
|
||||
/**
|
||||
* Magic Power Level lookup table (level 0..10 → I..X).
|
||||
* Drives dice pool size, max mana per single spell, and total mana per round.
|
||||
*/
|
||||
export interface MagicPowerEntry {
|
||||
level: number; // 0..10
|
||||
label: string; // '0' | 'I' | 'II' | ... | 'X'
|
||||
dicePool: number; // dice added when casting
|
||||
maxPerSpell: number; // max mana spent on one spell
|
||||
manaPerRound: number; // mana budget per combat round
|
||||
}
|
||||
|
||||
export const MAGIC_POWER_TABLE: readonly MagicPowerEntry[] = Object.freeze([
|
||||
{ level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerRound: 20 },
|
||||
{ level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerRound: 12 },
|
||||
{ level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerRound: 10 },
|
||||
{ level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerRound: 7 },
|
||||
{ level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerRound: 8 },
|
||||
{ level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerRound: 6 },
|
||||
{ level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerRound: 6 },
|
||||
{ level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerRound: 4 },
|
||||
{ level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerRound: 3 },
|
||||
{ level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerRound: 2 },
|
||||
{ level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerRound: 1 },
|
||||
]);
|
||||
|
||||
export function getMagicPowerEntry(level: number): MagicPowerEntry {
|
||||
const clamped = Math.max(0, Math.min(10, Math.floor(level ?? 0)));
|
||||
return MAGIC_POWER_TABLE[clamped]!;
|
||||
}
|
||||
|
||||
/** TS roll defaults */
|
||||
export const TS_DEFAULT_THRESHOLD = 4;
|
||||
export const TS_DEFAULT_REQUIRED = 1;
|
||||
export const TS_DIE_FACES = 6;
|
||||
export const TS_AUTO_FAILURE_FACE = 1;
|
||||
export const TS_AUTO_SUCCESS_FACE = 6;
|
||||
|
||||
/** Casting modes for spells */
|
||||
export const CASTING_MODES = ['standard', 'sacred', 'witch', 'blood'] as const;
|
||||
export type CastingMode = (typeof CASTING_MODES)[number];
|
||||
|
||||
/**
|
||||
* Spell schools / disciplines (English identifiers).
|
||||
* Covers academic, sacred, witch, and forbidden disciplines from all books.
|
||||
*/
|
||||
export const SPELL_SCHOOLS = [
|
||||
// Generic / unclassified
|
||||
'general',
|
||||
// Academic disciplines
|
||||
'alchemyTransmutation', 'alchemyBrewing', 'botany',
|
||||
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
|
||||
'artifice', 'golemancy', 'runes', 'manaSourceMage',
|
||||
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy',
|
||||
// Forbidden / extra-academic
|
||||
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
|
||||
] as const;
|
||||
export type SpellSchool = (typeof SPELL_SCHOOLS)[number];
|
||||
|
||||
/** Sacred Magic deities (sub-school identifier when school === 'sacred'). */
|
||||
export const SACRED_DEITIES = [
|
||||
'common', 'jahwe', 'zeus', 'demeter', 'artemis',
|
||||
'hekate', 'aphrodite', 'eros',
|
||||
] as const;
|
||||
export type SacredDeity = (typeof SACRED_DEITIES)[number];
|
||||
|
||||
/**
|
||||
* Witch magic symbols. EXTENSIBLE — additional content may add more.
|
||||
* Used for autocomplete only; spell.components.symbols accepts arbitrary strings.
|
||||
*/
|
||||
export const WITCH_SYMBOLS: readonly string[] = Object.freeze([
|
||||
'Potentia', 'Tutamen', 'Lux', 'Motus', 'Iter', 'Vacuos', 'Vitium',
|
||||
'Praecantatio', 'Aer', 'Aqua', 'Gelum', 'Ignis', 'Terra',
|
||||
'Cognitio', 'Alienis', 'Illusio', 'Somnium', 'Tenebrae', 'Auram',
|
||||
'Vinculum', 'Telum', 'Sensus', 'Perditio', 'Perfodio', 'Sano',
|
||||
'Volatus', 'Tempestas',
|
||||
]);
|
||||
|
||||
/** Source-book provenance for compendium content. */
|
||||
export const SOURCE_BOOKS = [
|
||||
'core-rules', 'magic-book', 'arcanum-sanguinis',
|
||||
'crimson-cult', 'abyss-curse', 'humanity-guide',
|
||||
'bestiary', 'gold-steel-magic',
|
||||
] as const;
|
||||
export type SourceBook = (typeof SOURCE_BOOKS)[number];
|
||||
|
||||
/** Area-of-effect shapes for spells. */
|
||||
export const AOE_SHAPES = ['point', 'square', 'rectangle', 'cone', 'sphere', 'line'] as const;
|
||||
export type AoeShape = (typeof AOE_SHAPES)[number];
|
||||
|
||||
/** Spell damage types (extends DAMAGE_TYPES with magical variants). */
|
||||
export const SPELL_DAMAGE_TYPES = ['magical', 'physicalMagical', 'pure'] as const;
|
||||
export type SpellDamageType = (typeof SPELL_DAMAGE_TYPES)[number];
|
||||
|
||||
/** Trigger event identifiers (reactive spell hooks). */
|
||||
export const TRIGGER_EVENTS = ['killWithWeapon', 'targetCastsSpell', 'damageTaken', 'turnStart'] as const;
|
||||
export type TriggerEvent = (typeof TRIGGER_EVENTS)[number];
|
||||
|
||||
/** Gear categories */
|
||||
export const GEAR_CATEGORIES = ['weapon', 'armor', 'equipment'] as const;
|
||||
export type GearCategory = (typeof GEAR_CATEGORIES)[number];
|
||||
|
||||
/** Damage types */
|
||||
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
|
||||
export type DamageType = (typeof DAMAGE_TYPES)[number];
|
||||
|
||||
/** Armor types */
|
||||
export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const;
|
||||
export type ArmorType = (typeof ARMOR_TYPES)[number];
|
||||
|
||||
/** Ability action type */
|
||||
export const ABILITY_TYPES = ['passive', 'active', 'reaction', 'freeAction'] as const;
|
||||
export type AbilityType = (typeof ABILITY_TYPES)[number];
|
||||
|
||||
/** Item rarity (Polish-source naming preserved as identifiers in English form) */
|
||||
export const RARITIES = ['common', 'uncommon', 'rare', 'veryRare', 'legendary', 'artifact'] as const;
|
||||
export type Rarity = (typeof RARITIES)[number];
|
||||
|
||||
/**
|
||||
* Mental illness condition IDs (Klątwa Otchłani VIII).
|
||||
* Registered as ActiveEffect-driven status conditions alongside physical conditions.
|
||||
*/
|
||||
export const MENTAL_CONDITIONS = ['paranoja', 'fobia', 'depresja', 'mania', 'schizofrenia'] as const;
|
||||
export type MentalCondition = (typeof MENTAL_CONDITIONS)[number];
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeHtmlField,
|
||||
makeValueMaxField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from '../constants';
|
||||
|
||||
/**
|
||||
* Player Character data model.
|
||||
*
|
||||
* Derived stats (computed in prepareDerivedData):
|
||||
* - attributes.health.max = 3 * (body + mind + soul)
|
||||
* - attributes.zeal.max = ceil(soul / 2)
|
||||
* - attributes.initiative = mind + skills.reflex.value + skills.perception.value
|
||||
* - attributes.mana.max / .maxPerSpell / magic.dicePool — from MAGIC_POWER_TABLE
|
||||
*/
|
||||
export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
|
||||
const attributeSchema = () => new f.SchemaField({
|
||||
value: makeIntField(1, { min: 1, max: 8 }),
|
||||
});
|
||||
|
||||
const skillEntries: Record<string, foundry.data.fields.DataField.Any> = {};
|
||||
for (const key of SKILL_KEYS) {
|
||||
skillEntries[key] = new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0, max: 8 }),
|
||||
defaultAttribute: makeStringField(SKILLS[key] as AttributeKey, {
|
||||
blank: false,
|
||||
choices: ATTRIBUTES as unknown as readonly string[],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleRows: Record<string, foundry.data.fields.DataField.Any> = {};
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
scheduleRows[`row${i}`] = new f.SchemaField({
|
||||
hours: makeStringField('', { blank: true }),
|
||||
mon: makeStringField('', { blank: true }),
|
||||
tue: makeStringField('', { blank: true }),
|
||||
wed: makeStringField('', { blank: true }),
|
||||
thu: makeStringField('', { blank: true }),
|
||||
fri: makeStringField('', { blank: true }),
|
||||
sat: makeStringField('', { blank: true }),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: new f.SchemaField({
|
||||
body: attributeSchema(),
|
||||
mind: attributeSchema(),
|
||||
soul: attributeSchema(),
|
||||
magic: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0, max: 10 }),
|
||||
actual: makeIntField(0, { min: 0, max: 10 }), // derived
|
||||
dicePool: makeIntField(0, { min: 0 }), // derived
|
||||
}),
|
||||
health: makeValueMaxField(0, 0),
|
||||
mana: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
max: makeIntField(0, { min: 0 }),
|
||||
maxPerSpell: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
zeal: makeValueMaxField(0, 0),
|
||||
// Blood Pool (Arcanum Sanguinis Ch. III) — experimental until full spell list ships.
|
||||
// Placeholder formula: max = body + soul.
|
||||
blood: makeValueMaxField(0, 0),
|
||||
// Elixir tolerance (Podręcznik Gry — brewing). Each potion consumed adds 1;
|
||||
// recovers 1 per long rest. Soft cap = body + 1; over cap → poisoning.
|
||||
elixirTolerance: makeIntField(0, { min: 0 }),
|
||||
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
|
||||
// mental-condition table when reaching thresholds.
|
||||
insanity: makeIntField(0, { min: 0 }),
|
||||
magicalArmor: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
max: makeIntField(0, { min: 0 }),
|
||||
runicCounter: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
magicalShield: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
physicalArmor: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }), // derived
|
||||
max: makeIntField(0, { min: 0 }), // derived: base DR from equipment
|
||||
bonus: makeIntField(0, { min: 0 }), // editable bonus (flat +DR)
|
||||
condition: makeIntField(0, { min: 0 }), // derived: sum of armor conditions
|
||||
conditionMax: makeIntField(0, { min: 0 }), // derived: sum of armor conditionMax
|
||||
}),
|
||||
initiative: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
skills: new f.SchemaField(skillEntries),
|
||||
details: new f.SchemaField({
|
||||
race: makeStringField('', { blank: true }),
|
||||
raceId: makeStringField('', { blank: true }),
|
||||
classIds: makeArrayField(new f.StringField({ blank: true })),
|
||||
year: makeIntField(1, { min: 1, max: 4 }),
|
||||
discipline: makeStringField('', { blank: true }),
|
||||
title: makeStringField('', { blank: true }),
|
||||
biography: makeHtmlField(''),
|
||||
customEquipment: makeHtmlField(''), // legacy textarea — kept for migration
|
||||
customItems: makeArrayField(new f.SchemaField({
|
||||
name: makeStringField('', { blank: true }),
|
||||
qty: makeIntField(1, { min: 0 }),
|
||||
note: makeStringField('', { blank: true }),
|
||||
})),
|
||||
experience: makeIntField(0, { min: 0 }),
|
||||
money: makeIntField(0, { min: 0 }),
|
||||
classSchedule: makeStringField('', { blank: true }),
|
||||
schedule: new f.SchemaField(scheduleRows),
|
||||
personalDetails: makeHtmlField(''),
|
||||
startingYear: makeIntField(2026, { min: 1900 }),
|
||||
currentYear: makeIntField(2026, { min: 1900 }),
|
||||
}),
|
||||
advancement: new f.SchemaField({
|
||||
attributePointsAvailable: makeIntField(0, { min: 0 }),
|
||||
skillPointsAvailable: makeIntField(0, { min: 0 }),
|
||||
freeTalentsAvailable: makeIntField(0, { min: 0 }), // derived
|
||||
bonusAttributePoints: makeIntField(0, { min: 0 }),
|
||||
bonusSkillPoints: makeIntField(0, { min: 0 }),
|
||||
bonusFreeTalents: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** prepareDerivedData runs after source data loads; compute formulas here. */
|
||||
override prepareDerivedData() {
|
||||
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
|
||||
const sys = this as unknown as {
|
||||
attributes: {
|
||||
body: { value: number };
|
||||
mind: { value: number };
|
||||
soul: { value: number };
|
||||
magic: { value: number; actual: number; dicePool: number };
|
||||
health: { value: number; max: number };
|
||||
mana: { value: number; max: number; maxPerSpell: number };
|
||||
zeal: { value: number; max: number };
|
||||
blood: { value: number; max: number };
|
||||
elixirTolerance: number;
|
||||
insanity: number;
|
||||
magicalArmor: { value: number; max: number; runicCounter: number };
|
||||
magicalShield: { value: number };
|
||||
physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
|
||||
initiative: number;
|
||||
};
|
||||
skills: Record<string, { value: number; defaultAttribute: AttributeKey }>;
|
||||
details: { raceId?: string; classIds?: string[] };
|
||||
advancement: {
|
||||
attributePointsAvailable: number;
|
||||
skillPointsAvailable: number;
|
||||
freeTalentsAvailable: number;
|
||||
bonusAttributePoints: number;
|
||||
bonusSkillPoints: number;
|
||||
bonusFreeTalents: number;
|
||||
};
|
||||
};
|
||||
|
||||
const a = sys.attributes;
|
||||
|
||||
a.health.max = 3 * (a.body.value + a.mind.value + a.soul.value);
|
||||
if (a.health.value > a.health.max) a.health.value = a.health.max;
|
||||
|
||||
a.zeal.max = Math.ceil(a.soul.value / 2);
|
||||
if (a.zeal.value > a.zeal.max) a.zeal.value = a.zeal.max;
|
||||
|
||||
// Compute physical armor DR from equipped gear + talents
|
||||
const items = (self.parent?.items ? Array.from(self.parent.items as Iterable<any>) : []) as any[];
|
||||
|
||||
// Calculate actual magic circle based on talents
|
||||
let magicMod = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'talent') {
|
||||
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
|
||||
const name = it.name?.toLowerCase() || '';
|
||||
if (slug === 'jednosc-z-magia' || slug === 'unity-with-magic' || name === 'jedność z magią' || name === 'unity with magic') {
|
||||
magicMod -= 1;
|
||||
} else if (slug === 'odpornosc-na-magie' || slug === 'magic-resistance' || name === 'odporność na magię' || name === 'magic resistance') {
|
||||
magicMod += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const actualMagic = a.magic.value + magicMod;
|
||||
a.magic.actual = Math.max(0, Math.min(10, actualMagic));
|
||||
|
||||
const mp = getMagicPowerEntry(a.magic.actual);
|
||||
a.magic.dicePool = mp.dicePool;
|
||||
a.mana.max = mp.manaPerRound;
|
||||
a.mana.maxPerSpell = mp.maxPerSpell;
|
||||
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
|
||||
|
||||
// Count battle-readiness stacks for initiative bonus (+2 per copy)
|
||||
let initiativeBonus = 0;
|
||||
// Count Man of Iron copies for passive armor (only applies when no physical armor is worn)
|
||||
let manOfIronStacks = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'talent') {
|
||||
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
|
||||
const name = it.name?.toLowerCase() || '';
|
||||
if (
|
||||
slug === 'battle-readiness' || slug === 'gotowosc-do-walki' ||
|
||||
name === 'gotowo\u015b\u0107 do walki' || name === 'battle readiness'
|
||||
) {
|
||||
initiativeBonus += 2;
|
||||
}
|
||||
if (
|
||||
slug === 'man-of-iron' || slug === 'czlowiek-z-zelaza' ||
|
||||
name === 'cz\u0142owiek z \u017celaza' || name === 'man of iron'
|
||||
) {
|
||||
manOfIronStacks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.initiative =
|
||||
a.mind.value +
|
||||
(sys.skills.reflex?.value ?? 0) +
|
||||
(sys.skills.perception?.value ?? 0) +
|
||||
initiativeBonus;
|
||||
|
||||
// Clamp magical armor / shield
|
||||
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
|
||||
|
||||
let armorDR = 0;
|
||||
let armorPieces = 0;
|
||||
let totalArmorCondition = 0;
|
||||
let totalArmorConditionMax = 0;
|
||||
// Compute effective DR per piece using condition scaling
|
||||
let effectiveDR = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'gear') {
|
||||
const g = it.system;
|
||||
if (!g) continue;
|
||||
if (g.equipped && g.category === 'armor') {
|
||||
const baseDR = Number(g.armor?.damageReduction ?? 0);
|
||||
const cond = Number(g.armor?.condition ?? 0);
|
||||
const condMax = Number(g.armor?.conditionMax ?? 1);
|
||||
armorDR += baseDR;
|
||||
armorPieces += 1;
|
||||
totalArmorCondition += cond;
|
||||
totalArmorConditionMax += condMax;
|
||||
// Condition-scaled DR: broken armor (cond=0) gives 0 DR
|
||||
if (condMax > 0) {
|
||||
effectiveDR += Math.floor(baseDR * (cond / condMax));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// physicalArmorBonus is stored/editable; add it to effective DR
|
||||
const paBonus = (sys as any).attributes?.physicalArmor?.bonus ?? 0;
|
||||
a.physicalArmor.max = Math.max(0, armorDR + paBonus);
|
||||
a.physicalArmor.value = Math.max(0, effectiveDR + paBonus);
|
||||
|
||||
if (armorPieces > 0) {
|
||||
a.physicalArmor.condition = totalArmorCondition;
|
||||
a.physicalArmor.conditionMax = totalArmorConditionMax;
|
||||
}
|
||||
|
||||
// Man of Iron: +1 passive armor per stack, only when wearing no physical armor
|
||||
if (manOfIronStacks > 0 && armorPieces === 0) {
|
||||
a.physicalArmor.max = Math.max(a.physicalArmor.max, manOfIronStacks);
|
||||
a.physicalArmor.value = Math.max(a.physicalArmor.value, manOfIronStacks);
|
||||
}
|
||||
|
||||
// Blood Pool (placeholder formula): max = body + soul.
|
||||
a.blood.max = a.body.value + a.soul.value;
|
||||
if (a.blood.value > a.blood.max) a.blood.value = a.blood.max;
|
||||
|
||||
// Advancement points (UUID-based race/class lookup; falls back to 0 if not set)
|
||||
const raceItem = items.find((i) => i.type === 'race');
|
||||
const classItems = items.filter((i) => i.type === 'class');
|
||||
const startingAttr = Number(raceItem?.system?.attributePoints ?? 0);
|
||||
const startingSkill = Number(raceItem?.system?.skillPoints ?? 0);
|
||||
const startingFreeTalents = raceItem?.system?.freeTalents?.length ?? 0;
|
||||
const classAttr = classItems.reduce((sum, c) => sum + Number(c.system?.attributePoints ?? 0), 0);
|
||||
const classSkill = classItems.reduce((sum, c) => sum + Number(c.system?.skillPoints ?? 0), 0);
|
||||
const spentAttr = (a.body.value - 1) + (a.mind.value - 1) + (a.soul.value - 1) + a.magic.value;
|
||||
const spentSkill = Object.values(sys.skills).reduce((sum, s) => sum + (s.value ?? 0), 0);
|
||||
const spentFreeTalents = items.filter((i) => i.type === 'talent').length;
|
||||
sys.advancement.attributePointsAvailable = Math.max(0, startingAttr + classAttr + (sys.advancement.bonusAttributePoints ?? 0) - spentAttr);
|
||||
sys.advancement.skillPointsAvailable = Math.max(0, startingSkill + classSkill + (sys.advancement.bonusSkillPoints ?? 0) - spentSkill);
|
||||
sys.advancement.freeTalentsAvailable = Math.max(0, startingFreeTalents + (sys.advancement.bonusFreeTalents ?? 0) - spentFreeTalents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration shim: map legacy `attributes.armor` (single number) to new
|
||||
* `physicalArmor.value` so existing characters do not crash on load.
|
||||
*/
|
||||
_initializeSource(data: any, options: any): any {
|
||||
const initialized = super._initializeSource(data, options);
|
||||
if (initialized?.attributes && typeof initialized.attributes.armor === 'number') {
|
||||
const legacy = initialized.attributes.armor;
|
||||
if (!initialized.attributes.physicalArmor) {
|
||||
initialized.attributes.physicalArmor = { value: legacy, condition: 1, conditionMax: 1 };
|
||||
}
|
||||
delete initialized.attributes.armor;
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeHtmlField,
|
||||
makeValueMaxField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { getMagicPowerEntry } from '../constants';
|
||||
|
||||
/**
|
||||
* NPC / Creature data model.
|
||||
* Looser than character (no derived health formula — set directly).
|
||||
*/
|
||||
export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
|
||||
const attackSchema = () => new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
damage: makeStringField(''),
|
||||
damageType: makeStringField('physical'),
|
||||
bonus: makeIntField(0),
|
||||
});
|
||||
|
||||
const namedEntry = () => new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
});
|
||||
|
||||
return {
|
||||
attributes: new f.SchemaField({
|
||||
body: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
mind: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
soul: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
magic: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0, max: 10 }),
|
||||
actual: makeIntField(0, { min: 0, max: 10 }), // derived
|
||||
}),
|
||||
mana: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
max: makeIntField(0, { min: 0 }),
|
||||
maxPerSpell: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
zeal: makeValueMaxField(0, 0),
|
||||
blood: makeValueMaxField(0, 0),
|
||||
health: makeValueMaxField(1, 1),
|
||||
magicalArmor: makeValueMaxField(0, 0),
|
||||
magicalShield: makeValueMaxField(0, 0),
|
||||
physicalArmor: new f.SchemaField({ value: makeIntField(0, { min: 0 }) }),
|
||||
speed: makeStringField('5m'),
|
||||
}),
|
||||
details: new f.SchemaField({
|
||||
type: makeStringField(''),
|
||||
size: makeStringField('medium'),
|
||||
alignment: makeStringField(''),
|
||||
xp: makeIntField(0, { min: 0 }),
|
||||
senses: makeStringField(''),
|
||||
languages: makeStringField(''),
|
||||
customEquipment: makeHtmlField(''),
|
||||
money: makeIntField(0, { min: 0 }),
|
||||
startingYear: makeIntField(2026, { min: 1900 }),
|
||||
currentYear: makeIntField(2026, { min: 1900 }),
|
||||
}),
|
||||
combat: new f.SchemaField({
|
||||
attacks: makeArrayField(attackSchema()),
|
||||
specialAbilities: makeArrayField(namedEntry()),
|
||||
reactions: makeArrayField(namedEntry()),
|
||||
legendaryActions: makeArrayField(namedEntry()),
|
||||
}),
|
||||
defenses: new f.SchemaField({
|
||||
savingThrows: makeArrayField(new f.StringField({ blank: false })),
|
||||
damageResistances: makeArrayField(new f.StringField({ blank: false })),
|
||||
damageImmunities: makeArrayField(new f.StringField({ blank: false })),
|
||||
conditionImmunities: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
lore: new f.SchemaField({
|
||||
description: makeHtmlField(''),
|
||||
habitat: makeStringField(''),
|
||||
behavior: makeStringField(''),
|
||||
history: makeStringField(''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
override prepareDerivedData() {
|
||||
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
|
||||
const sys = this as unknown as {
|
||||
attributes: {
|
||||
magic: { value: number; actual: number };
|
||||
mana: { value: number; max: number; maxPerSpell: number };
|
||||
health: { value: number; max: number };
|
||||
magicalArmor: { value: number; max: number };
|
||||
magicalShield: { value: number; max: number };
|
||||
};
|
||||
};
|
||||
const a = sys.attributes;
|
||||
|
||||
// Compute magic.actual based on NPC talents
|
||||
const items = (self.parent?.items ? Array.from(self.parent.items as Iterable<any>) : []) as any[];
|
||||
let magicMod = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'talent') {
|
||||
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
|
||||
const name = it.name?.toLowerCase() || '';
|
||||
if (slug === 'jednosc-z-magia' || slug === 'unity-with-magic' || name === 'jedność z magią' || name === 'unity with magic') {
|
||||
magicMod -= 1;
|
||||
} else if (slug === 'odpornosc-na-magie' || slug === 'magic-resistance' || name === 'odporność na magię' || name === 'magic resistance') {
|
||||
magicMod += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const actualMagic = a.magic.value + magicMod;
|
||||
a.magic.actual = Math.max(0, Math.min(10, actualMagic));
|
||||
|
||||
// Derive NPC mana from MAGIC_POWER_TABLE
|
||||
const mp = getMagicPowerEntry(a.magic.actual);
|
||||
a.mana.max = mp.manaPerRound;
|
||||
a.mana.maxPerSpell = mp.maxPerSpell;
|
||||
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
|
||||
|
||||
if (a.health.value > a.health.max) a.health.value = a.health.max;
|
||||
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
|
||||
if (a.magicalShield.value > a.magicalShield.max) a.magicalShield.value = a.magicalShield.max;
|
||||
}
|
||||
|
||||
/** Migrate legacy `attributes.armor` → `physicalArmor.value` */
|
||||
_initializeSource(data: any, options: any): any {
|
||||
const initialized = super._initializeSource(data, options);
|
||||
if (initialized?.attributes && typeof initialized.attributes.armor === 'number') {
|
||||
const legacy = initialized.attributes.armor;
|
||||
if (!initialized.attributes.physicalArmor) {
|
||||
initialized.attributes.physicalArmor = { value: legacy };
|
||||
}
|
||||
delete initialized.attributes.armor;
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Shared DataModel field helpers. Foundry's data model field constructors
|
||||
* live under foundry.data.fields at runtime; we re-export local aliases
|
||||
* to keep the data model files terse.
|
||||
*/
|
||||
|
||||
// Foundry exposes fields globally; declared via fvtt-types.
|
||||
export const fields = (): typeof foundry.data.fields => foundry.data.fields;
|
||||
|
||||
export function makeIntField(initial = 0, options: Partial<{ min: number; max: number; nullable: boolean }> = {}) {
|
||||
const f = fields();
|
||||
return new f.NumberField({
|
||||
required: true,
|
||||
nullable: options.nullable ?? false,
|
||||
integer: true,
|
||||
initial,
|
||||
min: options.min,
|
||||
max: options.max,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeStringField(initial = '', options: Partial<{ blank: boolean; choices: readonly string[] }> = {}) {
|
||||
const f = fields();
|
||||
return new f.StringField({
|
||||
required: true,
|
||||
blank: options.blank ?? true,
|
||||
initial,
|
||||
choices: options.choices as string[] | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeBoolField(initial = false) {
|
||||
const f = fields();
|
||||
return new f.BooleanField({ required: true, initial });
|
||||
}
|
||||
|
||||
export function makeHtmlField(initial = '') {
|
||||
const f = fields();
|
||||
return new f.HTMLField({ required: true, blank: true, initial });
|
||||
}
|
||||
|
||||
export function makeArrayField(element: foundry.data.fields.DataField.Any) {
|
||||
const f = fields();
|
||||
return new f.ArrayField(element);
|
||||
}
|
||||
|
||||
export function makeValueMaxField(initial = 0, max = 0) {
|
||||
const f = fields();
|
||||
return new f.SchemaField({
|
||||
value: makeIntField(initial, { min: 0 }),
|
||||
max: makeIntField(max, { min: 0 }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { makeStringField, makeHtmlField, makeIntField } from './fields';
|
||||
import { ABILITY_TYPES } from '../constants';
|
||||
|
||||
export class AbilityData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
type: makeStringField('passive', {
|
||||
blank: false,
|
||||
choices: ABILITY_TYPES as unknown as readonly string[],
|
||||
}),
|
||||
prerequisite: makeStringField(''),
|
||||
cost: makeStringField(''),
|
||||
manaCost: makeIntField(0, { min: 0 }),
|
||||
zealCost: makeIntField(0, { min: 0 }),
|
||||
description: makeHtmlField(''),
|
||||
mechanics: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { makeIntField, makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
|
||||
|
||||
export class ClassData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
year: makeIntField(1, { min: 1, max: 4 }),
|
||||
attributePoints: makeIntField(0, { min: 0 }),
|
||||
skillPoints: makeIntField(0, { min: 0 }),
|
||||
talents: makeArrayField(new f.StringField({ blank: false })),
|
||||
spells: makeArrayField(new f.StringField({ blank: false })),
|
||||
features: makeArrayField(
|
||||
new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
}),
|
||||
),
|
||||
description: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
|
||||
import { ATTRIBUTES } from '../constants';
|
||||
|
||||
export class DisciplineData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
color: makeStringField(''),
|
||||
suggestedAttribute: makeStringField('magic', {
|
||||
blank: false,
|
||||
choices: ATTRIBUTES as unknown as readonly string[],
|
||||
}),
|
||||
suggestedSkills: makeArrayField(new f.StringField({ blank: false })),
|
||||
passiveAbility: makeHtmlField(''),
|
||||
availableSpells: makeArrayField(new f.StringField({ blank: false })),
|
||||
description: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeBoolField,
|
||||
makeHtmlField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants';
|
||||
|
||||
/**
|
||||
* Unified Gear item — discriminated by `category`.
|
||||
* Weapon/armor/equipment all share base fields; specialised fields
|
||||
* are scoped under `weapon` and `armor` sub-schemas.
|
||||
*/
|
||||
export class GearData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
category: makeStringField('equipment', {
|
||||
blank: false,
|
||||
choices: GEAR_CATEGORIES as unknown as readonly string[],
|
||||
}),
|
||||
rarity: makeStringField('common', {
|
||||
blank: false,
|
||||
choices: RARITIES as unknown as readonly string[],
|
||||
}),
|
||||
quantity: makeIntField(1, { min: 0 }),
|
||||
weight: makeIntField(0, { min: 0 }),
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
equipped: makeBoolField(false),
|
||||
damageReductionBonus: makeIntField(0, { min: 0 }),
|
||||
description: makeHtmlField(''),
|
||||
|
||||
weapon: new f.SchemaField({
|
||||
damage: makeStringField(''),
|
||||
damageType: makeStringField('physical', {
|
||||
blank: false,
|
||||
choices: DAMAGE_TYPES as unknown as readonly string[],
|
||||
}),
|
||||
properties: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
|
||||
armor: new f.SchemaField({
|
||||
damageReduction: makeIntField(0, { min: 0 }),
|
||||
condition: makeIntField(1, { min: 0 }),
|
||||
conditionMax: makeIntField(1, { min: 0 }),
|
||||
armorType: makeStringField('light', {
|
||||
blank: false,
|
||||
choices: ARMOR_TYPES as unknown as readonly string[],
|
||||
}),
|
||||
stealthDisadvantage: makeBoolField(false),
|
||||
strengthRequirement: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Migrate legacy `armor.armorClass` → `armor.damageReduction`. */
|
||||
_initializeSource(data: any, options: any): any {
|
||||
const initialized = super._initializeSource(data, options);
|
||||
if (initialized?.armor && typeof initialized.armor.armorClass === 'number' && initialized.armor.damageReduction == null) {
|
||||
initialized.armor.damageReduction = initialized.armor.armorClass;
|
||||
delete initialized.armor.armorClass;
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { makeIntField, makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
|
||||
|
||||
export class RaceData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
availableDisciplines: makeArrayField(new f.StringField({ blank: false })),
|
||||
attributePoints: makeIntField(0, { min: 0 }),
|
||||
skillPoints: makeIntField(0, { min: 0 }),
|
||||
freeTalents: makeArrayField(new f.StringField({ blank: false })),
|
||||
racialAbilities: makeArrayField(
|
||||
new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
}),
|
||||
),
|
||||
physicalDescription: makeStringField(''),
|
||||
description: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeBoolField,
|
||||
makeHtmlField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import {
|
||||
CASTING_MODES,
|
||||
SPELL_SCHOOLS,
|
||||
SACRED_DEITIES,
|
||||
SOURCE_BOOKS,
|
||||
AOE_SHAPES,
|
||||
SPELL_DAMAGE_TYPES,
|
||||
TRIGGER_EVENTS,
|
||||
TS_DEFAULT_THRESHOLD,
|
||||
TS_DEFAULT_REQUIRED,
|
||||
} from '../constants';
|
||||
|
||||
export class SpellData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
// Identification
|
||||
circle: makeIntField(1, { min: 0, max: 9 }),
|
||||
school: makeStringField('', { blank: true, choices: SPELL_SCHOOLS as unknown as readonly string[] }),
|
||||
discipline: makeStringField(''),
|
||||
deity: makeStringField('', { blank: true, choices: SACRED_DEITIES as unknown as readonly string[] }),
|
||||
sourceBook: makeStringField('', { blank: true, choices: SOURCE_BOOKS as unknown as readonly string[] }),
|
||||
|
||||
// Casting mode + resources
|
||||
castingMode: makeStringField('standard', { blank: false, choices: CASTING_MODES as unknown as readonly string[] }),
|
||||
manaCost: makeIntField(1, { min: 0 }),
|
||||
bloodCost: makeIntField(0, { min: 0 }),
|
||||
|
||||
// Difficulty
|
||||
difficulty: new f.SchemaField({
|
||||
threshold: makeIntField(TS_DEFAULT_THRESHOLD, { min: 2, max: 6 }),
|
||||
successes: makeIntField(TS_DEFAULT_REQUIRED, { min: 1, max: 10 }),
|
||||
}),
|
||||
|
||||
// Components
|
||||
components: new f.SchemaField({
|
||||
verbal: makeBoolField(false),
|
||||
somatic: makeBoolField(false),
|
||||
material: makeStringField(''),
|
||||
symbols: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
|
||||
// Timing & geometry
|
||||
castingTime: makeStringField('1 akcja'),
|
||||
castingTimeRounds: makeIntField(0, { min: 0 }),
|
||||
castingTimeMinutes: makeIntField(0, { min: 0 }),
|
||||
range: makeStringField(''),
|
||||
targets: makeStringField(''),
|
||||
duration: makeStringField(''),
|
||||
areaOfEffect: new f.SchemaField({
|
||||
shape: makeStringField('point', { blank: false, choices: AOE_SHAPES as unknown as readonly string[] }),
|
||||
x: makeIntField(0, { min: 0 }),
|
||||
y: makeIntField(0, { min: 0 }),
|
||||
unit: makeStringField('m'),
|
||||
}),
|
||||
|
||||
// Damage
|
||||
damageBase: makeStringField(''),
|
||||
damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }),
|
||||
ignoresArmor: makeBoolField(false),
|
||||
|
||||
// Status effects on hit
|
||||
statusEffects: makeArrayField(new f.StringField({ blank: false })),
|
||||
saveAttribute: makeStringField(''),
|
||||
saveSkill: makeStringField(''),
|
||||
|
||||
// Requirements
|
||||
requirements: new f.SchemaField({
|
||||
race: makeArrayField(new f.StringField({ blank: false })),
|
||||
talent: makeArrayField(new f.StringField({ blank: false })),
|
||||
discipline: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
|
||||
// Flags
|
||||
isSuperspell: makeBoolField(false),
|
||||
requiresGroupCast: makeBoolField(false),
|
||||
minCasters: makeIntField(1, { min: 1 }),
|
||||
nonCombatOnly: makeBoolField(false),
|
||||
|
||||
// Overcast — structured
|
||||
overcastOptions: makeArrayField(new f.SchemaField({
|
||||
description: makeStringField(''),
|
||||
manaPerStep: makeIntField(1, { min: 0 }),
|
||||
})),
|
||||
|
||||
// Reactive triggers
|
||||
triggers: makeArrayField(new f.SchemaField({
|
||||
event: makeStringField('killWithWeapon', { blank: false, choices: TRIGGER_EVENTS as unknown as readonly string[] }),
|
||||
effect: makeStringField(''),
|
||||
})),
|
||||
|
||||
// Variable success summons (Przywołanie Istoty z Otchłani)
|
||||
variableSuccesses: makeArrayField(new f.SchemaField({
|
||||
label: makeStringField(''),
|
||||
successes: makeIntField(1, { min: 1, max: 10 }),
|
||||
})),
|
||||
|
||||
// Description
|
||||
description: makeHtmlField(''),
|
||||
higherCircles: makeHtmlField(''), // legacy — superseded by overcastOptions
|
||||
|
||||
// Legacy fields (kept for migration; will be removed in a later release)
|
||||
overcasting: makeStringField(''),
|
||||
complexityLevel: makeIntField(0, { min: 0, max: 10 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { makeIntField, makeStringField, makeHtmlField, makeBoolField, fields } from './fields';
|
||||
|
||||
export class TalentData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
requirements: new f.SchemaField({
|
||||
race: makeStringField(''),
|
||||
attribute: makeStringField(''),
|
||||
skill: makeStringField(''),
|
||||
talent: makeStringField(''),
|
||||
title: makeStringField(''),
|
||||
discipline: makeStringField(''),
|
||||
}),
|
||||
multiSelect: makeBoolField(false),
|
||||
cost: makeStringField(''),
|
||||
damageReductionBonus: makeIntField(0, { min: 0 }),
|
||||
description: makeHtmlField(''),
|
||||
effect: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Comprehensive cast dialog. Replaces the old `askManaSpent` for spell-cast
|
||||
* actions on the character/NPC sheet. Returns null if cancelled.
|
||||
*
|
||||
* Inputs adapt to spell mode:
|
||||
* - standard: mana
|
||||
* - sacred: zeal (+ optional Hekate hybrid toggle if witch symbols set)
|
||||
* - witch: mana
|
||||
* - blood: mana + blood
|
||||
*
|
||||
* Plus GM bypass checkboxes for super/non-combat warnings.
|
||||
*/
|
||||
|
||||
import type { CastOptions } from '../logic/spell-cast';
|
||||
|
||||
interface SpellForDialog {
|
||||
name: string;
|
||||
system: {
|
||||
castingMode: 'standard' | 'sacred' | 'witch' | 'blood';
|
||||
manaCost: number;
|
||||
bloodCost?: number;
|
||||
isSuperspell?: boolean;
|
||||
nonCombatOnly?: boolean;
|
||||
requiresGroupCast?: boolean;
|
||||
minCasters?: number;
|
||||
components?: { symbols?: string[] };
|
||||
variableSuccesses?: Array<{ label: string; successes: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ActorForDialog {
|
||||
system: {
|
||||
attributes: {
|
||||
mana: { value: number; maxPerSpell: number };
|
||||
zeal: { value: number; max: number };
|
||||
blood?: { value: number; max: number };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialog): Promise<CastOptions | null> {
|
||||
const mode = spell.system.castingMode ?? 'standard';
|
||||
const a = actor.system.attributes;
|
||||
const baseMana = Math.max(0, spell.system.manaCost ?? 0);
|
||||
const baseBlood = Math.max(0, spell.system.bloodCost ?? 0);
|
||||
const baseZeal = mode === 'sacred' ? 1 : 0;
|
||||
|
||||
const showHekate = mode === 'sacred' && (spell.system.components?.symbols?.length ?? 0) > 0;
|
||||
const showSuper = !!spell.system.isSuperspell;
|
||||
const showNonCombat = !!spell.system.nonCombatOnly;
|
||||
const showGroup = !!spell.system.requiresGroupCast;
|
||||
|
||||
const fields: string[] = [];
|
||||
|
||||
if (mode === 'standard' || mode === 'witch' || mode === 'blood') {
|
||||
const max = a.mana.value;
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.spellCast.manaSpent')} (max ${max})</label>
|
||||
<input type="number" name="manaSpent" value="${baseMana}" min="${baseMana}" max="${max}" step="${baseMana > 0 ? baseMana : 1}"/>
|
||||
<small>${game.i18n.localize('HBM.resources.maxManaPerSpell')}: ${a.mana.maxPerSpell}</small>
|
||||
</div>`);
|
||||
}
|
||||
if (mode === 'sacred') {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.spellCast.zealSpent')} (max ${a.zeal.value})</label>
|
||||
<input type="number" name="zealSpent" value="${baseZeal}" min="1" max="${a.zeal.value}"/>
|
||||
</div>`);
|
||||
}
|
||||
if (mode === 'blood' && a.blood) {
|
||||
const max = a.blood.value;
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.spellCast.bloodSpent')} (max ${max})</label>
|
||||
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
|
||||
</div>`);
|
||||
}
|
||||
if (showHekate) {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="hekateMode"/> ${game.i18n.localize('HBM.spellCast.hekateMode')}</label>
|
||||
</div>`);
|
||||
}
|
||||
if (showSuper) {
|
||||
fields.push(`
|
||||
<div class="form-group warn">
|
||||
<label><input type="checkbox" name="bypassSuperspellWarning"/> ${game.i18n.localize('HBM.spellCast.bypassSuperspell')}</label>
|
||||
</div>`);
|
||||
}
|
||||
if (showNonCombat) {
|
||||
fields.push(`
|
||||
<div class="form-group warn">
|
||||
<label><input type="checkbox" name="bypassNonCombatBlock"/> ${game.i18n.localize('HBM.spellCast.bypassNonCombatBlock')}</label>
|
||||
</div>`);
|
||||
}
|
||||
if (showGroup) {
|
||||
// Try to populate from canvas tokens (excluding the caster).
|
||||
const canvasAny = (canvas as unknown as { tokens?: { placeables: Array<{ id: string; name: string; actor?: { id: string; name: string } }> } } | undefined);
|
||||
const tokens = canvasAny?.tokens?.placeables ?? [];
|
||||
const candidates = tokens
|
||||
.filter((t) => t.actor)
|
||||
.map((t) => ({ id: t.actor!.id, name: t.actor!.name }));
|
||||
if (candidates.length > 0) {
|
||||
const checkboxes = candidates.map((c) =>
|
||||
`<label class="caster-option"><input type="checkbox" name="groupCaster_${c.id}" value="${c.id}"/> ${c.name}</label>`
|
||||
).join('');
|
||||
fields.push(`
|
||||
<div class="form-group group-casters">
|
||||
<label>${game.i18n.localize('HBM.spellCast.groupCastersFromCanvas')} (${game.i18n.localize('HBM.spell.minCasters')}: ${spell.system.minCasters ?? 1})</label>
|
||||
<div class="caster-list">${checkboxes}</div>
|
||||
</div>`);
|
||||
} else {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.spellCast.groupCasters')} (${game.i18n.localize('HBM.spell.minCasters')}: ${spell.system.minCasters ?? 1})</label>
|
||||
<input type="text" name="groupCasters" placeholder="actorId1, actorId2"/>
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
// Variable-success picker (e.g. Przywołanie Istoty z Otchłani)
|
||||
const variants = spell.system.variableSuccesses ?? [];
|
||||
if (variants.length > 0) {
|
||||
const radios = variants.map((v, i) =>
|
||||
`<label class="variant-option"><input type="radio" name="variantIdx" value="${i}" ${i === 0 ? 'checked' : ''}/> ${v.label} <small>(${v.successes} sukcesów)</small></label>`
|
||||
).join('');
|
||||
fields.push(`
|
||||
<div class="form-group variable-successes">
|
||||
<label>${game.i18n.localize('HBM.spellCast.variableSuccesses')}</label>
|
||||
<div class="variant-list">${radios}</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
new foundry.appv1.api.Dialog({
|
||||
title: `${game.i18n.localize('HBM.spell.cast')}: ${spell.name}`,
|
||||
content: `<form class="hbm-dialog cast-dialog">${fields.join('')}</form>`,
|
||||
buttons: {
|
||||
cast: {
|
||||
icon: '<i class="fas fa-hat-wizard"></i>',
|
||||
label: game.i18n.localize('HBM.spell.cast'),
|
||||
callback: (html: any) => {
|
||||
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
|
||||
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
|
||||
const opts: CastOptions = {};
|
||||
if (fd.manaSpent != null) {
|
||||
let spent = Number(fd.manaSpent) || baseMana;
|
||||
if (baseMana > 0) {
|
||||
const remainder = spent % baseMana;
|
||||
if (remainder !== 0) {
|
||||
spent = Math.round(spent / baseMana) * baseMana;
|
||||
}
|
||||
}
|
||||
opts.manaSpent = Math.max(baseMana, spent);
|
||||
}
|
||||
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.hekateMode) opts.hekateMode = 'witch';
|
||||
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
|
||||
if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true;
|
||||
const groupRaw = String(fd.groupCasters ?? '').trim();
|
||||
if (groupRaw) opts.groupCasters = groupRaw.split(/[,\s]+/).filter(Boolean);
|
||||
// Collect checkbox-form group casters
|
||||
const checkedCasters: string[] = [];
|
||||
for (const key of Object.keys(fd)) {
|
||||
if (key.startsWith('groupCaster_') && fd[key]) {
|
||||
const id = String(fd[key]);
|
||||
if (id) checkedCasters.push(id);
|
||||
}
|
||||
}
|
||||
if (checkedCasters.length > 0) {
|
||||
opts.groupCasters = [...(opts.groupCasters ?? []), ...checkedCasters];
|
||||
}
|
||||
// Variable-success override
|
||||
if (variants.length > 0 && fd.variantIdx != null) {
|
||||
const idx = Number(fd.variantIdx);
|
||||
const variant = variants[idx];
|
||||
if (variant) opts.requiredOverride = variant.successes;
|
||||
}
|
||||
resolve(opts);
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
icon: '<i class="fas fa-times"></i>',
|
||||
label: game.i18n.localize('HBM.ui.cancel') ?? 'Anuluj',
|
||||
callback: () => resolve(null),
|
||||
},
|
||||
},
|
||||
default: 'cast',
|
||||
close: () => resolve(null),
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Apply-damage dialog: collects amount, type, and bypass flags. Returns
|
||||
* an `ApplyDamageOptions` object suitable for passing into `applyDamage`,
|
||||
* or `null` if cancelled.
|
||||
*/
|
||||
|
||||
import { ApplyDamageOptions } from '../logic/damage';
|
||||
import { DamageType } from '../constants';
|
||||
|
||||
interface ActorLike { name: string; system: any }
|
||||
|
||||
export async function askApplyDamage(actor: ActorLike, presetAmount = 0): Promise<ApplyDamageOptions | null> {
|
||||
const ma = actor.system?.attributes?.magicalArmor?.value ?? 0;
|
||||
const ms = actor.system?.attributes?.magicalShield?.value ?? 0;
|
||||
const pa = actor.system?.attributes?.physicalArmor?.value ?? 0;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
new foundry.appv1.api.Dialog({
|
||||
title: `${game.i18n.localize('HBM.damage.title')} — ${actor.name}`,
|
||||
content: `
|
||||
<form class="hbm-dialog">
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.damage.amount')}</label>
|
||||
<input type="number" name="amount" value="${presetAmount}" min="0"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.damage.type')}</label>
|
||||
<select name="type">
|
||||
<option value="physical">${game.i18n.localize('HBM.gear.physical')}</option>
|
||||
<option value="magical">${game.i18n.localize('HBM.gear.magical')}</option>
|
||||
<option value="environmental">${game.i18n.localize('HBM.gear.environmental')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">
|
||||
${game.i18n.localize('HBM.resources.magicalArmor')}: <strong>${ma}</strong> ·
|
||||
${game.i18n.localize('HBM.resources.magicalShield')}: <strong>${ms}</strong> ·
|
||||
${game.i18n.localize('HBM.resources.physicalArmor')}: <strong>${pa}</strong>
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="ignoreMagicalArmor"/> ${game.i18n.localize('HBM.damage.ignoreMagicalArmor')}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="ignoreMagicalShield"/> ${game.i18n.localize('HBM.damage.ignoreMagicalShield')}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="ignorePhysicalArmor"/> ${game.i18n.localize('HBM.damage.ignorePhysicalArmor')}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="damageArmor"/> ${game.i18n.localize('HBM.damage.damageArmor')}</label>
|
||||
</div>
|
||||
</form>`,
|
||||
buttons: {
|
||||
apply: {
|
||||
icon: '<i class="fas fa-burst"></i>',
|
||||
label: game.i18n.localize('HBM.damage.apply'),
|
||||
callback: (html: any) => {
|
||||
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
|
||||
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
|
||||
resolve({
|
||||
amount: Math.max(0, Number(fd['amount']) || 0),
|
||||
type: (fd['type'] as DamageType) ?? 'physical',
|
||||
ignoreMagicalArmor: Boolean(fd['ignoreMagicalArmor']),
|
||||
ignoreMagicalShield: Boolean(fd['ignoreMagicalShield']),
|
||||
ignorePhysicalArmor: Boolean(fd['ignorePhysicalArmor']),
|
||||
damageArmor: Boolean(fd['damageArmor']),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
default: 'apply',
|
||||
close: () => resolve(null),
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Active-Effect-driven roll modifier collection.
|
||||
* Reads `flags.hbm.*` metadata from the actor's currently-applied effects
|
||||
* (`actor.appliedEffects` in v13) and aggregates threshold steps + blocked tags.
|
||||
*/
|
||||
|
||||
import { SkillTag } from '../constants';
|
||||
|
||||
export interface RollModifiers {
|
||||
thresholdSteps: number;
|
||||
requiredSteps: number;
|
||||
/** First blocked condition encountered, for friendly error message. */
|
||||
blockedBy?: { conditionLabel: string; tag: SkillTag };
|
||||
}
|
||||
|
||||
export function collectRollModifiers(actor: any, tags: readonly SkillTag[]): RollModifiers {
|
||||
const result: RollModifiers = { thresholdSteps: 0, requiredSteps: 0 };
|
||||
const effects = (actor?.appliedEffects ?? actor?.effects ?? []) as Iterable<any>;
|
||||
for (const ef of effects) {
|
||||
if (ef?.disabled) continue;
|
||||
const hbm = ef?.flags?.hbm;
|
||||
if (!hbm) continue;
|
||||
|
||||
// Threshold step modifiers — keys may be tag names or 'all'
|
||||
const ts = hbm.thresholdSteps as Record<string, number> | undefined;
|
||||
if (ts && typeof ts === 'object') {
|
||||
if (typeof ts['all'] === 'number') result.thresholdSteps += ts['all'];
|
||||
for (const t of tags) {
|
||||
if (typeof ts[t] === 'number') result.thresholdSteps += ts[t]!;
|
||||
}
|
||||
}
|
||||
if (typeof hbm.requiredSteps === 'number') result.requiredSteps += hbm.requiredSteps;
|
||||
|
||||
// Blocked tags
|
||||
const blocked = (hbm.blocksTags ?? []) as SkillTag[];
|
||||
if (Array.isArray(blocked) && !result.blockedBy) {
|
||||
for (const t of tags) {
|
||||
if (blocked.includes(t)) {
|
||||
result.blockedBy = { conditionLabel: ef?.name ?? ef?.label ?? '?', tag: t };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class RollBlockedError extends Error {
|
||||
constructor(public conditionLabel: string, public tag: SkillTag) {
|
||||
super(`Roll blocked by ${conditionLabel} (${tag})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Roll helpers — convenience wrappers around HbmTSRoll bound to actor data.
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from './ts-roll';
|
||||
import { collectRollModifiers, RollBlockedError } from './effect-mods';
|
||||
import { ATTRIBUTES, AttributeKey, SKILLS, SKILL_TAGS, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
|
||||
export interface SkillRollOptions {
|
||||
/** Override the skill's default attribute (GM allows another for a specific roll). */
|
||||
attributeOverride?: AttributeKey;
|
||||
threshold?: number;
|
||||
required?: number;
|
||||
modifier?: number;
|
||||
flavor?: string;
|
||||
speaker?: ChatMessage.SpeakerData;
|
||||
}
|
||||
|
||||
export interface ActorLike {
|
||||
name: string;
|
||||
system: {
|
||||
attributes: Record<AttributeKey, { value: number } | { value: number; dicePool?: number }>;
|
||||
skills?: Record<string, { value: number; defaultAttribute: AttributeKey }>;
|
||||
};
|
||||
}
|
||||
|
||||
function getAttributeValue(actor: ActorLike, key: AttributeKey): number {
|
||||
const a = actor.system.attributes[key];
|
||||
return a?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function rollSkill(
|
||||
actor: ActorLike,
|
||||
skillKey: string,
|
||||
opts: SkillRollOptions = {},
|
||||
): Promise<HbmTSRoll> {
|
||||
const skill = actor.system.skills?.[skillKey];
|
||||
if (!skill) throw new Error(`Unknown skill: ${skillKey}`);
|
||||
const attrKey = opts.attributeOverride ?? skill.defaultAttribute ?? SKILLS[skillKey] ?? 'mind';
|
||||
if (!ATTRIBUTES.includes(attrKey)) throw new Error(`Invalid attribute: ${String(attrKey)}`);
|
||||
|
||||
const tags = SKILL_TAGS[skillKey] ?? [];
|
||||
const mods = collectRollModifiers(actor, tags);
|
||||
if (mods.blockedBy) {
|
||||
const msg = game.i18n.format('HBM.roll.blocked', { reason: mods.blockedBy.conditionLabel });
|
||||
(ui as any).notifications?.warn(msg);
|
||||
throw new RollBlockedError(mods.blockedBy.conditionLabel, mods.blockedBy.tag);
|
||||
}
|
||||
|
||||
const pool = getAttributeValue(actor, attrKey) + (skill.value ?? 0);
|
||||
|
||||
const flavor = opts.flavor ??
|
||||
game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) });
|
||||
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: opts.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: opts.required ?? TS_DEFAULT_REQUIRED,
|
||||
modifier: opts.modifier ?? 0,
|
||||
thresholdSteps: mods.thresholdSteps,
|
||||
requiredSteps: mods.requiredSteps,
|
||||
flavor,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({ flavor, speaker: opts.speaker });
|
||||
return roll;
|
||||
}
|
||||
|
||||
export async function rollAttribute(
|
||||
actor: ActorLike,
|
||||
attrKey: AttributeKey,
|
||||
opts: Omit<SkillRollOptions, 'attributeOverride'> = {},
|
||||
): Promise<HbmTSRoll> {
|
||||
const flavor = opts.flavor ??
|
||||
game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attrKey}`) });
|
||||
|
||||
const mods = collectRollModifiers(actor, []);
|
||||
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool: getAttributeValue(actor, attrKey),
|
||||
threshold: opts.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: opts.required ?? TS_DEFAULT_REQUIRED,
|
||||
modifier: opts.modifier ?? 0,
|
||||
thresholdSteps: mods.thresholdSteps,
|
||||
requiredSteps: mods.requiredSteps,
|
||||
flavor,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({ flavor, speaker: opts.speaker });
|
||||
return roll;
|
||||
}
|
||||
|
||||
export async function rollInitiative(actor: ActorLike): Promise<any> {
|
||||
const mind = getAttributeValue(actor, 'mind');
|
||||
const reflex = actor.system.skills?.reflex?.value ?? 0;
|
||||
const perception = actor.system.skills?.perception?.value ?? 0;
|
||||
const mod = mind + reflex + perception;
|
||||
const flavor = game.i18n.localize('HBM.resources.initiative');
|
||||
|
||||
const roll = new Roll('2d6 + @mod', { mod });
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({
|
||||
flavor,
|
||||
speaker: ChatMessage.getSpeaker({ actor: actor as any }),
|
||||
});
|
||||
return roll;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Interactive dialogs for TS roll configuration and spell mana input.
|
||||
* Uses the v12-compat foundry.appv1.api.Dialog which is always available in v13.
|
||||
*/
|
||||
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
|
||||
export interface RollDialogResult {
|
||||
pool: number;
|
||||
threshold: number;
|
||||
required: number;
|
||||
flavor?: string;
|
||||
}
|
||||
|
||||
/** Prompt to (optionally) adjust T and Y before rolling. Returns null if cancelled. */
|
||||
export async function askRollParams(initial: {
|
||||
pool: number;
|
||||
threshold?: number;
|
||||
required?: number;
|
||||
flavor?: string;
|
||||
}): Promise<RollDialogResult | null> {
|
||||
const t = initial.threshold ?? TS_DEFAULT_THRESHOLD;
|
||||
const y = initial.required ?? TS_DEFAULT_REQUIRED;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
new foundry.appv1.api.Dialog({
|
||||
title: game.i18n.localize('HBM.roll.configTitle'),
|
||||
content: `
|
||||
<form class="hbm-dialog">
|
||||
<p class="hint">${game.i18n.localize('HBM.roll.pool')}: <strong>${initial.pool}</strong></p>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.roll.threshold')} (2–6)</label>
|
||||
<input type="number" name="threshold" value="${t}" min="2" max="6"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.roll.required')} (1–10)</label>
|
||||
<input type="number" name="required" value="${y}" min="1" max="10"/>
|
||||
</div>
|
||||
</form>`,
|
||||
buttons: {
|
||||
roll: {
|
||||
icon: '<i class="fas fa-dice-d6"></i>',
|
||||
label: game.i18n.localize('HBM.roll.roll'),
|
||||
callback: (html: any) => {
|
||||
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
|
||||
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
|
||||
resolve({
|
||||
pool: initial.pool,
|
||||
threshold: Math.min(6, Math.max(2, Number(fd['threshold']) || t)),
|
||||
required: Math.min(10, Math.max(1, Number(fd['required']) || y)),
|
||||
flavor: initial.flavor,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
default: 'roll',
|
||||
close: () => resolve(null),
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prompt for how many mana to spend when casting a standard or witch spell. */
|
||||
export async function askManaSpent(
|
||||
spell: { name: string; system: { manaCost: number } },
|
||||
actor: { system: { attributes: { mana: { value: number; maxPerSpell: number } } } },
|
||||
): Promise<number | null> {
|
||||
const base = Math.max(0, spell.system.manaCost ?? 0);
|
||||
const current = actor.system.attributes.mana.value;
|
||||
const maxPerSpell = actor.system.attributes.mana.maxPerSpell;
|
||||
const overcastHint = base >= maxPerSpell
|
||||
? `<em class="warn"> — ${game.i18n.localize('HBM.spellCast.overcastWarning')}</em>`
|
||||
: '';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
new foundry.appv1.api.Dialog({
|
||||
title: `${game.i18n.localize('HBM.spell.cast')}: ${spell.name}`,
|
||||
content: `
|
||||
<form class="hbm-dialog">
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.spellCast.manaSpent')}</label>
|
||||
<input type="number" name="manaSpent" value="${base}" min="${base}" max="${current}"/>
|
||||
</div>
|
||||
<p class="hint">
|
||||
${game.i18n.localize('HBM.resources.mana')}: <strong>${current}</strong>
|
||||
|
|
||||
${game.i18n.localize('HBM.resources.maxManaPerSpell')}: <strong>${maxPerSpell}</strong>
|
||||
${overcastHint}
|
||||
</p>
|
||||
</form>`,
|
||||
buttons: {
|
||||
cast: {
|
||||
icon: '<i class="fas fa-hat-wizard"></i>',
|
||||
label: game.i18n.localize('HBM.spell.cast'),
|
||||
callback: (html: any) => {
|
||||
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
|
||||
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
|
||||
resolve(Math.max(base, Math.min(current, Number(fd['manaSpent']) || base)));
|
||||
},
|
||||
},
|
||||
},
|
||||
default: 'cast',
|
||||
close: () => resolve(null),
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* HbM TS (Trudność:Sukcesy) d6 dice pool roller.
|
||||
*
|
||||
* Mechanics:
|
||||
* - Roll N d6.
|
||||
* - For each die:
|
||||
* face === 1 → forced failure (even if T ≤ 1)
|
||||
* face === 6 → forced success (even if T > 6)
|
||||
* otherwise face >= T → success
|
||||
* - Roll succeeds when total successes ≥ requiredSuccesses (Y).
|
||||
*
|
||||
* Formula syntax: `ts(N, T, Y)` — e.g. `/r ts(5, 4, 2)`.
|
||||
* The N/T/Y parameters are also accepted via constructor data.
|
||||
*/
|
||||
|
||||
import {
|
||||
TS_DEFAULT_THRESHOLD,
|
||||
TS_DEFAULT_REQUIRED,
|
||||
TS_DIE_FACES,
|
||||
TS_AUTO_FAILURE_FACE,
|
||||
TS_AUTO_SUCCESS_FACE,
|
||||
} from '../constants';
|
||||
|
||||
export interface HbmTsRollData {
|
||||
pool: number;
|
||||
threshold: number;
|
||||
required: number;
|
||||
flavor?: string;
|
||||
/** Optional pre-roll modifier (added to pool). */
|
||||
modifier?: number;
|
||||
/** Step adjustment to T (positive = harder, negative = easier). Clamped to 2..6. */
|
||||
thresholdSteps?: number;
|
||||
/** Step adjustment to Y (rare; e.g. extra successes required). */
|
||||
requiredSteps?: number;
|
||||
}
|
||||
|
||||
export interface HbmTsRollResult {
|
||||
faces: number[];
|
||||
successes: number;
|
||||
required: number;
|
||||
threshold: number;
|
||||
pool: number;
|
||||
isSuccess: boolean;
|
||||
criticalSuccesses: number;
|
||||
criticalFailures: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Roll subclass. Stores the parsed TS parameters and the per-die
|
||||
* results in `this.options` for the chat renderer.
|
||||
*/
|
||||
export class HbmTSRoll extends Roll {
|
||||
static override CHAT_TEMPLATE = 'systems/hbm-rpg-v3/templates/chat/ts-roll.hbs';
|
||||
|
||||
ts: HbmTsRollResult | null = null;
|
||||
|
||||
constructor(formula: string, data: Record<string, unknown> = {}, options: Record<string, unknown> = {}) {
|
||||
super(formula, data, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a roll from N/T/Y parameters directly.
|
||||
*/
|
||||
static fromParams(params: HbmTsRollData): HbmTSRoll {
|
||||
const pool = Math.max(0, Math.floor((params.pool ?? 0) + (params.modifier ?? 0)));
|
||||
const threshold = clampThreshold((params.threshold ?? TS_DEFAULT_THRESHOLD) + (params.thresholdSteps ?? 0));
|
||||
const required = Math.max(1, Math.floor((params.required ?? TS_DEFAULT_REQUIRED) + (params.requiredSteps ?? 0)));
|
||||
const formula = `${pool}d${TS_DIE_FACES}`;
|
||||
const roll = new HbmTSRoll(formula, {}, { tsParams: { pool, threshold, required, flavor: params.flavor } });
|
||||
return roll;
|
||||
}
|
||||
|
||||
override async evaluate(options: Parameters<Roll['evaluate']>[0] = {}): Promise<this> {
|
||||
await super.evaluate(options);
|
||||
this.ts = computeTsResult(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Synchronous variant for non-async callers (Foundry exposes evaluateSync). */
|
||||
override evaluateSync(options: Parameters<Roll['evaluateSync']>[0] = {}): this {
|
||||
super.evaluateSync(options);
|
||||
this.ts = computeTsResult(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
override async render(options: Parameters<Roll['render']>[0] = {}): Promise<string> {
|
||||
if (!this.ts) this.ts = computeTsResult(this);
|
||||
const dice = this.ts.faces.map((face) => ({
|
||||
face,
|
||||
cls: faceClass(face, this.ts!.threshold),
|
||||
}));
|
||||
const data = {
|
||||
formula: this.formula,
|
||||
total: this.ts.successes,
|
||||
ts: this.ts,
|
||||
dice,
|
||||
flavor: (this.options as Record<string, unknown>).tsParams
|
||||
? ((this.options as Record<string, { flavor?: string }>).tsParams.flavor ?? options.flavor)
|
||||
: options.flavor,
|
||||
};
|
||||
return foundry.applications.handlebars.renderTemplate(HbmTSRoll.CHAT_TEMPLATE, data);
|
||||
}
|
||||
}
|
||||
|
||||
function clampThreshold(t: number): number {
|
||||
return Math.max(2, Math.min(6, Math.floor(t)));
|
||||
}
|
||||
|
||||
function faceClass(face: number, threshold: number): string {
|
||||
if (face === TS_AUTO_FAILURE_FACE) return 'die crit-failure';
|
||||
if (face === TS_AUTO_SUCCESS_FACE) return 'die crit-success';
|
||||
if (face >= threshold) return 'die success';
|
||||
return 'die failure';
|
||||
}
|
||||
|
||||
function computeTsResult(roll: Roll): HbmTsRollResult {
|
||||
const params = (roll.options as Record<string, HbmTsRollData | undefined>).tsParams;
|
||||
const threshold = clampThreshold(params?.threshold ?? TS_DEFAULT_THRESHOLD);
|
||||
const required = Math.max(1, Math.floor(params?.required ?? TS_DEFAULT_REQUIRED));
|
||||
|
||||
// Collect raw die faces from all DiceTerm results in the roll.
|
||||
const faces: number[] = [];
|
||||
for (const term of roll.terms) {
|
||||
// foundry.dice.terms.DiceTerm — has .results array with { result } entries.
|
||||
const anyTerm = term as unknown as { results?: Array<{ result: number; active?: boolean; discarded?: boolean }> };
|
||||
if (Array.isArray(anyTerm.results)) {
|
||||
for (const r of anyTerm.results) {
|
||||
if (r.discarded) continue;
|
||||
faces.push(r.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let successes = 0;
|
||||
let criticalSuccesses = 0;
|
||||
let criticalFailures = 0;
|
||||
for (const face of faces) {
|
||||
if (face === TS_AUTO_FAILURE_FACE) {
|
||||
criticalFailures += 1;
|
||||
continue;
|
||||
}
|
||||
if (face === TS_AUTO_SUCCESS_FACE) {
|
||||
criticalSuccesses += 1;
|
||||
successes += 1;
|
||||
continue;
|
||||
}
|
||||
if (face >= threshold) successes += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
faces,
|
||||
successes,
|
||||
required,
|
||||
threshold,
|
||||
pool: faces.length,
|
||||
isSuccess: successes >= required,
|
||||
criticalSuccesses,
|
||||
criticalFailures,
|
||||
};
|
||||
}
|
||||
Vendored
+193
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Permissive shims for Foundry VTT v13 globals.
|
||||
*
|
||||
* The official @league-of-foundry-developers/foundry-vtt-types package is
|
||||
* still being aligned to v13's API surface and many generic constraints are
|
||||
* over-strict for v13's actual runtime. To keep our `bun run typecheck`
|
||||
* green for our own logic, we declare the Foundry surface we touch loosely.
|
||||
*/
|
||||
|
||||
declare class Roll {
|
||||
[key: string]: any;
|
||||
constructor(formula?: string, data?: Record<string, unknown>, options?: Record<string, unknown>);
|
||||
static CHAT_TEMPLATE: string;
|
||||
formula: string;
|
||||
options: Record<string, any>;
|
||||
terms: any[];
|
||||
total: number;
|
||||
evaluate(options?: Record<string, unknown>): Promise<this>;
|
||||
evaluateSync(options?: Record<string, unknown>): this;
|
||||
render(options?: Record<string, unknown>): Promise<string>;
|
||||
toMessage(data?: Record<string, unknown>, options?: Record<string, unknown>): Promise<any>;
|
||||
}
|
||||
|
||||
declare class Combat {
|
||||
[key: string]: any;
|
||||
combatants: any[];
|
||||
combatant: any;
|
||||
}
|
||||
|
||||
declare class Combatant {
|
||||
[key: string]: any;
|
||||
actor: any;
|
||||
}
|
||||
|
||||
declare class Actor {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
declare class Item {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
declare class ActorSheet { [key: string]: any; }
|
||||
declare class ItemSheet { [key: string]: any; }
|
||||
|
||||
declare class FormDataExtended {
|
||||
constructor(form: HTMLFormElement);
|
||||
readonly object: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare const Handlebars: {
|
||||
registerHelper(name: string, fn: (...args: any[]) => any): void;
|
||||
};
|
||||
|
||||
// TextEditor is now namespaced in v13; kept as shim for backwards compat tooling.
|
||||
// Use foundry.applications.ux.TextEditor.implementation at runtime.
|
||||
|
||||
declare function fromUuid<T = unknown>(uuid: string): Promise<T | null>;
|
||||
declare class ChatMessage {
|
||||
[key: string]: any;
|
||||
static create(data: Record<string, unknown>): Promise<any>;
|
||||
static getWhisperRecipients(name: string): any[];
|
||||
static getSpeaker(data?: Record<string, unknown>): any;
|
||||
}
|
||||
|
||||
declare namespace ChatMessage {
|
||||
type SpeakerData = Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare const Hooks: {
|
||||
on(event: string, fn: (...args: any[]) => any): number;
|
||||
once(event: string, fn: (...args: any[]) => any): number;
|
||||
off(event: string, id: number | ((...args: any[]) => any)): void;
|
||||
};
|
||||
|
||||
declare const CONFIG: any;
|
||||
|
||||
declare const game: any;
|
||||
|
||||
declare const ui: any;
|
||||
|
||||
declare namespace foundry {
|
||||
namespace utils {
|
||||
function getProperty(obj: any, path: string): any;
|
||||
function setProperty(obj: any, path: string, value: any): boolean;
|
||||
function mergeObject<T = any>(original: T, other?: any, options?: any): T;
|
||||
function deepClone<T>(obj: T): T;
|
||||
function randomID(length?: number): string;
|
||||
}
|
||||
namespace abstract {
|
||||
class TypeDataModel {
|
||||
[key: string]: any;
|
||||
static defineSchema(): Record<string, any>;
|
||||
prepareDerivedData(): void;
|
||||
}
|
||||
class DataModel {
|
||||
[key: string]: any;
|
||||
}
|
||||
}
|
||||
namespace data {
|
||||
namespace fields {
|
||||
class DataField { constructor(options?: Record<string, unknown>); }
|
||||
namespace DataField { type Any = DataField; }
|
||||
class SchemaField extends DataField { constructor(fields: Record<string, any>, options?: Record<string, unknown>); }
|
||||
class StringField extends DataField {}
|
||||
class NumberField extends DataField {}
|
||||
class BooleanField extends DataField {}
|
||||
class HTMLField extends DataField {}
|
||||
class ArrayField extends DataField { constructor(element: any, options?: Record<string, unknown>); }
|
||||
class ObjectField extends DataField {}
|
||||
}
|
||||
}
|
||||
/** v12-compat layer, always available in v13 */
|
||||
namespace appv1 {
|
||||
namespace api {
|
||||
class Dialog {
|
||||
constructor(data: {
|
||||
title: string;
|
||||
content: string;
|
||||
buttons: Record<string, { icon?: string; label: string; callback?: (html: any) => void }>;
|
||||
default: string;
|
||||
close?: () => void;
|
||||
});
|
||||
render(force?: boolean): this;
|
||||
}
|
||||
}
|
||||
namespace sheets {
|
||||
class ActorSheet { [key: string]: any; }
|
||||
class ItemSheet { [key: string]: any; }
|
||||
}
|
||||
}
|
||||
namespace applications {
|
||||
namespace ux {
|
||||
const TextEditor: {
|
||||
implementation: {
|
||||
getDragEventData(event: DragEvent): Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
class FormDataExtended {
|
||||
constructor(form: HTMLFormElement);
|
||||
readonly object: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
namespace api {
|
||||
class DialogV2 {
|
||||
static wait(options: {
|
||||
title: string;
|
||||
content: string;
|
||||
buttons: Array<{ type: string; label: string; action: string; default?: boolean }>;
|
||||
rejectClose?: boolean;
|
||||
modal?: boolean;
|
||||
}): Promise<string | null>;
|
||||
static prompt(options: {
|
||||
title: string;
|
||||
content: string;
|
||||
label?: string;
|
||||
rejectClose?: boolean;
|
||||
modal?: boolean;
|
||||
ok?: { callback: (event: Event, button: any, dialog: HTMLElement) => any };
|
||||
}): Promise<any>;
|
||||
static inform(options: { title: string; content: string; rejectClose?: boolean }): Promise<void>;
|
||||
}
|
||||
class ApplicationV2 {
|
||||
[key: string]: any;
|
||||
static DEFAULT_OPTIONS: any;
|
||||
static PARTS: any;
|
||||
element: HTMLElement;
|
||||
tabGroups: Record<string, string>;
|
||||
_prepareContext(options?: any): Promise<any>;
|
||||
_preparePartContext(partId: string, context: any): Promise<any>;
|
||||
_onRender(context?: any, options?: any): void | Promise<void>;
|
||||
changeTab(tabId: string, groupId: string, options?: any): void;
|
||||
}
|
||||
function HandlebarsApplicationMixin<T>(base: T): T;
|
||||
}
|
||||
namespace sheets {
|
||||
class ActorSheetV2 extends api.ApplicationV2 { document: any; actor: any; }
|
||||
class ItemSheetV2 extends api.ApplicationV2 { document: any; item: any; }
|
||||
}
|
||||
namespace handlebars {
|
||||
function renderTemplate(path: string, data: Record<string, unknown>): Promise<string>;
|
||||
}
|
||||
}
|
||||
namespace documents {
|
||||
namespace collections {
|
||||
const Actors: { unregisterSheet(scope: string, cls: any): void; registerSheet(scope: string, cls: any, options: Record<string, unknown>): void };
|
||||
const Items: { unregisterSheet(scope: string, cls: any): void; registerSheet(scope: string, cls: any, options: Record<string, unknown>): void };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vite define replacement
|
||||
declare const __SYSTEM_ID__: string;
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Homebrew Magic: RPG v3 — Foundry VTT system entry point.
|
||||
*
|
||||
* All internal identifiers are English. Polish is reserved for user-facing
|
||||
* labels supplied via `lang/pl.json` (the canonical localisation).
|
||||
*/
|
||||
|
||||
declare const __SYSTEM_ID__: string;
|
||||
declare const Babele: any;
|
||||
|
||||
import { HbmTSRoll } from './dice/ts-roll';
|
||||
import { CharacterData } from './data/actor-character';
|
||||
import { NpcData } from './data/actor-npc';
|
||||
import { SpellData } from './data/item-spell';
|
||||
import { GearData } from './data/item-gear';
|
||||
import { AbilityData } from './data/item-ability';
|
||||
import { ClassData } from './data/item-class';
|
||||
import { RaceData } from './data/item-race';
|
||||
import { DisciplineData } from './data/item-discipline';
|
||||
import { TalentData } from './data/item-talent';
|
||||
import { CharacterSheet } from './sheets/character-sheet';
|
||||
import { NpcSheet } from './sheets/npc-sheet';
|
||||
import { HbmItemSheet } from './sheets/item-sheet';
|
||||
import { registerHbmConditions } from './logic/conditions';
|
||||
import { registerCombatHooks } from './logic/combat';
|
||||
import { registerMigrationSettings, runPendingMigrations } from './migrations';
|
||||
import { registerTriggerHooks } from './logic/spell-triggers';
|
||||
import { registerChatCardHooks } from './logic/chat-cards';
|
||||
import { castSpell } from './logic/spell-cast';
|
||||
import * as bloodMagic from './logic/blood-magic';
|
||||
import * as abyssMagic from './logic/abyss-magic';
|
||||
import * as brewing from './logic/brewing';
|
||||
import { rest } from './logic/rest';
|
||||
import * as trade from './logic/trade';
|
||||
|
||||
export const SYSTEM_ID = __SYSTEM_ID__;
|
||||
|
||||
Hooks.once('init', () => {
|
||||
console.log(`${SYSTEM_ID} | Initialising Homebrew Magic: RPG v3 system`);
|
||||
|
||||
// Register Babele translation if active
|
||||
if (typeof Babele !== 'undefined') {
|
||||
Babele.get().register({
|
||||
module: SYSTEM_ID,
|
||||
lang: 'en',
|
||||
dir: 'lang/compendium/en'
|
||||
});
|
||||
}
|
||||
|
||||
// Register custom roll
|
||||
CONFIG.Dice.rolls.unshift(HbmTSRoll as unknown as typeof Roll);
|
||||
|
||||
// Register data models
|
||||
CONFIG.Actor.dataModels = {
|
||||
...(CONFIG.Actor.dataModels ?? {}),
|
||||
character: CharacterData,
|
||||
npc: NpcData,
|
||||
} as Record<string, typeof foundry.abstract.TypeDataModel>;
|
||||
|
||||
CONFIG.Item.dataModels = {
|
||||
...(CONFIG.Item.dataModels ?? {}),
|
||||
spell: SpellData,
|
||||
gear: GearData,
|
||||
ability: AbilityData,
|
||||
class: ClassData,
|
||||
race: RaceData,
|
||||
discipline: DisciplineData,
|
||||
talent: TalentData,
|
||||
} as Record<string, typeof foundry.abstract.TypeDataModel>;
|
||||
|
||||
// Register sheets
|
||||
foundry.documents.collections.Actors.unregisterSheet('core', foundry.appv1.sheets.ActorSheet);
|
||||
foundry.documents.collections.Actors.registerSheet(SYSTEM_ID, CharacterSheet, {
|
||||
types: ['character'],
|
||||
makeDefault: true,
|
||||
label: 'HBM.actor.character',
|
||||
});
|
||||
foundry.documents.collections.Actors.registerSheet(SYSTEM_ID, NpcSheet, {
|
||||
types: ['npc'],
|
||||
makeDefault: true,
|
||||
label: 'HBM.actor.npc',
|
||||
});
|
||||
|
||||
foundry.documents.collections.Items.unregisterSheet('core', foundry.appv1.sheets.ItemSheet);
|
||||
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, HbmItemSheet, {
|
||||
makeDefault: true,
|
||||
label: 'HBM.system.name',
|
||||
});
|
||||
|
||||
registerHbmConditions();
|
||||
registerMigrationSettings();
|
||||
|
||||
// Preload Handlebars partials so the first render is synchronous.
|
||||
(foundry.applications.handlebars as any).loadTemplates([
|
||||
// Shared item partials
|
||||
'systems/hbm-rpg-v3/templates/item/_dispatch.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/_effects.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/header.hbs',
|
||||
// Item body partials (used via {{> (lookup this 'bodyPartial')}})
|
||||
'systems/hbm-rpg-v3/templates/item/talent.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/spell.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/gear.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/ability.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/class.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/race.hbs',
|
||||
'systems/hbm-rpg-v3/templates/item/discipline.hbs',
|
||||
// Actor partials
|
||||
'systems/hbm-rpg-v3/templates/actor/_actor-effects.hbs',
|
||||
]);
|
||||
|
||||
// Override default initiative formula to HbM's 2d6 + initiative attribute.
|
||||
CONFIG.Combat.initiative = {
|
||||
formula: '2d6 + @attributes.initiative',
|
||||
decimals: 2,
|
||||
};
|
||||
|
||||
// Handlebars helpers used in templates
|
||||
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
|
||||
Handlebars.registerHelper('gt', (a: number, b: number) => a > b);
|
||||
Handlebars.registerHelper('lt', (a: number, b: number) => a < b);
|
||||
Handlebars.registerHelper('or', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).some(Boolean));
|
||||
Handlebars.registerHelper('and', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).every(Boolean));
|
||||
Handlebars.registerHelper('concat', (...args: unknown[]) => (args.slice(0, -1) as string[]).join(''));
|
||||
});
|
||||
|
||||
Hooks.once('ready', () => {
|
||||
registerCombatHooks();
|
||||
registerTriggerHooks();
|
||||
registerChatCardHooks();
|
||||
void runPendingMigrations();
|
||||
|
||||
// Expose system API on game object for macros & external modules.
|
||||
(game as any).hbm = {
|
||||
castSpell,
|
||||
bloodMagic,
|
||||
abyssMagic,
|
||||
brewing,
|
||||
rest,
|
||||
trade,
|
||||
};
|
||||
|
||||
console.log(`${SYSTEM_ID} | Ready`);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Magia Otchłani — Abyss Magic logic (Klątwa Otchłani Ch. III–VIII).
|
||||
*
|
||||
* The Abyss splits into two disciplines:
|
||||
* - Magia Aspektów ("aspects") — controlled, low-risk, predictable
|
||||
* - Pierwotna Magia ("primal") — chaotic, high-power, requires `Dary Otchłani` rolls
|
||||
*
|
||||
* This module exposes:
|
||||
* - dispatchAbyssCast(spell) — returns 'aspects' | 'primal' from spell.discipline.
|
||||
* - rollAbyssGift(actor) — d100 against a roll table (resolved at runtime
|
||||
* from the `roll-tables-abyss.dary-otchlani` pack
|
||||
* when present; otherwise falls back to a chat
|
||||
* prompt for the GM).
|
||||
* - rollMistrzLosuPenalty(actor) — d100 penalty roll (Klątwa Otchłani VII).
|
||||
* - addInsanity(actor, n) — increments actor.system.attributes.insanity;
|
||||
* when crossing a threshold, fires a hook for
|
||||
* the GM to apply a mental condition.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export type AbyssDiscipline = 'aspects' | 'primal';
|
||||
|
||||
const PRIMAL_KEYS = new Set(['primal', 'pierwotna-magia', 'pierwotna_magia', 'magia-otchlani-pierwotna']);
|
||||
|
||||
interface SpellLikeForAbyss {
|
||||
system?: {
|
||||
school?: string;
|
||||
discipline?: string | string[];
|
||||
requirements?: { discipline?: string[] };
|
||||
};
|
||||
}
|
||||
|
||||
export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline {
|
||||
const disc = spell.system?.discipline ?? spell.system?.requirements?.discipline ?? [];
|
||||
const list = (Array.isArray(disc) ? disc : [disc]).map((s) => String(s).toLowerCase());
|
||||
for (const d of list) {
|
||||
if (PRIMAL_KEYS.has(d)) return 'primal';
|
||||
}
|
||||
return 'aspects';
|
||||
}
|
||||
|
||||
/** Insanity thresholds (Klątwa Otchłani VIII). Crossing any triggers a mental-condition roll. */
|
||||
const INSANITY_THRESHOLDS = [3, 6, 10, 15] as const;
|
||||
|
||||
export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> {
|
||||
if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] };
|
||||
const before = ((actor.system.attributes as any).insanity ?? 0) as number;
|
||||
const after = before + n;
|
||||
await actor.update({ 'system.attributes.insanity': after });
|
||||
const crossed = INSANITY_THRESHOLDS.filter((t) => before < t && after >= t);
|
||||
if (crossed.length > 0) {
|
||||
Hooks.callAll('hbm.insanityThreshold', actor, crossed, { before, after });
|
||||
}
|
||||
return { before, after, thresholdsCrossed: [...crossed] };
|
||||
}
|
||||
|
||||
/** Roll d100 against a named table inside the abyss roll-tables pack. */
|
||||
export async function rollAbyssGift(actor: CastableActor, tableName = 'dary-otchlani'): Promise<Roll> {
|
||||
return rollAgainstAbyssTable(actor, tableName, 'Dary Otchłani');
|
||||
}
|
||||
|
||||
export async function rollMistrzLosuPenalty(actor: CastableActor): Promise<Roll> {
|
||||
return rollAgainstAbyssTable(actor, 'mistrz-losu', 'Mistrz Losu');
|
||||
}
|
||||
|
||||
async function rollAgainstAbyssTable(actor: CastableActor, slug: string, flavor: string): Promise<Roll> {
|
||||
const roll = new Roll('1d100');
|
||||
await roll.evaluate();
|
||||
const speaker = ChatMessage.getSpeaker({ actor: actor as unknown as Actor });
|
||||
await roll.toMessage({
|
||||
flavor: `${flavor} — ${actor.name} (${slug})`,
|
||||
speaker,
|
||||
});
|
||||
Hooks.callAll('hbm.abyssTableRoll', actor, slug, roll.total);
|
||||
return roll;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Magia Krwi — Blood Magic logic (Arcanum Sanguinis Ch. III).
|
||||
*
|
||||
* Three primitives:
|
||||
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient.
|
||||
* - selfHarm(actor, hp) : convert hp → blood at 2:1 ratio (2 HP → 1 Blood).
|
||||
* - lifeStealOnDamage(...) : when actor inflicts damage with a blood spell,
|
||||
* restore blood = floor(damage / 4) (capped at max).
|
||||
*
|
||||
* Future hook: `Szacunek do Życia` talent triples self-harm cost (6 HP → 1 Blood).
|
||||
*
|
||||
* The cast pipeline (logic/spell-cast.ts) already deducts `bloodCost` directly;
|
||||
* this module is the reusable API surface for UI dialogs and the damage hook.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
const SELF_HARM_RATIO = 2; // HP per 1 Blood
|
||||
const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present
|
||||
const LIFE_STEAL_DIVISOR = 4; // damage / N → blood restored
|
||||
|
||||
interface ActorWithItems extends CastableActor {
|
||||
items?: Iterable<{ type: string; system?: { slug?: string } }>;
|
||||
}
|
||||
|
||||
function hasRespectForLife(actor: ActorWithItems): boolean {
|
||||
if (!actor.items) return false;
|
||||
for (const it of actor.items) {
|
||||
if (it.type !== 'talent') continue;
|
||||
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug;
|
||||
if (slug === 'respect-for-life' || slug === 'szacunek-do-zycia') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface BloodSpendResult {
|
||||
ok: boolean;
|
||||
spent: number;
|
||||
remaining: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Deduct `amount` from `actor.system.attributes.blood.value`. */
|
||||
export async function spendBlood(actor: CastableActor, amount: number): Promise<BloodSpendResult> {
|
||||
const blood = actor.system.attributes.blood;
|
||||
if (!blood) return { ok: false, spent: 0, remaining: 0, reason: 'no-blood-pool' };
|
||||
if (amount <= 0) return { ok: true, spent: 0, remaining: blood.value };
|
||||
if (blood.value < amount) {
|
||||
return { ok: false, spent: 0, remaining: blood.value, reason: 'insufficient-blood' };
|
||||
}
|
||||
await actor.update({ 'system.attributes.blood.value': blood.value - amount });
|
||||
return { ok: true, spent: amount, remaining: blood.value - amount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-Harm: convert health into blood. Returns blood gained.
|
||||
* 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. */
|
||||
export async function lifeStealOnDamage(actor: CastableActor, damageDealt: number): Promise<number> {
|
||||
if (damageDealt <= 0) return 0;
|
||||
const blood = actor.system.attributes.blood;
|
||||
if (!blood) return 0;
|
||||
const restore = Math.floor(damageDealt / LIFE_STEAL_DIVISOR);
|
||||
if (restore <= 0) return 0;
|
||||
const newBlood = Math.min(blood.max, blood.value + restore);
|
||||
await actor.update({ 'system.attributes.blood.value': newBlood });
|
||||
return newBlood - blood.value;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Warzenie Eliksirów — Brewing logic (Podręcznik Gry, Alchemia / Aneks C).
|
||||
*
|
||||
* Each character has `attributes.elixirTolerance` (default 0). Soft cap = body+1.
|
||||
* Drinking a potion increments the counter; exceeding the cap triggers a
|
||||
* poisoning hook (`hbm.elixirOverdose`). Long rest restores 1 tolerance
|
||||
* (handled by rest.ts); the `Nadzwyczajna Odporność` discipline-passive lets
|
||||
* a Short Rest restore 1 instead.
|
||||
*
|
||||
* Brewing a potion: TS test `Magic + Brewing skill` against the recipe's
|
||||
* difficulty. Caller passes the recipe (`{ name, difficulty, ingredients[] }`).
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export interface ElixirRecipe {
|
||||
name: string;
|
||||
/** TS test target — { threshold, successes }. */
|
||||
difficulty: { threshold: number; successes: number };
|
||||
/** Ingredient names (free-form). */
|
||||
ingredients: string[];
|
||||
/** Discipline skill key used for the brew test (default: alchemyBrewing). */
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
export function elixirToleranceCap(actor: CastableActor): number {
|
||||
const body = actor.system.attributes.body?.value ?? 1;
|
||||
return body + 1;
|
||||
}
|
||||
|
||||
/** Drink a potion: increment tolerance, fire `hbm.elixirOverdose` on overflow. */
|
||||
export async function consumeElixir(actor: CastableActor, recipe: Pick<ElixirRecipe, 'name'>): Promise<{ tolerance: number; overdose: boolean }> {
|
||||
const cur = ((actor.system.attributes as any).elixirTolerance ?? 0) as number;
|
||||
const next = cur + 1;
|
||||
await actor.update({ 'system.attributes.elixirTolerance': next });
|
||||
const overdose = next > elixirToleranceCap(actor);
|
||||
if (overdose) Hooks.callAll('hbm.elixirOverdose', actor, recipe.name, next);
|
||||
return { tolerance: next, overdose };
|
||||
}
|
||||
|
||||
/** Brewing TS test: pool = magic + skill (default `alchemyBrewing`). */
|
||||
export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Promise<{ roll: HbmTSRoll; success: boolean }> {
|
||||
const skillKey = recipe.skill ?? 'alchemyBrewing';
|
||||
const skillValue = actor.system.skills?.[skillKey]?.value ?? 0;
|
||||
const pool = actor.system.attributes.magic.actual + skillValue;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: recipe.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: recipe.difficulty.successes ?? TS_DEFAULT_REQUIRED,
|
||||
flavor: `Warzenie: ${recipe.name}`,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({
|
||||
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || '—'})`,
|
||||
});
|
||||
return { roll, success: !!roll.ts?.isSuccess };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Click handlers for the rich spell-cast chat card.
|
||||
*
|
||||
* Wires:
|
||||
* - `data-action="hbm-apply-status"` → toggle/add a CONFIG.statusEffects entry
|
||||
* on every selected/targeted token of the current user.
|
||||
* - `data-action="hbm-apply-damage"` → run the system damage pipeline against
|
||||
* the actor identified by `data-target-uuid`.
|
||||
*/
|
||||
|
||||
import { applyDamage } from './damage';
|
||||
import { CONDITIONS } from './conditions';
|
||||
|
||||
declare const Hooks: any;
|
||||
declare const fromUuid: <T = unknown>(uuid: string) => Promise<T | null>;
|
||||
declare const game: any;
|
||||
declare const canvas: any;
|
||||
declare const ui: any;
|
||||
|
||||
export function registerChatCardHooks(): void {
|
||||
Hooks.on('renderChatMessageHTML', (_msg: unknown, html: HTMLElement | JQuery) => {
|
||||
const root = (html as any)?.jquery ? (html as any)[0] : (html as HTMLElement);
|
||||
if (!root) return;
|
||||
root.querySelectorAll<HTMLElement>('button.hbm-action[data-action="hbm-apply-status"]').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
void onApplyStatus(btn);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>('button.hbm-action[data-action="hbm-apply-damage"]').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
void onApplyDamage(btn);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onApplyStatus(btn: HTMLElement): Promise<void> {
|
||||
const id = btn.dataset.effectId;
|
||||
if (!id) return;
|
||||
const def = CONDITIONS.find((c) => c.id === id);
|
||||
if (!def) {
|
||||
ui.notifications?.warn(`Unknown condition: ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = collectTargetTokens();
|
||||
if (tokens.length === 0) {
|
||||
ui.notifications?.warn(game.i18n?.localize?.('HBM.spellCast.noTargets') ?? 'No targets selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
const actor = token.actor;
|
||||
if (!actor) continue;
|
||||
if (typeof actor.toggleStatusEffect === 'function') {
|
||||
await actor.toggleStatusEffect(id, { active: true });
|
||||
}
|
||||
}
|
||||
ui.notifications?.info(`${game.i18n?.localize?.(def.i18nKey) ?? id} → ${tokens.length}`);
|
||||
}
|
||||
|
||||
async function onApplyDamage(btn: HTMLElement): Promise<void> {
|
||||
const uuid = btn.dataset.targetUuid;
|
||||
const amount = Number(btn.dataset.amount ?? 0);
|
||||
if (!uuid || !Number.isFinite(amount) || amount <= 0) return;
|
||||
const target = await fromUuid<any>(uuid);
|
||||
if (!target) {
|
||||
ui.notifications?.warn(`Target not found: ${uuid}`);
|
||||
return;
|
||||
}
|
||||
const ignoresArmor = btn.dataset.ignoresArmor === 'true';
|
||||
await applyDamage(target, {
|
||||
amount,
|
||||
type: (btn.dataset.type as any) ?? 'magical',
|
||||
ignoreMagicalArmor: ignoresArmor,
|
||||
ignoreMagicalShield: ignoresArmor,
|
||||
ignorePhysicalArmor: ignoresArmor,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function collectTargetTokens(): any[] {
|
||||
const tokens: any[] = [];
|
||||
const targets = game.user?.targets;
|
||||
if (targets && typeof targets[Symbol.iterator] === 'function') {
|
||||
for (const t of targets) tokens.push(t);
|
||||
}
|
||||
if (tokens.length === 0 && canvas?.tokens?.controlled?.length) {
|
||||
tokens.push(...canvas.tokens.controlled);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Combat hooks — handle per-round Mana reset, per-turn Zeal regen,
|
||||
* and condition-driven turn behavior (skip / damage tick / death save).
|
||||
*/
|
||||
|
||||
import { applyDamage } from './damage';
|
||||
|
||||
function hasStatus(actor: any, id: string): boolean {
|
||||
const effects = actor?.effects ?? [];
|
||||
for (const ef of effects) {
|
||||
if (ef?.disabled) continue;
|
||||
const statuses = ef?.statuses;
|
||||
if (statuses && typeof statuses.has === 'function' ? statuses.has(id) : Array.isArray(statuses) && statuses.includes(id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerCombatHooks(): void {
|
||||
Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
|
||||
for (const combatant of combat.combatants) {
|
||||
const actor = combatant.actor;
|
||||
if (!actor || actor.type !== 'character') continue;
|
||||
const sys = actor.system as { attributes: { mana: { max: number; value: number } } };
|
||||
await actor.update({ 'system.attributes.mana.value': sys.attributes.mana.max });
|
||||
}
|
||||
ChatMessage.create({
|
||||
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
|
||||
whisper: ChatMessage.getWhisperRecipients('GM'),
|
||||
});
|
||||
});
|
||||
|
||||
Hooks.on('combatTurn', async (combat: Combat) => {
|
||||
const combatant = combat.combatant;
|
||||
const actor = combatant?.actor;
|
||||
if (!actor) return;
|
||||
|
||||
// Skip turn for unconscious / restrained-to-incapacitation
|
||||
if (hasStatus(actor, 'nieprzytomny') || hasStatus(actor, 'obezwladniony')) {
|
||||
ChatMessage.create({
|
||||
content: `<em>${actor.name}: ${game.i18n.localize('HBM.combat.turnSkipped')}</em>`,
|
||||
});
|
||||
try { await (combat as any).nextTurn?.(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// Burning: tick environmental damage
|
||||
if (hasStatus(actor, 'podpalony')) {
|
||||
await applyDamage(actor, { amount: 1, type: 'environmental', ignoreMagicalArmor: true, ignoreMagicalShield: true, ignorePhysicalArmor: true });
|
||||
}
|
||||
|
||||
// Dying: prompt death save (simplified — posts a chat reminder)
|
||||
if (hasStatus(actor, 'umierajacy')) {
|
||||
ChatMessage.create({
|
||||
content: `<strong>${actor.name}</strong>: ${game.i18n.localize('HBM.combat.deathSavePrompt')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Per-turn Zeal regen (characters) — base 1 + talent bonus from flag.
|
||||
if (actor.type === 'character') {
|
||||
const sys = actor.system as { attributes: { zeal: { value: number; max: number } } };
|
||||
const regen = getZealRegen(actor);
|
||||
const next = Math.min(sys.attributes.zeal.max, sys.attributes.zeal.value + regen);
|
||||
if (next !== sys.attributes.zeal.value) {
|
||||
await actor.update({ 'system.attributes.zeal.value': next });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeal regenerated at the start of an actor's turn.
|
||||
* Defaults to 1; talents may add bonus via `flags['hbm-rpg-v3'].zealRegenBonus`
|
||||
* (typically set by an ActiveEffect with mode ADD targeting that flag path).
|
||||
*/
|
||||
export function getZealRegen(actor: any): number {
|
||||
const base = 1;
|
||||
const bonus = Number(actor?.getFlag?.('hbm-rpg-v3', 'zealRegenBonus') ?? 0) || 0;
|
||||
return Math.max(0, base + bonus);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* HbM canonical status conditions (Aneks A — Podręcznik Gry).
|
||||
* 15 entries with Active-Effect change ops and `flags.hbm.*` metadata
|
||||
* consumed by the roll pipeline and combat hooks.
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
|
||||
export interface ConditionDef {
|
||||
id: string;
|
||||
i18nKey: string;
|
||||
icon: string;
|
||||
changes?: Array<{ key: string; mode: number; value: string; priority?: number }>;
|
||||
flags?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CHANGE_MODE_ADD = 2;
|
||||
|
||||
export const CONDITIONS: ConditionDef[] = [
|
||||
{ id: 'przewrocony', i18nKey: 'HBM.conditions.przewrocony', icon: 'icons/svg/falling.svg', flags: { hbm: { prone: true } } },
|
||||
{ id: 'unieruchomiony', i18nKey: 'HBM.conditions.unieruchomiony', icon: 'icons/svg/net.svg', flags: { hbm: { thresholdSteps: { attack: 1 }, defenseSteps: -1 } } },
|
||||
{ id: '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: '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: '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: 'zatruty',
|
||||
i18nKey: 'HBM.conditions.zatruty',
|
||||
icon: 'icons/svg/poison.svg',
|
||||
changes: [
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
|
||||
],
|
||||
},
|
||||
{ id: 'podpalony', i18nKey: 'HBM.conditions.podpalony', icon: 'icons/svg/fire.svg', flags: { hbm: { damagePerRound: { amount: 1, type: 'environmental' } } } },
|
||||
{ id: 'spowolniony', i18nKey: 'HBM.conditions.spowolniony', icon: 'icons/svg/clockwork.svg', flags: { hbm: { halveSpeed: true } } },
|
||||
{
|
||||
id: 'przeklety',
|
||||
i18nKey: 'HBM.conditions.przeklety',
|
||||
icon: 'icons/svg/sun.svg',
|
||||
changes: [
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
|
||||
],
|
||||
flags: { hbm: { blocksRegen: true } },
|
||||
},
|
||||
{ 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 } } },
|
||||
// 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 } } },
|
||||
|
||||
// Mental illnesses (Klątwa Otchłani Ch. VIII — book chapter currently a placeholder;
|
||||
// these are stub registrations with `todoBookGap: true` until full mechanics drop).
|
||||
{ id: 'paranoja', i18nKey: 'HBM.conditions.paranoja', icon: 'icons/svg/eye.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'fobia', i18nKey: 'HBM.conditions.fobia', icon: 'icons/svg/silenced.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'depresja', i18nKey: 'HBM.conditions.depresja', icon: 'icons/svg/sleep.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'mania', i18nKey: 'HBM.conditions.mania', icon: 'icons/svg/lightning.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'schizofrenia', i18nKey: 'HBM.conditions.schizofrenia', icon: 'icons/svg/stoned.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
];
|
||||
|
||||
export function registerHbmConditions(): void {
|
||||
CONFIG.statusEffects = CONDITIONS.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.i18nKey,
|
||||
img: c.icon,
|
||||
statuses: [c.id],
|
||||
changes: c.changes ?? [],
|
||||
flags: c.flags ?? {},
|
||||
}));
|
||||
console.log(`${SYSTEM_ID} | Registered ${CONDITIONS.length} canonical status effects`);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Damage application engine — flows through 4 layers:
|
||||
* 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter)
|
||||
* 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0)
|
||||
* 3. Physical Armor (flat DR; optionally degrade `condition` by 1)
|
||||
* 4. Health (the leftover hits HP)
|
||||
*
|
||||
* Bypass flags allow effects to skip individual layers.
|
||||
*
|
||||
* After resolving, if HP loss meets the Oszołomiony threshold ⌈(C+U+D)/3⌉
|
||||
* the effect is auto-applied to character actors.
|
||||
*/
|
||||
|
||||
import { DamageType } from '../constants';
|
||||
|
||||
export interface ApplyDamageOptions {
|
||||
amount: number;
|
||||
type?: DamageType;
|
||||
ignoreMagicalArmor?: boolean;
|
||||
ignoreMagicalShield?: boolean;
|
||||
ignorePhysicalArmor?: boolean;
|
||||
damageArmor?: boolean;
|
||||
postChat?: boolean;
|
||||
}
|
||||
|
||||
export interface DamageReport {
|
||||
amount: number;
|
||||
absorbed: { magicalArmor: number; magicalShield: number; physicalArmor: number };
|
||||
hpDamage: number;
|
||||
shieldDropped: boolean;
|
||||
oszolomionyApplied: boolean;
|
||||
}
|
||||
|
||||
interface ActorLike {
|
||||
name: string;
|
||||
type: string;
|
||||
system: any;
|
||||
update: (changes: Record<string, unknown>) => Promise<unknown>;
|
||||
toggleStatusEffect?: (id: string, options?: any) => Promise<any> | any;
|
||||
effects?: { find: (fn: (e: any) => boolean) => any };
|
||||
}
|
||||
|
||||
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> {
|
||||
const total = Math.max(0, Math.floor(opts.amount));
|
||||
const a = actor.system.attributes;
|
||||
const updates: Record<string, unknown> = {};
|
||||
const report: DamageReport = {
|
||||
amount: total,
|
||||
absorbed: { magicalArmor: 0, magicalShield: 0, physicalArmor: 0 },
|
||||
hpDamage: 0,
|
||||
shieldDropped: false,
|
||||
oszolomionyApplied: false,
|
||||
};
|
||||
|
||||
let remaining = total;
|
||||
|
||||
// Layer 1: Magical Armor — DR-based; runicCounter accumulates incoming damage.
|
||||
if (!opts.ignoreMagicalArmor && a.magicalArmor) {
|
||||
const dr = Math.max(0, Number(a.magicalArmor.value) || 0);
|
||||
const absorbed = Math.min(dr, remaining);
|
||||
if (absorbed > 0) {
|
||||
const incomingDmg = remaining;
|
||||
report.absorbed.magicalArmor = absorbed;
|
||||
remaining -= absorbed;
|
||||
const newCounter = (Number(a.magicalArmor.runicCounter) || 0) + incomingDmg;
|
||||
const decrements = Math.floor(newCounter / 5);
|
||||
const newValue = Math.max(0, dr - decrements);
|
||||
updates['system.attributes.magicalArmor.runicCounter'] = newCounter % 5;
|
||||
updates['system.attributes.magicalArmor.value'] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Magical Shield — pure temp HP (raw subtraction).
|
||||
if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) {
|
||||
const shield = Math.max(0, Number(a.magicalShield.value) || 0);
|
||||
const taken = Math.min(shield, remaining);
|
||||
if (taken > 0) {
|
||||
report.absorbed.magicalShield = taken;
|
||||
remaining -= taken;
|
||||
const newShield = shield - taken;
|
||||
updates['system.attributes.magicalShield.value'] = newShield;
|
||||
if (newShield === 0) report.shieldDropped = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Physical Armor — flat DR; optionally degrade condition.
|
||||
if (remaining > 0 && !opts.ignorePhysicalArmor && a.physicalArmor) {
|
||||
const dr = Math.max(0, Number(a.physicalArmor.value) || 0);
|
||||
const absorbed = Math.min(dr, remaining);
|
||||
if (absorbed > 0) {
|
||||
report.absorbed.physicalArmor = absorbed;
|
||||
remaining -= absorbed;
|
||||
}
|
||||
if (opts.damageArmor && actor.type === 'character' && a.physicalArmor.condition != null) {
|
||||
// Decrement durability of the first equipped armor piece (character only)
|
||||
const items = (actor as any).items as Iterable<any> | undefined;
|
||||
if (items) {
|
||||
for (const it of items) {
|
||||
if (it.type === 'gear' && it.system?.equipped && it.system?.category === 'armor') {
|
||||
const cond = Math.max(0, Number(it.system.armor?.condition ?? 0) - 1);
|
||||
await it.update({ 'system.armor.condition': cond });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 4: Health
|
||||
if (remaining > 0 && a.health) {
|
||||
const hp = Math.max(0, Number(a.health.value) || 0);
|
||||
const newHp = Math.max(0, hp - remaining);
|
||||
report.hpDamage = hp - newHp;
|
||||
updates['system.attributes.health.value'] = newHp;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) await actor.update(updates);
|
||||
|
||||
if (opts.postChat ?? true) {
|
||||
const lines: string[] = [
|
||||
`<strong>${actor.name}</strong> — ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
|
||||
];
|
||||
if (report.absorbed.magicalArmor) lines.push(`${game.i18n.localize('HBM.resources.magicalArmor')}: −${report.absorbed.magicalArmor}`);
|
||||
if (report.absorbed.magicalShield) lines.push(`${game.i18n.localize('HBM.resources.magicalShield')}: −${report.absorbed.magicalShield}${report.shieldDropped ? ' ✦' : ''}`);
|
||||
if (report.absorbed.physicalArmor) lines.push(`${game.i18n.localize('HBM.resources.physicalArmor')}: −${report.absorbed.physicalArmor}`);
|
||||
if (report.hpDamage) lines.push(`${game.i18n.localize('HBM.resources.health')}: −${report.hpDamage}`);
|
||||
await ChatMessage.create({ content: `<div class="hbm-damage-report">${lines.join('<br/>')}</div>` });
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Rest logic.
|
||||
*
|
||||
* Short Rest (Krótki Odpoczynek):
|
||||
* - Restore HP equal to actor.body.value (capped at max).
|
||||
* - Restore mana to max-per-spell? — no: short rest does NOT restore mana.
|
||||
* - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance.
|
||||
*
|
||||
* Long Rest (Długi Odpoczynek):
|
||||
* - Restore HP, mana, zeal to max.
|
||||
* - Restore 1 elixir tolerance (or all, if `Nadzwyczajna Odporność`? — book says
|
||||
* elixir tolerance recovers per long rest by default; tracked here as -1).
|
||||
* - Reset blood pool to max (assumption — refine when AS spell list lands).
|
||||
* - Clear runic counter on magical armor.
|
||||
*
|
||||
* Hooks `hbm.beforeRest` and `hbm.afterRest` fire for module integration.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
import { CONDITIONS } from './conditions';
|
||||
|
||||
export type RestKind = 'breather' | 'short' | 'long';
|
||||
|
||||
declare const Hooks: any;
|
||||
declare const Roll: any;
|
||||
|
||||
interface ActorWithItems extends CastableActor {
|
||||
items?: Iterable<{ type: string; system?: { slug?: string }; flags?: any }>;
|
||||
}
|
||||
|
||||
function hasExtraordinaryResilience(actor: ActorWithItems): boolean {
|
||||
if (!actor.items) return false;
|
||||
for (const it of actor.items) {
|
||||
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug ?? '';
|
||||
if (slug === 'extraordinary-resilience' || slug === 'nadzwyczajna-odpornosc') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface RestResult {
|
||||
kind: RestKind;
|
||||
hpRestored: number;
|
||||
manaRestored: number;
|
||||
zealRestored: number;
|
||||
bloodRestored: number;
|
||||
toleranceRecovered: number;
|
||||
effectsCleared: number;
|
||||
}
|
||||
|
||||
export async function rest(actor: CastableActor, kind: RestKind): Promise<RestResult> {
|
||||
Hooks.callAll('hbm.beforeRest', actor, kind);
|
||||
const a = actor.system.attributes as any;
|
||||
const update: Record<string, unknown> = {};
|
||||
|
||||
const hpBefore = a.health?.value ?? 0;
|
||||
const manaBefore = a.mana?.value ?? 0;
|
||||
const zealBefore = a.zeal?.value ?? 0;
|
||||
const bloodBefore = a.blood?.value ?? 0;
|
||||
const toleranceBefore = a.elixirTolerance ?? 0;
|
||||
|
||||
let hpRestored = 0;
|
||||
let manaRestored = 0;
|
||||
let zealRestored = 0;
|
||||
let bloodRestored = 0;
|
||||
let toleranceRecovered = 0;
|
||||
|
||||
if (kind === 'breather') {
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
}
|
||||
const endurance = actor.system.skills?.endurance?.value ?? 0;
|
||||
const roll = new Roll('1d6 + @endurance', { endurance });
|
||||
await roll.evaluate();
|
||||
const rollTotal = roll.total;
|
||||
const heal = Math.min(rollTotal, (a.health?.max ?? 0) - hpBefore);
|
||||
if (heal > 0) {
|
||||
update['system.attributes.health.value'] = hpBefore + heal;
|
||||
hpRestored = heal;
|
||||
}
|
||||
} else if (kind === 'short') {
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
}
|
||||
const healAmount = Math.ceil((a.health?.max ?? 0) / 3);
|
||||
const heal = Math.min(healAmount, (a.health?.max ?? 0) - hpBefore);
|
||||
if (heal > 0) {
|
||||
update['system.attributes.health.value'] = hpBefore + heal;
|
||||
hpRestored = heal;
|
||||
}
|
||||
if (hasExtraordinaryResilience(actor as ActorWithItems) && toleranceBefore > 0) {
|
||||
update['system.attributes.elixirTolerance'] = toleranceBefore - 1;
|
||||
toleranceRecovered = 1;
|
||||
}
|
||||
} else {
|
||||
if (a.health?.max != null && hpBefore < a.health.max) {
|
||||
update['system.attributes.health.value'] = a.health.max;
|
||||
hpRestored = a.health.max - hpBefore;
|
||||
}
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
}
|
||||
if (a.zeal?.max != null && zealBefore < a.zeal.max) {
|
||||
update['system.attributes.zeal.value'] = a.zeal.max;
|
||||
zealRestored = a.zeal.max - zealBefore;
|
||||
}
|
||||
if (a.blood?.max != null && bloodBefore < a.blood.max) {
|
||||
update['system.attributes.blood.value'] = a.blood.max;
|
||||
bloodRestored = a.blood.max - bloodBefore;
|
||||
}
|
||||
if (toleranceBefore > 0) {
|
||||
update['system.attributes.elixirTolerance'] = Math.max(0, toleranceBefore - 1);
|
||||
toleranceRecovered = 1;
|
||||
}
|
||||
if (a.magicalArmor?.runicCounter && a.magicalArmor.runicCounter > 0) {
|
||||
update['system.attributes.magicalArmor.runicCounter'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) await actor.update(update);
|
||||
|
||||
let effectsCleared = 0;
|
||||
const actorAny = actor as unknown as { effects?: any; deleteEmbeddedDocuments?: (t: string, ids: string[]) => Promise<unknown> };
|
||||
const ids: string[] = [];
|
||||
|
||||
if (kind === 'short') {
|
||||
for (const ef of actorAny.effects ?? []) {
|
||||
const isUnconscious = ef.statuses?.has('nieprzytomny') || ef.flags?.core?.statusId === 'nieprzytomny' || ef.id === 'nieprzytomny';
|
||||
if (isUnconscious) ids.push(ef.id);
|
||||
}
|
||||
} else if (kind === 'long') {
|
||||
const conditionIds = new Set(CONDITIONS.map(c => c.id));
|
||||
for (const ef of actorAny.effects ?? []) {
|
||||
const isState = conditionIds.has(ef.id) ||
|
||||
(ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) ||
|
||||
conditionIds.has(ef.flags?.core?.statusId);
|
||||
|
||||
if (isState) {
|
||||
ids.push(ef.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ef.origin) continue;
|
||||
const dur = ef.duration ?? {};
|
||||
const isTemporary = (dur.seconds ?? 0) > 0 || (dur.rounds ?? 0) > 0 || (dur.turns ?? 0) > 0 || ef.flags?.['hbm-rpg-v3']?.untilLongRest === true;
|
||||
if (isTemporary) ids.push(ef.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.length > 0 && actorAny.deleteEmbeddedDocuments) {
|
||||
await actorAny.deleteEmbeddedDocuments('ActiveEffect', ids);
|
||||
effectsCleared = ids.length;
|
||||
}
|
||||
|
||||
const result: RestResult = { kind, hpRestored, manaRestored, zealRestored, bloodRestored, toleranceRecovered, effectsCleared };
|
||||
Hooks.callAll('hbm.afterRest', actor, result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Spell casting workflow — handles standard, sacred (Magia Sakralna),
|
||||
* witch (Wiedźmia Magia), and blood (Magia Krwi) modes.
|
||||
*
|
||||
* Pipeline per cast:
|
||||
* 1. validateCast() — gate on resources, race, talent, discipline,
|
||||
* deity, group-cast, in-combat, witch symbols
|
||||
* 2. Resource deduction — mana / zeal / blood
|
||||
* 3. TS test roll — pool depends on mode
|
||||
* 4. (success) damage roll — parses spell.damageBase
|
||||
* 5. Status effect auto-apply — from spell.statusEffects[]
|
||||
* 6. Trigger registration — from spell.triggers[]
|
||||
*
|
||||
* Bypasses for GM workflows: opts.bypassSuperspellWarning, opts.bypassNonCombatBlock.
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
import { validateCast } from './spell-validation';
|
||||
import type { ValidationResult } from './spell-validation';
|
||||
import { rollSpellDamage } from './spell-damage';
|
||||
import { registerSpellTriggers } from './spell-triggers';
|
||||
import { CONDITIONS } from './conditions';
|
||||
|
||||
export interface CastableActor {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'character' | 'npc';
|
||||
system: {
|
||||
attributes: {
|
||||
magic: { value: number };
|
||||
soul: { value: number };
|
||||
mana: { value: number; max: number; maxPerSpell: number };
|
||||
zeal: { value: number; max: number };
|
||||
blood?: { value: number; max: number };
|
||||
};
|
||||
skills?: Record<string, { value: number }>;
|
||||
};
|
||||
update: (changes: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface SpellLike {
|
||||
id?: string;
|
||||
name: string;
|
||||
system: {
|
||||
castingMode: 'standard' | 'sacred' | 'witch' | 'blood';
|
||||
school?: string;
|
||||
deity?: string;
|
||||
manaCost: number;
|
||||
bloodCost?: number;
|
||||
complexityLevel: number;
|
||||
isSuperspell?: boolean;
|
||||
requiresGroupCast?: boolean;
|
||||
minCasters?: number;
|
||||
nonCombatOnly?: boolean;
|
||||
damageBase?: string;
|
||||
damageType?: string;
|
||||
ignoresArmor?: boolean;
|
||||
statusEffects?: string[];
|
||||
saveAttribute?: string;
|
||||
saveSkill?: string;
|
||||
triggers?: Array<{ event: string; effect: string }>;
|
||||
components?: { symbols?: string[] };
|
||||
requirements?: { race?: string[]; talent?: string[]; discipline?: string[] };
|
||||
difficulty: { threshold: number; successes: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface CastOptions {
|
||||
manaSpent?: number;
|
||||
zealSpent?: number;
|
||||
bloodSpent?: number;
|
||||
hekateMode?: 'sacred' | 'witch';
|
||||
bypassSuperspellWarning?: boolean;
|
||||
bypassNonCombatBlock?: boolean;
|
||||
groupCasters?: string[];
|
||||
/** Override required successes (used by variableSuccesses summons). */
|
||||
requiredOverride?: number;
|
||||
speaker?: ChatMessage.SpeakerData;
|
||||
}
|
||||
|
||||
export interface CastResult {
|
||||
validation: ValidationResult;
|
||||
baseRoll: HbmTSRoll | null;
|
||||
damageRoll: Roll | null;
|
||||
triggersRegistered: number;
|
||||
}
|
||||
|
||||
function notify(text: string, severity: 'warn' | 'error' | 'info' = 'info'): void {
|
||||
const u = ui as unknown as { notifications?: { warn: (s: string) => void; error: (s: string) => void; info: (s: string) => void } };
|
||||
u.notifications?.[severity](text);
|
||||
}
|
||||
|
||||
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> {
|
||||
// 1. Validate
|
||||
const validation = validateCast(actor, spell, opts);
|
||||
if (!validation.ok) {
|
||||
for (const err of validation.errors) {
|
||||
notify(err.i18nKey ? game.i18n.format(err.i18nKey, (err.i18nArgs ?? {}) as Record<string, string>) : err.message, 'warn');
|
||||
}
|
||||
return { validation, baseRoll: null, damageRoll: null, triggersRegistered: 0 };
|
||||
}
|
||||
for (const w of validation.warnings) {
|
||||
notify(w.i18nKey ? game.i18n.format(w.i18nKey, (w.i18nArgs ?? {}) as Record<string, string>) : w.message, 'info');
|
||||
}
|
||||
|
||||
// 2-3. Mode dispatch
|
||||
const mode = spell.system.castingMode ?? 'standard';
|
||||
let baseRoll: HbmTSRoll | null = null;
|
||||
if (mode === 'sacred') {
|
||||
baseRoll = opts.hekateMode === 'witch'
|
||||
? await castWitch(actor, spell, opts)
|
||||
: await castSacred(actor, spell, opts);
|
||||
} else if (mode === 'witch') {
|
||||
baseRoll = await castWitch(actor, spell, opts);
|
||||
} else if (mode === 'blood') {
|
||||
baseRoll = await castBlood(actor, spell, opts);
|
||||
} else {
|
||||
baseRoll = await castStandard(actor, spell, opts);
|
||||
}
|
||||
|
||||
let damageRoll: Roll | null = null;
|
||||
let triggersRegistered = 0;
|
||||
|
||||
if (baseRoll?.ts?.isSuccess) {
|
||||
// 4. Damage
|
||||
if (spell.system.damageBase) {
|
||||
damageRoll = await rollSpellDamage(spell.system.damageBase, { actor, spellName: spell.name });
|
||||
}
|
||||
|
||||
// 6. Triggers
|
||||
triggersRegistered = await registerSpellTriggers(actor as any, spell);
|
||||
}
|
||||
|
||||
// Render unified rich chat card (Phase 1.6)
|
||||
await renderCastCard(actor, spell, opts, baseRoll, damageRoll, triggersRegistered);
|
||||
|
||||
return { validation, baseRoll, damageRoll, triggersRegistered };
|
||||
}
|
||||
|
||||
async function renderCastCard(
|
||||
actor: CastableActor,
|
||||
spell: SpellLike,
|
||||
opts: CastOptions,
|
||||
baseRoll: HbmTSRoll | null,
|
||||
damageRoll: Roll | null,
|
||||
triggersRegistered: number,
|
||||
): Promise<void> {
|
||||
const success = baseRoll?.ts?.isSuccess ?? false;
|
||||
const mode = spell.system.castingMode ?? 'standard';
|
||||
|
||||
// Costs ledger
|
||||
const costs: Array<{ label: string; amount: number }> = [];
|
||||
if ((opts.manaSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.manaSpent'), amount: opts.manaSpent! });
|
||||
if ((opts.zealSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
|
||||
if ((opts.bloodSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.bloodSpent'), amount: opts.bloodSpent! });
|
||||
|
||||
// Damage block
|
||||
let damage: Record<string, unknown> | null = null;
|
||||
if (success && damageRoll) {
|
||||
const targets: Array<{ uuid: string; name: string }> = [];
|
||||
const userTargets = (game.user as any)?.targets;
|
||||
if (userTargets && typeof userTargets[Symbol.iterator] === 'function') {
|
||||
for (const t of userTargets) {
|
||||
if (t?.actor?.uuid) targets.push({ uuid: t.actor.uuid, name: t.actor.name ?? t.name });
|
||||
}
|
||||
}
|
||||
damage = {
|
||||
total: damageRoll.total,
|
||||
formula: damageRoll.formula,
|
||||
type: spell.system.damageType ?? 'magical',
|
||||
ignoresArmor: !!spell.system.ignoresArmor,
|
||||
targets: targets.length > 0 ? targets : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Status effects (with apply buttons)
|
||||
const statusEffects: Array<{ id: string; label: string }> = [];
|
||||
if (success && Array.isArray(spell.system.statusEffects)) {
|
||||
for (const id of spell.system.statusEffects) {
|
||||
const def = CONDITIONS.find((c) => c.id === id);
|
||||
const label = def ? game.i18n.localize(def.i18nKey) : id;
|
||||
statusEffects.push({ id, label });
|
||||
}
|
||||
}
|
||||
|
||||
// Save line
|
||||
const save = success && spell.system.saveAttribute
|
||||
? { attribute: spell.system.saveAttribute, skill: spell.system.saveSkill ?? '' }
|
||||
: null;
|
||||
|
||||
const triggers = success && triggersRegistered > 0 && Array.isArray(spell.system.triggers)
|
||||
? spell.system.triggers
|
||||
: [];
|
||||
|
||||
const data = {
|
||||
spell: { name: spell.name },
|
||||
mode,
|
||||
school: spell.system.school ?? '',
|
||||
circle: (spell.system as any).circle ?? 0,
|
||||
outcome: { success },
|
||||
costs,
|
||||
damage,
|
||||
save,
|
||||
statusEffects,
|
||||
triggers,
|
||||
description: (spell.system as any).description ?? '',
|
||||
};
|
||||
|
||||
const html = await renderTemplate('systems/hbm-rpg-v3/templates/chat/spell-cast.hbs', data);
|
||||
await ChatMessage.create({
|
||||
content: html,
|
||||
speaker: opts.speaker ?? ChatMessage.getSpeaker({ actor: actor as any }),
|
||||
flags: { 'hbm-rpg-v3': { spellCast: { spellName: spell.name, success } } },
|
||||
});
|
||||
}
|
||||
|
||||
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
||||
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
|
||||
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
|
||||
const a = actor.system.attributes;
|
||||
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaSpent });
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${spell.name}`;
|
||||
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const baseRoll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required,
|
||||
flavor,
|
||||
});
|
||||
await baseRoll.evaluate();
|
||||
await baseRoll.toMessage({ flavor, speaker: opts.speaker });
|
||||
|
||||
// Overcast — extra TS test if manaSpent exceeds maxPerSpell.
|
||||
if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
|
||||
const excess = manaSpent - a.mana.maxPerSpell;
|
||||
const tThreshold = Math.min(6, Math.max(2, baseCost));
|
||||
const ySuccesses = Math.min(10, Math.max(1, excess));
|
||||
const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`;
|
||||
const overcastRoll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: tThreshold,
|
||||
required: ySuccesses,
|
||||
flavor: overcastFlavor,
|
||||
});
|
||||
await overcastRoll.evaluate();
|
||||
await overcastRoll.toMessage({ flavor: overcastFlavor, speaker: opts.speaker });
|
||||
}
|
||||
|
||||
return baseRoll;
|
||||
}
|
||||
|
||||
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
||||
const a = actor.system.attributes;
|
||||
const zealCost = Math.max(1, opts.zealSpent ?? 1);
|
||||
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
|
||||
|
||||
const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0);
|
||||
const flavor = `${spell.name} — ${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
|
||||
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required,
|
||||
flavor,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({ flavor, speaker: opts.speaker });
|
||||
return roll;
|
||||
}
|
||||
|
||||
async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
||||
const a = actor.system.attributes;
|
||||
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
|
||||
const maxSymbols = Math.ceil(a.magic.actual / 2);
|
||||
|
||||
// Witch magic still has a mana cost.
|
||||
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - baseCost });
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${spell.name} — ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
|
||||
const reqSuccesses = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: reqSuccesses,
|
||||
flavor,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({ flavor, speaker: opts.speaker });
|
||||
return roll;
|
||||
}
|
||||
|
||||
async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
||||
const a = actor.system.attributes;
|
||||
const baseCost = Math.max(0, spell.system.bloodCost ?? 0);
|
||||
const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost));
|
||||
const blood = a.blood;
|
||||
if (blood) {
|
||||
await actor.update({ 'system.attributes.blood.value': Math.max(0, blood.value - bloodSpent) });
|
||||
}
|
||||
|
||||
// Mana cost (some blood spells may also have one)
|
||||
const manaCost = Math.max(0, spell.system.manaCost ?? 0);
|
||||
if (manaCost > 0) {
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaCost });
|
||||
}
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${spell.name} — ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
|
||||
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required,
|
||||
flavor,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({ flavor, speaker: opts.speaker });
|
||||
return roll;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Spell damage formula evaluator.
|
||||
*
|
||||
* Tokens recognised in `spell.damageBase`:
|
||||
* magicalAbilities, devotion, soul, mind, body, magic
|
||||
* magicalAbilities/2 (and similar /N or *N suffixes)
|
||||
* 1d6, 2d6, 1d3 etc. (Foundry dice notation)
|
||||
* numeric literals
|
||||
* Operators: + - * / (integer division for /N tokens; standard for dice)
|
||||
*
|
||||
* Returns a Foundry Roll ready to evaluate, or null when the spell has
|
||||
* no damageBase formula.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export interface DamageContext {
|
||||
actor: CastableActor;
|
||||
spellName?: string;
|
||||
}
|
||||
|
||||
const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']);
|
||||
|
||||
function resolveToken(token: string, ctx: DamageContext): number {
|
||||
const a = (ctx.actor as any).system?.attributes ?? {};
|
||||
const s = (ctx.actor as any).system?.skills ?? {};
|
||||
switch (token) {
|
||||
case 'body': return Number(a.body?.value ?? 0);
|
||||
case 'mind': return Number(a.mind?.value ?? 0);
|
||||
case 'soul': return Number(a.soul?.value ?? 0);
|
||||
case 'magic': return Number(a.magic?.value ?? 0);
|
||||
case 'magicalAbilities': return Number(s.magicalAbilities?.value ?? 0);
|
||||
case 'devotion': return Number(s.devotion?.value ?? 0);
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutes attribute tokens with their numeric values, leaving dice
|
||||
* notation intact for Foundry to parse.
|
||||
*/
|
||||
export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext): string | null {
|
||||
if (!spellDamageBase || !spellDamageBase.trim()) return null;
|
||||
|
||||
let formula = spellDamageBase.trim();
|
||||
|
||||
// Replace attribute tokens (with optional /N or *N suffix).
|
||||
// Match e.g. magicalAbilities, magicalAbilities/2, soul*3
|
||||
const tokenRe = /([a-zA-Z]+)(?:\s*([\/*])\s*(\d+))?/g;
|
||||
formula = formula.replace(tokenRe, (match, name: string, op: string | undefined, num: string | undefined) => {
|
||||
if (!ATTR_TOKENS.has(name)) {
|
||||
// Leave alone (likely dice notation like 1d6 — no, dice has digits before)
|
||||
return match;
|
||||
}
|
||||
let v = resolveToken(name, ctx);
|
||||
if (op && num) {
|
||||
const n = Number(num);
|
||||
if (op === '/') v = Math.ceil(v / n); // ceil per HbM rounding rules
|
||||
if (op === '*') v = v * n;
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
return formula;
|
||||
}
|
||||
|
||||
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
|
||||
const formula = buildDamageFormula(spellDamageBase, ctx);
|
||||
if (!formula) return null;
|
||||
const roll = new Roll(formula, {});
|
||||
await roll.evaluate();
|
||||
return roll;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Reactive spell trigger registry.
|
||||
*
|
||||
* Spells with `triggers[]` register flag-based listeners on the caster:
|
||||
* actor.flags.hbm.triggers[] = [{ event, effect, spellId, expiresAt }]
|
||||
*
|
||||
* Hooks check the matching event and prompt/resolve the trigger.
|
||||
*
|
||||
* Supported events:
|
||||
* - killWithWeapon (e.g. Szkarłatny Sztylet — free spell after kill)
|
||||
* - targetCastsSpell (e.g. Klątwa Szkarłatu — opposed save → unconscious)
|
||||
* - damageTaken (reactive shields)
|
||||
* - turnStart (per-turn drains)
|
||||
*
|
||||
* Triggers are stored as world-flag data (serialisable); the runtime hook
|
||||
* dispatches based on `event`. Effect strings are advisory text (GM-resolved)
|
||||
* unless they map to a known machine effect ID.
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
|
||||
export interface SpellTrigger {
|
||||
event: string;
|
||||
effect: string;
|
||||
spellId: string;
|
||||
spellName: string;
|
||||
/** Optional: combat round at which this trigger expires. */
|
||||
expiresAtRound?: number;
|
||||
/** Single-use after fire. */
|
||||
oneShot?: boolean;
|
||||
}
|
||||
|
||||
interface ActorWithFlags {
|
||||
id?: string;
|
||||
name?: string;
|
||||
getFlag?: (scope: string, key: string) => unknown;
|
||||
setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown>;
|
||||
unsetFlag?: (scope: string, key: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function readTriggers(actor: ActorWithFlags): SpellTrigger[] {
|
||||
const raw = actor.getFlag?.(SYSTEM_ID, 'triggers') as SpellTrigger[] | undefined;
|
||||
return Array.isArray(raw) ? raw.slice() : [];
|
||||
}
|
||||
|
||||
async function writeTriggers(actor: ActorWithFlags, triggers: SpellTrigger[]): Promise<void> {
|
||||
if (triggers.length === 0) {
|
||||
await actor.unsetFlag?.(SYSTEM_ID, 'triggers');
|
||||
} else {
|
||||
await actor.setFlag?.(SYSTEM_ID, 'triggers', triggers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerSpellTriggers(
|
||||
actor: ActorWithFlags,
|
||||
spell: { id?: string; name?: string; system?: { triggers?: Array<{ event: string; effect: string }> } },
|
||||
): Promise<number> {
|
||||
const list = spell.system?.triggers ?? [];
|
||||
if (!Array.isArray(list) || list.length === 0) return 0;
|
||||
const existing = readTriggers(actor);
|
||||
const now = (game as any).combat?.round ?? 0;
|
||||
for (const t of list) {
|
||||
existing.push({
|
||||
event: t.event,
|
||||
effect: t.effect,
|
||||
spellId: spell.id ?? '',
|
||||
spellName: spell.name ?? '',
|
||||
expiresAtRound: now + 1, // default: end of next round; spell may override later
|
||||
oneShot: true,
|
||||
});
|
||||
}
|
||||
await writeTriggers(actor, existing);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
export async function fireTriggers(
|
||||
actor: ActorWithFlags,
|
||||
event: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<SpellTrigger[]> {
|
||||
const triggers = readTriggers(actor);
|
||||
const fired: SpellTrigger[] = [];
|
||||
const remaining: SpellTrigger[] = [];
|
||||
for (const t of triggers) {
|
||||
if (t.event === event) {
|
||||
fired.push(t);
|
||||
// Post chat card so GM can resolve the effect manually if no auto-handler.
|
||||
await ChatMessage.create({
|
||||
content: `<strong>${actor.name ?? ''}</strong> — Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`,
|
||||
whisper: ChatMessage.getWhisperRecipients?.('GM') ?? [],
|
||||
});
|
||||
if (!t.oneShot) remaining.push(t);
|
||||
} else {
|
||||
remaining.push(t);
|
||||
}
|
||||
}
|
||||
if (fired.length > 0) await writeTriggers(actor, remaining);
|
||||
return fired;
|
||||
}
|
||||
|
||||
export function registerTriggerHooks(): void {
|
||||
// Combat-end / actor death events feed triggers.
|
||||
// Foundry hooks: 'updateActor' (HP delta), 'createChatMessage' (attack rolls), 'updateCombat'.
|
||||
Hooks.on('updateActor', async (actor: any, change: any) => {
|
||||
const newHp = change?.system?.attributes?.health?.value;
|
||||
if (typeof newHp === 'number' && newHp <= 0) {
|
||||
// Find any actor in combat with a killWithWeapon trigger awaiting fire
|
||||
const combat = (game as any).combat;
|
||||
if (!combat?.started) return;
|
||||
for (const c of combat.combatants ?? []) {
|
||||
const a = c.actor as ActorWithFlags;
|
||||
if (!a) continue;
|
||||
const triggers = readTriggers(a);
|
||||
if (triggers.some((t) => t.event === 'killWithWeapon')) {
|
||||
await fireTriggers(a, 'killWithWeapon', { victim: actor.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Pre-cast validation gates. Runs before any resource deduction or roll.
|
||||
* Returns structured ValidationResult; caller decides whether to block,
|
||||
* warn, or proceed with bypass flags.
|
||||
*/
|
||||
|
||||
import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
|
||||
|
||||
export interface ValidationIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
i18nKey?: string;
|
||||
i18nArgs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
errors: ValidationIssue[]; // hard blocks
|
||||
warnings: ValidationIssue[]; // soft, may be bypassed
|
||||
ok: boolean; // errors.length === 0
|
||||
}
|
||||
|
||||
function getActorRaceId(actor: CastableActor): string {
|
||||
// Prefer embedded race item; fall back to details.raceId / details.race.
|
||||
const raceItem = (actor as any).items?.find?.((i: any) => i.type === 'race');
|
||||
if (raceItem) {
|
||||
return String(raceItem.system?.raceId ?? raceItem.system?.id ?? raceItem.name ?? '').toLowerCase();
|
||||
}
|
||||
const d = (actor as any).system?.details ?? {};
|
||||
return String(d.raceId ?? d.race ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function actorHasTalent(actor: CastableActor, talentId: string): boolean {
|
||||
const id = talentId.toLowerCase();
|
||||
const items = (actor as any).items as Iterable<any> | undefined;
|
||||
if (!items) return false;
|
||||
for (const it of items) {
|
||||
if (it.type !== 'talent') continue;
|
||||
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
|
||||
if (t === id) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
|
||||
const id = disciplineId.toLowerCase();
|
||||
const items = (actor as any).items as Iterable<any> | undefined;
|
||||
if (!items) return false;
|
||||
for (const it of items) {
|
||||
if (it.type !== 'discipline') continue;
|
||||
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '').toLowerCase();
|
||||
if (d === id) return true;
|
||||
}
|
||||
// Also accept characters whose details.discipline matches
|
||||
const detail = String((actor as any).system?.details?.discipline ?? '').toLowerCase();
|
||||
return detail === id;
|
||||
}
|
||||
|
||||
function actorIsInCombat(actor: CastableActor): boolean {
|
||||
const combat = (game as any).combat;
|
||||
if (!combat?.started) return false;
|
||||
return Boolean(combat.combatants?.find?.((c: any) => c.actorId === actor.id));
|
||||
}
|
||||
|
||||
export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): ValidationResult {
|
||||
const errors: ValidationIssue[] = [];
|
||||
const warnings: ValidationIssue[] = [];
|
||||
|
||||
const sys = spell.system as any;
|
||||
const a = (actor as any).system?.attributes ?? {};
|
||||
const mode = sys.castingMode ?? 'standard';
|
||||
|
||||
// Resource gates
|
||||
const baseMana = Math.max(0, Number(sys.manaCost ?? 0));
|
||||
const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana));
|
||||
const bloodSpent = Math.max(0, Math.floor(opts.bloodSpent ?? Number(sys.bloodCost ?? 0)));
|
||||
|
||||
if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) {
|
||||
if (manaSpent > Number(a.mana?.value ?? 0)) {
|
||||
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' });
|
||||
}
|
||||
}
|
||||
if (mode === 'sacred' && opts.hekateMode !== 'witch') {
|
||||
const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
|
||||
if (zealNeeded > Number(a.zeal?.value ?? 0)) {
|
||||
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
|
||||
}
|
||||
}
|
||||
if (mode === 'blood') {
|
||||
if (bloodSpent > Number(a.blood?.value ?? 0)) {
|
||||
errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' });
|
||||
}
|
||||
}
|
||||
|
||||
// Race gate
|
||||
const raceReq: string[] = Array.isArray(sys.requirements?.race) ? sys.requirements.race : [];
|
||||
if (raceReq.length > 0) {
|
||||
const actorRace = getActorRaceId(actor);
|
||||
const ok = raceReq.some((r) => r.toLowerCase() === actorRace);
|
||||
if (!ok) {
|
||||
const translatedRaces = raceReq.map(r => {
|
||||
const key = r.toLowerCase();
|
||||
return game.i18n.has(`HBM.racesList.${key}`) ? game.i18n.localize(`HBM.racesList.${key}`) : r;
|
||||
}).join(', ');
|
||||
errors.push({
|
||||
code: 'race-locked', message: `Spell requires race: ${raceReq.join(', ')}`,
|
||||
i18nKey: 'HBM.spellCast.raceLocked', i18nArgs: { races: translatedRaces },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Talent gate
|
||||
const talentReq: string[] = Array.isArray(sys.requirements?.talent) ? sys.requirements.talent : [];
|
||||
for (const t of talentReq) {
|
||||
if (!actorHasTalent(actor, t)) {
|
||||
errors.push({
|
||||
code: 'talent-missing', message: `Missing required talent: ${t}`,
|
||||
i18nKey: 'HBM.spellCast.talentMissing', i18nArgs: { talent: t },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discipline gate (any-of)
|
||||
const discReq: string[] = Array.isArray(sys.requirements?.discipline) ? sys.requirements.discipline : [];
|
||||
if (discReq.length > 0) {
|
||||
const ok = discReq.some((d) => actorHasDiscipline(actor, d));
|
||||
if (!ok) {
|
||||
const translatedDisciplines = discReq.map(d => {
|
||||
return game.i18n.has(`HBM.spellSchool.${d}`) ? game.i18n.localize(`HBM.spellSchool.${d}`) : d;
|
||||
}).join(', ');
|
||||
errors.push({
|
||||
code: 'discipline-missing', message: `Requires one of disciplines: ${discReq.join(', ')}`,
|
||||
i18nKey: 'HBM.spellCast.disciplineMissing', i18nArgs: { disciplines: translatedDisciplines },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Deity gate (sacred + non-common)
|
||||
const deity = String(sys.deity ?? '').trim();
|
||||
if ((sys.school === 'sacred' || mode === 'sacred') && deity && deity !== 'common') {
|
||||
const actorDeity = String((actor as any).system?.details?.deity ?? '').trim();
|
||||
if (actorDeity && actorDeity !== deity) {
|
||||
warnings.push({
|
||||
code: 'deity-mismatch',
|
||||
message: `Spell deity (${deity}) differs from devoted deity (${actorDeity})`,
|
||||
i18nKey: 'HBM.spellCast.deityMismatch', i18nArgs: { spell: deity, actor: actorDeity },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Group cast
|
||||
if (sys.requiresGroupCast) {
|
||||
const min = Math.max(1, Number(sys.minCasters ?? 1));
|
||||
const provided = (opts.groupCasters?.length ?? 0) + 1;
|
||||
if (provided < min) {
|
||||
errors.push({
|
||||
code: 'group-cast', message: `Requires ${min} co-casters; have ${provided}`,
|
||||
i18nKey: 'HBM.spellCast.groupCastRequired', i18nArgs: { min, provided },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Witch symbol cap
|
||||
if (mode === 'witch' || opts.hekateMode === 'witch') {
|
||||
const required = Math.max(0, Number(sys.complexityLevel ?? sys.components?.symbols?.length ?? 0));
|
||||
const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2);
|
||||
if (required > maxSymbols) {
|
||||
errors.push({
|
||||
code: 'witch-symbols', message: `Symbols ${required}/${maxSymbols}`,
|
||||
i18nKey: 'HBM.spellCast.witchSymbols', i18nArgs: { used: required, max: maxSymbols },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Non-combat-only spells
|
||||
if (sys.nonCombatOnly && actorIsInCombat(actor) && !opts.bypassNonCombatBlock) {
|
||||
errors.push({
|
||||
code: 'non-combat', message: 'Spell cannot be cast in combat',
|
||||
i18nKey: 'HBM.spellCast.nonCombatOnly',
|
||||
});
|
||||
}
|
||||
|
||||
// Superspell warning (non-blocking)
|
||||
if (sys.isSuperspell && actorIsInCombat(actor) && !opts.bypassSuperspellWarning) {
|
||||
warnings.push({
|
||||
code: 'superspell-combat',
|
||||
message: 'Casting superspell during combat is generally inadvisable',
|
||||
i18nKey: 'HBM.spellCast.superspellInCombat',
|
||||
});
|
||||
}
|
||||
|
||||
return { errors, warnings, ok: errors.length === 0 };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Trade logic — founding companies, transactions, smuggling.
|
||||
* Source: Złoto Stal i Magia, Rozdział IX.
|
||||
*
|
||||
* The book's exact difficulty formulas depend on commodity, route, and party
|
||||
* skill, so this module exposes the *primitives*; the cast dialog/UI passes
|
||||
* the threshold and required-successes derived from the table.
|
||||
*
|
||||
* State: a "company" is just a JournalEntry created in a configurable folder;
|
||||
* here we only expose the rolling helpers.
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export interface TradeTestParams {
|
||||
/** TS threshold, default 5. */
|
||||
threshold?: number;
|
||||
/** Required successes, default 2. */
|
||||
required?: number;
|
||||
/** Skill key contributing to the pool (e.g. `tradeAndPersuasion`). */
|
||||
skill?: string;
|
||||
/** Attribute key whose `value` adds to the pool (default `mind`). */
|
||||
attribute?: 'body' | 'mind' | 'soul' | 'magic';
|
||||
/** Free-form description for the chat card. */
|
||||
flavor?: string;
|
||||
}
|
||||
|
||||
function rollTrade(actor: CastableActor, label: string, params: TradeTestParams): Promise<HbmTSRoll> {
|
||||
const attribute = params.attribute ?? 'mind';
|
||||
const attrVal = (actor.system.attributes as any)[attribute]?.value ?? 0;
|
||||
const skillVal = params.skill ? (actor.system.skills?.[params.skill]?.value ?? 0) : 0;
|
||||
const pool = attrVal + skillVal;
|
||||
const flavor = params.flavor ?? `${label}: ${actor.name}`;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: params.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: params.required ?? TS_DEFAULT_REQUIRED,
|
||||
flavor,
|
||||
});
|
||||
return roll.evaluate().then(async () => {
|
||||
await roll.toMessage({ flavor });
|
||||
return roll;
|
||||
});
|
||||
}
|
||||
|
||||
/** Founding a trading company — usually a single TS test plus capital. */
|
||||
export function foundCompany(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
return rollTrade(actor, 'Założenie Firmy Handlowej', params);
|
||||
}
|
||||
|
||||
/** Standard legal transaction. */
|
||||
export function transaction(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
return rollTrade(actor, 'Transakcja', params);
|
||||
}
|
||||
|
||||
/** Smuggling — illegal transaction; failure should fire `hbm.smugglingFailed`. */
|
||||
export async function smuggling(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
const roll = await rollTrade(actor, 'Przemyt', params);
|
||||
if (!roll.ts?.isSuccess) {
|
||||
Hooks.callAll('hbm.smugglingFailed', actor, roll);
|
||||
}
|
||||
return roll;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Migration to v0.4.0 SpellData schema (numbered 0.2.0 per planning doc).
|
||||
*
|
||||
* Operations:
|
||||
* - For each Item of type 'spell' (in world & embedded on actors):
|
||||
* • If `components.symbols` is empty AND `complexityLevel > 0`,
|
||||
* leave symbols empty (cannot infer names) but log so user can fix.
|
||||
* • If `targets` matches a recognised AoE pattern, populate `areaOfEffect`.
|
||||
* • If legacy `overcasting` text is non-empty AND `overcastOptions` is empty,
|
||||
* seed `overcastOptions[0] = { description: <text>, manaPerStep: 1 }`.
|
||||
* - Add `attributes.blood = { value: 0, max: 0 }` to characters missing it
|
||||
* (DataModel default usually handles this; explicit safety net).
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
import type { MigrationStep } from './index';
|
||||
|
||||
interface LegacySpellSystem {
|
||||
targets?: string;
|
||||
overcasting?: string;
|
||||
overcastOptions?: Array<{ description: string; manaPerStep: number }>;
|
||||
areaOfEffect?: { shape: string; x: number; y: number; unit: string };
|
||||
components?: { symbols?: string[] };
|
||||
complexityLevel?: number;
|
||||
}
|
||||
|
||||
const AOE_PATTERNS: Array<{ re: RegExp; build: (m: RegExpMatchArray) => { shape: string; x: number; y: number; unit: string } }> = [
|
||||
// "3 × 8 m" / "3x8 m" → rectangle
|
||||
{ re: /(\d+)\s*[×x]\s*(\d+)\s*m/i, build: (m) => ({ shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: 'm' }) },
|
||||
// "promień 5 m" → sphere
|
||||
{ re: /promień\s+(\d+)\s*m/i, build: (m) => ({ shape: 'sphere', x: Number(m[1]), y: 0, unit: 'm' }) },
|
||||
// "stożek 10 m" → cone
|
||||
{ re: /stoż\w+\s+(\d+)\s*m/i, build: (m) => ({ shape: 'cone', x: Number(m[1]), y: 0, unit: 'm' }) },
|
||||
// "linia 15 m" → line
|
||||
{ re: /linia\s+(\d+)\s*m/i, build: (m) => ({ shape: 'line', x: Number(m[1]), y: 0, unit: 'm' }) },
|
||||
];
|
||||
|
||||
function migrateSpellDoc(doc: any): Record<string, unknown> | null {
|
||||
const sys = (doc.system ?? {}) as LegacySpellSystem;
|
||||
const updates: Record<string, unknown> = {};
|
||||
let dirty = false;
|
||||
|
||||
// AoE inference
|
||||
const isPoint = !sys.areaOfEffect || (sys.areaOfEffect.shape === 'point' && !sys.areaOfEffect.x);
|
||||
if (isPoint && sys.targets) {
|
||||
for (const { re, build } of AOE_PATTERNS) {
|
||||
const m = sys.targets.match(re);
|
||||
if (m) {
|
||||
updates['system.areaOfEffect'] = build(m);
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overcast text → structured option
|
||||
const noStructuredOvercast = !sys.overcastOptions || sys.overcastOptions.length === 0;
|
||||
if (sys.overcasting && noStructuredOvercast) {
|
||||
updates['system.overcastOptions'] = [{ description: sys.overcasting, manaPerStep: 1 }];
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// Witch symbols audit log
|
||||
if ((sys.complexityLevel ?? 0) > 0 && (!sys.components?.symbols || sys.components.symbols.length === 0)) {
|
||||
console.warn(`${SYSTEM_ID} | Spell "${doc.name}" has complexityLevel=${sys.complexityLevel} but no symbols list; please populate components.symbols manually.`);
|
||||
}
|
||||
|
||||
return dirty ? updates : null;
|
||||
}
|
||||
|
||||
async function migrateAllSpells(): Promise<void> {
|
||||
// World items
|
||||
const worldItems = (game.items?.contents ?? []) as any[];
|
||||
for (const item of worldItems) {
|
||||
if (item.type !== 'spell') continue;
|
||||
const updates = migrateSpellDoc(item);
|
||||
if (updates) await item.update(updates);
|
||||
}
|
||||
|
||||
// Actor-embedded items
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const actor of actors) {
|
||||
const items = (actor.items?.contents ?? []) as any[];
|
||||
for (const item of items) {
|
||||
if (item.type !== 'spell') continue;
|
||||
const updates = migrateSpellDoc(item);
|
||||
if (updates) await item.update(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBloodPool(): Promise<void> {
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const actor of actors) {
|
||||
if (actor.type !== 'character') continue;
|
||||
const blood = actor.system?.attributes?.blood;
|
||||
if (!blood || typeof blood.max !== 'number') {
|
||||
await actor.update({ 'system.attributes.blood': { value: 0, max: 0 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const migration_0_2_0: MigrationStep = {
|
||||
version: '0.4.0',
|
||||
description: 'Expand SpellData schema, add Blood Pool to characters',
|
||||
run: async () => {
|
||||
await migrateAllSpells();
|
||||
await ensureBloodPool();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Migration to v1.2.0.
|
||||
*
|
||||
* Operations:
|
||||
* - Set default `flags['hbm-rpg-v3'].zealRegenBonus = 0` on all character actors
|
||||
* that don't have it (so the combat-turn helper can always read a number).
|
||||
* - Backfill NPC `attributes.{mana, zeal, blood}` for any NPC actors stored
|
||||
* pre-v1.1.2 (the schema defaults handle it on read, but persist on save).
|
||||
* - Best-effort: scan world & embedded talents for descriptions matching
|
||||
* "+1 Zapał" / "regeneracja zapału" and attach a transferable ActiveEffect
|
||||
* that adds `flags.hbm-rpg-v3.zealRegenBonus = 1` (only if no AE present).
|
||||
*/
|
||||
|
||||
import type { MigrationStep } from './index';
|
||||
|
||||
const ZEAL_REGEN_PATTERNS = [
|
||||
/\+\s*1\s*zapa[łl]/i,
|
||||
/regeneracj\w+\s+zapa[łl]u/i,
|
||||
/odzyskuje\s+\+?1\s+zapa[łl]/i,
|
||||
];
|
||||
|
||||
function shouldAttachZealRegenAE(item: any): boolean {
|
||||
if (item?.type !== 'talent') return false;
|
||||
const text = `${item.system?.description ?? ''} ${item.system?.effect ?? ''}`;
|
||||
if (!text.trim()) return false;
|
||||
if (!ZEAL_REGEN_PATTERNS.some((re) => re.test(text))) return false;
|
||||
// Skip if this talent already has an AE targeting the flag.
|
||||
for (const ef of item.effects ?? []) {
|
||||
for (const ch of ef.changes ?? []) {
|
||||
if (ch.key === 'flags.hbm-rpg-v3.zealRegenBonus') return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function attachZealRegenAE(item: any): Promise<void> {
|
||||
await item.createEmbeddedDocuments('ActiveEffect', [{
|
||||
name: '+1 regeneracja Zapału',
|
||||
icon: 'icons/svg/lightning.svg',
|
||||
transfer: true,
|
||||
disabled: false,
|
||||
changes: [{
|
||||
key: 'flags.hbm-rpg-v3.zealRegenBonus',
|
||||
value: '1',
|
||||
mode: 2, // CONST.ACTIVE_EFFECT_MODES.ADD
|
||||
priority: 20,
|
||||
}],
|
||||
}]);
|
||||
}
|
||||
|
||||
async function migrateActorFlags(): Promise<void> {
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const a of actors) {
|
||||
if (a.type !== 'character') continue;
|
||||
const cur = a.getFlag?.('hbm-rpg-v3', 'zealRegenBonus');
|
||||
if (cur == null) {
|
||||
await a.setFlag('hbm-rpg-v3', 'zealRegenBonus', 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateTalents(): Promise<void> {
|
||||
// World talents
|
||||
const worldItems = (game.items?.contents ?? []) as any[];
|
||||
for (const it of worldItems) {
|
||||
if (shouldAttachZealRegenAE(it)) await attachZealRegenAE(it);
|
||||
}
|
||||
// Actor-embedded talents
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const a of actors) {
|
||||
for (const it of a.items ?? []) {
|
||||
if (shouldAttachZealRegenAE(it)) await attachZealRegenAE(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillNpcResources(): Promise<void> {
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const a of actors) {
|
||||
if (a.type !== 'npc') continue;
|
||||
const sys = a.system as any;
|
||||
const update: Record<string, unknown> = {};
|
||||
if (sys.attributes?.mana == null) update['system.attributes.mana'] = { value: 0, max: 0, maxPerSpell: 0 };
|
||||
if (sys.attributes?.zeal == null) update['system.attributes.zeal'] = { value: 0, max: 0 };
|
||||
if (sys.attributes?.blood == null) update['system.attributes.blood'] = { value: 0, max: 0 };
|
||||
if (Object.keys(update).length > 0) await a.update(update);
|
||||
}
|
||||
}
|
||||
|
||||
export const migration_1_2_0: MigrationStep = {
|
||||
version: '1.2.0',
|
||||
description: 'v1.2.0: zealRegenBonus flag, NPC resource backfill, ActiveEffect attachment for +1 Zeal talents',
|
||||
async run() {
|
||||
await migrateActorFlags();
|
||||
await backfillNpcResources();
|
||||
await migrateTalents();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* World data migration runner. Compares stored `flags.hbm.lastMigratedVersion`
|
||||
* against the system version and applies any pending migrations in order.
|
||||
*
|
||||
* Each migration module exports `run(): Promise<void>` and a numeric `targetVersion`
|
||||
* (semver-style string). Migrations are idempotent.
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
import { migration_0_2_0 } from './0.2.0';
|
||||
import { migration_1_2_0 } from './1.2.0';
|
||||
|
||||
export interface MigrationStep {
|
||||
version: string; // version this migration brings the world TO
|
||||
description: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MIGRATIONS: MigrationStep[] = [
|
||||
migration_0_2_0,
|
||||
migration_1_2_0,
|
||||
];
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const da = pa[i] ?? 0;
|
||||
const db = pb[i] ?? 0;
|
||||
if (da !== db) return da - db;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runPendingMigrations(): Promise<void> {
|
||||
if (!game.user?.isGM) return;
|
||||
const sys = game.system as unknown as { version: string };
|
||||
const currentVersion = sys.version ?? '0.0.0';
|
||||
const last = (game.settings.get(SYSTEM_ID, 'lastMigratedVersion') as string | undefined) ?? '0.0.0';
|
||||
|
||||
if (compareSemver(last, currentVersion) >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${SYSTEM_ID} | Running migrations: ${last} → ${currentVersion}`);
|
||||
for (const step of MIGRATIONS) {
|
||||
if (compareSemver(last, step.version) < 0 && compareSemver(step.version, currentVersion) <= 0) {
|
||||
console.log(`${SYSTEM_ID} | Migration ${step.version}: ${step.description}`);
|
||||
try {
|
||||
await step.run();
|
||||
} catch (err) {
|
||||
console.error(`${SYSTEM_ID} | Migration ${step.version} failed`, err);
|
||||
ui.notifications?.error(`HbM migration ${step.version} failed — see console.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await game.settings.set(SYSTEM_ID, 'lastMigratedVersion', currentVersion);
|
||||
ui.notifications?.info(`HbM RPG v3 migrated to ${currentVersion}.`);
|
||||
}
|
||||
|
||||
export function registerMigrationSettings(): void {
|
||||
game.settings.register(SYSTEM_ID, 'lastMigratedVersion', {
|
||||
name: 'Last migrated system version',
|
||||
scope: 'world',
|
||||
config: false,
|
||||
type: String,
|
||||
default: '0.0.0',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Character sheet — Foundry v13 ApplicationV2 + Handlebars.
|
||||
* Features: tabs, roll-dialogs, drag-and-drop item creation.
|
||||
*/
|
||||
|
||||
import { rollSkill, rollAttribute, rollInitiative } from '../dice/macros';
|
||||
import { castSpell } from '../logic/spell-cast';
|
||||
import { askRollParams } from '../dice/roll-dialog';
|
||||
import { askCastOptions } from '../dice/cast-dialog';
|
||||
import { askApplyDamage } from '../dice/damage-dialog';
|
||||
import { applyDamage } from '../logic/damage';
|
||||
import { rest } from '../logic/rest';
|
||||
import { ATTRIBUTES, AttributeKey, SKILL_KEYS } from '../constants';
|
||||
|
||||
const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
|
||||
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
|
||||
};
|
||||
const { HandlebarsApplicationMixin } = foundry.applications.api as unknown as {
|
||||
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => T;
|
||||
};
|
||||
|
||||
export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
static override DEFAULT_OPTIONS = {
|
||||
classes: ['hbm', 'sheet', 'character'],
|
||||
position: { width: 760, height: 720 },
|
||||
window: { resizable: true, title: 'HBM.actor.character' },
|
||||
actions: {
|
||||
rollSkill: CharacterSheet._onRollSkill,
|
||||
rollAttribute: CharacterSheet._onRollAttribute,
|
||||
rollInitiative: CharacterSheet._onRollInitiative,
|
||||
castSpell: CharacterSheet._onCastSpell,
|
||||
deleteItem: CharacterSheet._onDeleteItem,
|
||||
editItem: CharacterSheet._onEditItem,
|
||||
applyDamage: CharacterSheet._onApplyDamage,
|
||||
takeBreather: CharacterSheet._onTakeBreather,
|
||||
shortRest: CharacterSheet._onShortRest,
|
||||
longRest: CharacterSheet._onLongRest,
|
||||
actorEffectCreate: CharacterSheet._onActorEffectCreate,
|
||||
actorEffectToggle: CharacterSheet._onActorEffectToggle,
|
||||
actorEffectEdit: CharacterSheet._onActorEffectEdit,
|
||||
actorEffectDelete: CharacterSheet._onActorEffectDelete,
|
||||
addArrayEntry: CharacterSheet._onAddArrayEntry,
|
||||
removeArrayEntry: CharacterSheet._onRemoveArrayEntry,
|
||||
editImage: CharacterSheet._onEditImage,
|
||||
toggleEquipped: CharacterSheet._onToggleEquipped,
|
||||
recalculateMoney: CharacterSheet._onRecalculateMoney,
|
||||
},
|
||||
form: {
|
||||
submitOnChange: true,
|
||||
closeOnSubmit: false,
|
||||
},
|
||||
};
|
||||
|
||||
static override PARTS = {
|
||||
main: { template: 'systems/hbm-rpg-v3/templates/actor/character.hbs' },
|
||||
};
|
||||
|
||||
/** Active tab per group — persists across re-renders. */
|
||||
tabGroups: Record<string, string> = { primary: 'stats' };
|
||||
|
||||
override async _prepareContext(options: unknown) {
|
||||
const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
|
||||
const actor = (this as unknown as { actor: { system: unknown; items: any[] } }).actor;
|
||||
const spells = actor.items.filter((it: any) => it.type === 'spell');
|
||||
const gear = actor.items.filter((it: any) => it.type === 'gear');
|
||||
const abilities = actor.items.filter((it: any) => it.type === 'ability');
|
||||
const talents = actor.items.filter((it: any) => it.type === 'talent');
|
||||
|
||||
// Group spells by school for sheet display
|
||||
const spellsBySchool = new Map<string, any[]>();
|
||||
for (const sp of spells) {
|
||||
const s = sp.system?.school || 'standard';
|
||||
if (!spellsBySchool.has(s)) spellsBySchool.set(s, []);
|
||||
spellsBySchool.get(s)!.push(sp);
|
||||
}
|
||||
const spellSchoolGroups = [...spellsBySchool.entries()]
|
||||
.map(([school, items]) => ({
|
||||
school,
|
||||
label: game.i18n.localize(`HBM.spellSchool.${school}`),
|
||||
items: items.sort((a: any, b: any) => (a.system?.circle ?? 0) - (b.system?.circle ?? 0)),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'pl'));
|
||||
|
||||
const sys = actor.system as any;
|
||||
const hasBloodMagic = sys?.details?.discipline === 'Magia Krwi' || sys?.details?.discipline === 'Blood Magic';
|
||||
const elixirCap = (sys?.attributes?.body?.value ?? 0) + 1;
|
||||
|
||||
const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({
|
||||
id: ef.id,
|
||||
name: ef.name,
|
||||
icon: ef.icon ?? 'icons/svg/aura.svg',
|
||||
disabled: ef.disabled ?? false,
|
||||
isTransferred: !!ef.origin,
|
||||
originName: ef.origin ? (actor.items.find((it: any) => ef.origin?.endsWith(it.id))?.name ?? ef.origin) : '',
|
||||
changes: ef.changes ?? [],
|
||||
}));
|
||||
|
||||
// Pre-map attributes and skills to prevent lookup context issues in Handlebars
|
||||
const attributes = ATTRIBUTES.map(key => {
|
||||
const attrObj = sys.attributes?.[key];
|
||||
const actualVal = attrObj?.actual !== undefined ? attrObj.actual : (attrObj?.value ?? 0);
|
||||
const labels = ['0', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
|
||||
const actualLabel = labels[Math.max(0, Math.min(10, actualVal))] || String(actualVal);
|
||||
return {
|
||||
key,
|
||||
value: attrObj?.value ?? 0,
|
||||
actual: actualVal,
|
||||
actualLabel,
|
||||
label: game.i18n.localize(`HBM.attributes.${key}`)
|
||||
};
|
||||
});
|
||||
|
||||
const skills = SKILL_KEYS.map(key => ({
|
||||
key,
|
||||
value: sys.skills?.[key]?.value ?? 0,
|
||||
label: game.i18n.localize(`HBM.skills.${key}`)
|
||||
}));
|
||||
|
||||
const races = ['czlowiek', 'elf', 'lamia', 'feles', 'aniol'].map(key => ({
|
||||
key,
|
||||
label: game.i18n.localize(`HBM.racesList.${key}`)
|
||||
}));
|
||||
|
||||
const disciplines = [
|
||||
'alchemyTransmutation', 'alchemyBrewing', 'botany',
|
||||
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
|
||||
'artifice', 'golemancy', 'runes', 'manaSourceMage',
|
||||
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'blood'
|
||||
].map(key => ({
|
||||
key,
|
||||
label: game.i18n.localize(`HBM.spellSchool.${key}`)
|
||||
}));
|
||||
|
||||
// Filter gear categories for tabular sheet display
|
||||
const weapons = gear.filter((it: any) => it.system?.category === 'weapon');
|
||||
const armors = gear.filter((it: any) => it.system?.category === 'armor');
|
||||
const equipment = gear.filter((it: any) => it.system?.category === 'equipment');
|
||||
|
||||
// Localized talents list no longer needed (dropdown removed — drag from compendium)
|
||||
|
||||
return {
|
||||
...ctx,
|
||||
system: actor.system,
|
||||
attributes,
|
||||
skills,
|
||||
races,
|
||||
disciplines,
|
||||
tabGroups: this.tabGroups,
|
||||
attributeKeys: ATTRIBUTES,
|
||||
skillKeys: SKILL_KEYS,
|
||||
spells,
|
||||
spellSchoolGroups,
|
||||
hasBloodMagic,
|
||||
elixirCap,
|
||||
gear,
|
||||
weapons,
|
||||
armors,
|
||||
equipment,
|
||||
abilities,
|
||||
talents,
|
||||
actorEffects,
|
||||
tabs: this._prepareTabs(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build tab metadata for the template. */
|
||||
private _prepareTabs() {
|
||||
const active = this.tabGroups['primary'] ?? 'stats';
|
||||
return ['stats', 'actions', 'skills', 'spells', 'talents', 'inventory', 'effects', 'biography'].map((id) => ({
|
||||
id,
|
||||
label: `HBM.ui.tabs.${id}`,
|
||||
active: active === id,
|
||||
cssClass: active === id ? 'active' : '',
|
||||
}));
|
||||
}
|
||||
|
||||
override _onRender(context: unknown, options: unknown) {
|
||||
super._onRender(context, options);
|
||||
const html = this.element;
|
||||
|
||||
// Tab navigation
|
||||
html.querySelectorAll<HTMLElement>('.tabs .tab-item').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.dataset.tab;
|
||||
if (!tab) return;
|
||||
this.tabGroups['primary'] = tab;
|
||||
// Update active state without full re-render
|
||||
html.querySelectorAll('.tabs .tab-item').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
html.querySelectorAll<HTMLElement>('.tab-panel').forEach((p) => {
|
||||
p.classList.toggle('active', p.dataset.tab === tab);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Drag-and-drop: accept items dragged from compendium / sidebar
|
||||
const body = html.querySelector<HTMLElement>('.sheet-body');
|
||||
if (body) {
|
||||
body.addEventListener('dragover', (ev) => { ev.preventDefault(); });
|
||||
body.addEventListener('drop', (ev) => this._handleDrop(ev));
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // prevent ActorSheetV2 base from also handling this
|
||||
const data = (foundry.applications as any).ux.TextEditor.implementation.getDragEventData(event);
|
||||
if (!data || data['type'] !== 'Item') return;
|
||||
const item = await fromUuid<any>(data['uuid'] as string);
|
||||
if (!item) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const uuid = data['uuid'] as string;
|
||||
|
||||
// Auto-link Race / Class items by UUID — do NOT create embedded copies.
|
||||
if (item.type === 'race') {
|
||||
await actor.update({ 'system.details.raceId': uuid });
|
||||
return;
|
||||
} else if (item.type === 'class') {
|
||||
const year = Math.max(1, Math.min(4, Number(item.system?.year ?? 1)));
|
||||
const ids = [...((actor.system as any).details?.classIds ?? [])];
|
||||
ids[year - 1] = uuid;
|
||||
await actor.update({ 'system.details.classIds': ids });
|
||||
return;
|
||||
}
|
||||
|
||||
// Talents may always be added multiple times (each rank/copy is a distinct embedded item).
|
||||
// All other types: deduplicate by source slug or by matching the exact same document id.
|
||||
if (item.type !== 'talent') {
|
||||
const sourceSlug: string | undefined = item.flags?.['hbm-rpg-v3']?.slug;
|
||||
const existing = actor.items.find((it: any) => {
|
||||
if (it.id === item.id) return true; // same document
|
||||
if (sourceSlug && it.flags?.['hbm-rpg-v3']?.slug === sourceSlug) return true;
|
||||
return false;
|
||||
});
|
||||
if (existing) {
|
||||
ui?.notifications?.warn(game.i18n.format('HBM.ui.itemAlreadyOwned', { name: item.name }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'talent' && item.system?.multiSelect) {
|
||||
const match = item.name.match(/(Dziedzina|B\u00f3stwo|Zmys\u0142|Atrybut|Umiej\u0119tno\u015b\u0107)/i);
|
||||
if (match) {
|
||||
const paramType = match[1];
|
||||
const title = game.i18n.localize('HBM.ui.selectTalentParamTitle') || 'Wybierz parametr talentu';
|
||||
const labelText = game.i18n.format('HBM.ui.selectTalentParamDesc', { param: paramType }) || `Wprowad\u017a warto\u015b\u0107 dla parametru (${paramType}):`;
|
||||
|
||||
let specified: string | null = null;
|
||||
await (foundry.applications.api as any).DialogV2.wait({
|
||||
title,
|
||||
content: `
|
||||
<div class="hbm-talent-dialog" style="padding:10px;">
|
||||
<p>${labelText}</p>
|
||||
<input id="hbm-talent-param-input" type="text" placeholder="${paramType}" style="width:100%; margin-bottom:10px;" />
|
||||
</div>`,
|
||||
buttons: [
|
||||
{
|
||||
type: 'button', action: 'confirm',
|
||||
label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a',
|
||||
default: true,
|
||||
callback: (_ev: Event, _btn: any, html: HTMLElement) => {
|
||||
const val = (html.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
|
||||
specified = val || null;
|
||||
},
|
||||
},
|
||||
{ type: 'button', action: 'cancel', label: game.i18n.localize('HBM.ui.cancel') || 'Anuluj' },
|
||||
],
|
||||
rejectClose: false,
|
||||
});
|
||||
|
||||
if (specified) {
|
||||
const obj = item.toObject();
|
||||
obj.name = item.name.replace(/\([^)]+\)$/, `(${specified})`);
|
||||
await actor.createEmbeddedDocuments('Item', [obj]);
|
||||
return;
|
||||
} else {
|
||||
return; // cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await actor.createEmbeddedDocuments('Item', [item.toObject()]);
|
||||
}
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
static async _onRollSkill(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const skillKey = target.dataset.skill;
|
||||
if (!skillKey) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
|
||||
// Determine default pool so the dialog can show it
|
||||
const skill = actor.system.skills?.[skillKey];
|
||||
const attrKey = (skill?.defaultAttribute ?? 'body') as AttributeKey;
|
||||
const pool = (actor.system.attributes[attrKey]?.value ?? 0) + (skill?.value ?? 0);
|
||||
|
||||
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) }) });
|
||||
if (!params) return;
|
||||
|
||||
await rollSkill(actor, skillKey, {
|
||||
threshold: params.threshold,
|
||||
required: params.required,
|
||||
flavor: params.flavor,
|
||||
});
|
||||
}
|
||||
|
||||
static async _onRollAttribute(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const attr = target.dataset.attribute as AttributeKey | undefined;
|
||||
if (!attr) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
|
||||
const pool = actor.system.attributes[attr]?.value ?? 0;
|
||||
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }) });
|
||||
if (!params) return;
|
||||
|
||||
await rollAttribute(actor, attr, {
|
||||
threshold: params.threshold,
|
||||
required: params.required,
|
||||
});
|
||||
}
|
||||
|
||||
static async _onCastSpell(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const spell = actor.items.get(itemId);
|
||||
if (!spell || spell.type !== 'spell') return;
|
||||
|
||||
const opts = await askCastOptions(spell, actor);
|
||||
if (!opts) return;
|
||||
opts.speaker = ChatMessage.getSpeaker({ actor });
|
||||
|
||||
await castSpell(actor, spell, opts);
|
||||
}
|
||||
|
||||
static async _onDeleteItem(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const item = actor.items.get(itemId);
|
||||
if (!item) return;
|
||||
await item.delete();
|
||||
}
|
||||
|
||||
static async _onEditItem(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const item = actor.items.get(itemId);
|
||||
if (!item) return;
|
||||
item.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onRollInitiative(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
await rollInitiative(actor);
|
||||
}
|
||||
|
||||
static async _onApplyDamage(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const params = await askApplyDamage(actor);
|
||||
if (!params) return;
|
||||
await applyDamage(actor, params);
|
||||
}
|
||||
|
||||
static async _onTakeBreather(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const r = await rest(actor, 'breather');
|
||||
await ChatMessage.create({
|
||||
content: `<strong>${actor.name}</strong> — Chwila Wytchnienia: +${r.hpRestored} ŻYW · +${r.manaRestored} MN`,
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
});
|
||||
}
|
||||
|
||||
static async _onShortRest(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const r = await rest(actor, 'short');
|
||||
await ChatMessage.create({
|
||||
content: `<strong>${actor.name}</strong> — Krótki Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN${r.toleranceRecovered ? `, −${r.toleranceRecovered} tolerancja` : ''}`,
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
});
|
||||
}
|
||||
|
||||
static async _onLongRest(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const r = await rest(actor, 'long');
|
||||
await ChatMessage.create({
|
||||
content: `<strong>${actor.name}</strong> — Długi Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN · +${r.zealRestored} ZP · +${r.bloodRestored} krew · −${r.toleranceRecovered} tolerancja`,
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
});
|
||||
}
|
||||
|
||||
static async _onActorEffectCreate(this: CharacterSheet) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const created = await actor.createEmbeddedDocuments('ActiveEffect', [{
|
||||
name: game.i18n.localize('HBM.activeEffect.newEffect'),
|
||||
icon: 'icons/svg/aura.svg',
|
||||
disabled: false,
|
||||
changes: [],
|
||||
}]);
|
||||
created[0]?.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onActorEffectToggle(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const effect = actor.effects.get(id);
|
||||
if (!effect) return;
|
||||
await effect.update({ disabled: !effect.disabled });
|
||||
}
|
||||
|
||||
static async _onActorEffectEdit(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
actor.effects.get(id)?.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onActorEffectDelete(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
await actor.deleteEmbeddedDocuments('ActiveEffect', [id]);
|
||||
}
|
||||
|
||||
static async _onAddArrayEntry(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const path = target.dataset.path;
|
||||
if (!path) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const current = (foundry.utils.getProperty(actor, path) ?? []) as unknown[];
|
||||
const template = target.dataset.template;
|
||||
const entry = template ? JSON.parse(template) : '';
|
||||
await actor.update({ [path]: [...current, entry] });
|
||||
}
|
||||
|
||||
static async _onRemoveArrayEntry(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const path = target.dataset.path;
|
||||
const idx = Number(target.dataset.index);
|
||||
if (!path || Number.isNaN(idx)) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const current = ([...(foundry.utils.getProperty(actor, path) ?? [])] as unknown[]);
|
||||
current.splice(idx, 1);
|
||||
await actor.update({ [path]: current });
|
||||
}
|
||||
|
||||
|
||||
static async _onEditImage(this: CharacterSheet, event: PointerEvent, target: HTMLElement) {
|
||||
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const current = actor.img;
|
||||
const fp = new FilePicker({
|
||||
type: "image",
|
||||
current: current,
|
||||
callback: (path: string) => {
|
||||
actor.update({ img: path });
|
||||
},
|
||||
top: this.position.top + 40,
|
||||
left: this.position.left + 10
|
||||
});
|
||||
return fp.browse();
|
||||
}
|
||||
|
||||
static async _onToggleEquipped(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const item = actor.items.get(itemId);
|
||||
if (!item) return;
|
||||
await item.update({ 'system.equipped': !item.system.equipped });
|
||||
}
|
||||
|
||||
static async _onRecalculateMoney(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const pln = actor.system.details?.money ?? 0;
|
||||
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> => {
|
||||
if (year >= 2002 && year <= currentRealYear) {
|
||||
for (let day = 1; day <= 7; day++) {
|
||||
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 isLive = !!(eurData || usdData);
|
||||
|
||||
const CURRENCIES: Record<string, { label: string; toPln: number; note: string }> = {
|
||||
PLN: { label: 'PLN (Złoty)', toPln: 1, note: '' },
|
||||
EUR: { label: 'EUR (Euro)', toPln: eurRate, note: `NBP ${rateDate}` },
|
||||
USD: { label: 'USD (Dolar)', toPln: usdRate, note: `NBP ${rateDate}` },
|
||||
TH: { label: 'TH (Thrakka)', toPln: eurRate, note: '1:1 z EUR' },
|
||||
FC: { label: 'FC (Kredyt Federacji Sol-3)', toPln: 2.0, note: 'stały kurs 2 PLN = 1 FC' },
|
||||
ST: { label: 'ST (Srebrny Talent)', toPln: 240, note: '1 ST = 240 PLN' },
|
||||
ZK: { label: 'ZK (Złota Korona)', toPln: 2880, note: '1 ZK = 12 ST' },
|
||||
PL: { label: 'PL (Platynowy Lingot)', toPln: 34560, note: '1 PL = 12 ZK' },
|
||||
};
|
||||
const currKeys = Object.keys(CURRENCIES);
|
||||
const optHtml = (sel: string) => currKeys.map(k =>
|
||||
`<option value="${k}"${k === sel ? ' selected' : ''}>${CURRENCIES[k].label}</option>`
|
||||
).join('');
|
||||
const ratesRows = currKeys.map(k => {
|
||||
const c = CURRENCIES[k];
|
||||
const fromPln = k === 'PLN' ? '1.0000' : (1 / c.toPln).toPrecision(4);
|
||||
return `<tr><td>${c.label}</td><td style="text-align:right;font-family:monospace;">${c.toPln === 1 ? '1.0000' : c.toPln.toFixed(4)}</td><td style="text-align:right;font-family:monospace;">${fromPln}</td><td style="color:#777;font-size:0.75rem;">${c.note}</td></tr>`;
|
||||
}).join('');
|
||||
const ratesJson = JSON.stringify(Object.fromEntries(currKeys.map(k => [k, CURRENCIES[k].toPln])));
|
||||
|
||||
const content = `
|
||||
<div class="hbm money-converter" style="padding:10px;font-family:'Signika',sans-serif;display:flex;flex-direction:column;gap:12px;">
|
||||
<p style="margin:0;font-size:0.78rem;color:${isLive ? '#2a7a3e' : '#888'};">
|
||||
<em>📡 ${isLive ? `Kursy z NBP (${rateDate})` : 'Domyślne kursy (brak połączenia z NBP)'}</em>
|
||||
</p>
|
||||
<div style="background:var(--hbm-card-bg,#1a1a2e);border:1px solid var(--hbm-border,#333);border-radius:6px;padding:8px 12px;">
|
||||
<span style="font-weight:bold;">Gotówka aktora:</span>
|
||||
<span style="font-family:monospace;font-size:1.1rem;margin-left:6px;">${pln} PLN</span>
|
||||
<small style="color:#888;margin-left:6px;">(Rok kampanii: ${currentYear})</small>
|
||||
</div>
|
||||
<div style="background:var(--hbm-card-bg,#1a1a2e);border:1px solid var(--hbm-accent,#6060c0);border-radius:6px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;">
|
||||
<strong style="font-size:0.9rem;">Kalkulator</strong>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<input id="hbm-conv-left" type="number" value="1" min="0" step="any"
|
||||
style="width:6.5rem;font-size:1rem;text-align:right;padding:3px 6px;background:transparent;color:var(--hbm-fg,#eee);border:1px solid var(--hbm-border,#555);border-radius:4px;"
|
||||
oninput="(function(v){var r=window._hbmRates;var lk=document.getElementById('hbm-sf').value;var rk=document.getElementById('hbm-st').value;var res=v*r[lk]/r[rk];document.getElementById('hbm-conv-right').value=isFinite(res)?res.toFixed(4):'';})(this.value)" />
|
||||
<select id="hbm-sf" style="flex:1;min-width:8rem;"
|
||||
onchange="(function(){var v=parseFloat(document.getElementById('hbm-conv-left').value)||0;var r=window._hbmRates;var lk=this.value;var rk=document.getElementById('hbm-st').value;var res=v*r[lk]/r[rk];document.getElementById('hbm-conv-right').value=isFinite(res)?res.toFixed(4):'';}).call(this)">
|
||||
${optHtml('PLN')}
|
||||
</select>
|
||||
<span style="font-size:1.2rem;color:var(--hbm-accent,#8080ff);">⇄</span>
|
||||
<input id="hbm-conv-right" type="number" value="" min="0" step="any"
|
||||
style="width:6.5rem;font-size:1rem;text-align:right;padding:3px 6px;background:transparent;color:var(--hbm-fg,#eee);border:1px solid var(--hbm-border,#555);border-radius:4px;"
|
||||
oninput="(function(v){var r=window._hbmRates;var lk=document.getElementById('hbm-sf').value;var rk=document.getElementById('hbm-st').value;var res=v*r[rk]/r[lk];document.getElementById('hbm-conv-left').value=isFinite(res)?res.toFixed(4):'';}).call(this)" />
|
||||
<select id="hbm-st" style="flex:1;min-width:8rem;"
|
||||
onchange="(function(){var v=parseFloat(document.getElementById('hbm-conv-right').value)||0;var r=window._hbmRates;var lk=document.getElementById('hbm-sf').value;var rk=this.value;var res=v*r[rk]/r[lk];document.getElementById('hbm-conv-left').value=isFinite(res)?res.toFixed(4):'';}).call(this)">
|
||||
${optHtml('EUR')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-size:0.85rem;color:var(--hbm-accent-dim,#999);">▸ Tabela kursów wymiany</summary>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.8rem;margin-top:8px;">
|
||||
<thead><tr style="border-bottom:1px solid var(--hbm-border,#555);">
|
||||
<th style="text-align:left;">Waluta</th><th style="text-align:right;">Kurs (PLN)</th><th style="text-align:right;">1 PLN =</th><th>Uwaga</th>
|
||||
</tr></thead>
|
||||
<tbody>${ratesRows}</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
<script>window._hbmRates=${ratesJson};</script>`;
|
||||
|
||||
await (foundry.applications.api as any).DialogV2.inform({
|
||||
title: game.i18n.localize('HBM.ui.moneyConverterTitle'),
|
||||
content,
|
||||
rejectClose: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Generic Item sheet that delegates the body partial based on the item type.
|
||||
*/
|
||||
|
||||
import {
|
||||
SPELL_SCHOOLS,
|
||||
SACRED_DEITIES,
|
||||
SOURCE_BOOKS,
|
||||
AOE_SHAPES,
|
||||
SPELL_DAMAGE_TYPES,
|
||||
TRIGGER_EVENTS,
|
||||
} from '../constants';
|
||||
|
||||
const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
|
||||
ItemSheetV2: typeof foundry.applications.sheets.ItemSheetV2;
|
||||
};
|
||||
const { HandlebarsApplicationMixin } = foundry.applications.api as unknown as {
|
||||
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => T;
|
||||
};
|
||||
|
||||
const TYPE_PARTIAL: Record<string, string> = {
|
||||
spell: 'systems/hbm-rpg-v3/templates/item/spell.hbs',
|
||||
gear: 'systems/hbm-rpg-v3/templates/item/gear.hbs',
|
||||
ability: 'systems/hbm-rpg-v3/templates/item/ability.hbs',
|
||||
class: 'systems/hbm-rpg-v3/templates/item/class.hbs',
|
||||
race: 'systems/hbm-rpg-v3/templates/item/race.hbs',
|
||||
discipline: 'systems/hbm-rpg-v3/templates/item/discipline.hbs',
|
||||
talent: 'systems/hbm-rpg-v3/templates/item/talent.hbs',
|
||||
};
|
||||
|
||||
export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
|
||||
static override DEFAULT_OPTIONS = {
|
||||
classes: ['hbm', 'sheet', 'item'],
|
||||
position: { width: 560, height: 600 },
|
||||
window: { resizable: true },
|
||||
actions: {
|
||||
addArrayEntry: HbmItemSheet._onAddArrayEntry,
|
||||
removeArrayEntry: HbmItemSheet._onRemoveArrayEntry,
|
||||
effectCreate: HbmItemSheet._onEffectCreate,
|
||||
effectEdit: HbmItemSheet._onEffectEdit,
|
||||
effectDelete: HbmItemSheet._onEffectDelete,
|
||||
effectToggle: HbmItemSheet._onEffectToggle,
|
||||
},
|
||||
form: { submitOnChange: true, closeOnSubmit: false },
|
||||
};
|
||||
|
||||
static override PARTS = {
|
||||
header: { template: 'systems/hbm-rpg-v3/templates/item/header.hbs' },
|
||||
body: { template: 'systems/hbm-rpg-v3/templates/item/_dispatch.hbs' },
|
||||
};
|
||||
|
||||
override async _prepareContext(options: unknown) {
|
||||
const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
|
||||
const item = (this as unknown as { item: { system: unknown; type: string; effects: any } }).item;
|
||||
const effects = Array.from(item.effects ?? []).map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
icon: e.icon ?? e.img ?? 'icons/svg/aura.svg',
|
||||
disabled: e.disabled,
|
||||
transfer: e.transfer,
|
||||
changes: e.changes ?? [],
|
||||
}));
|
||||
const supportsEffects = item.type === 'talent' || item.type === 'gear';
|
||||
return {
|
||||
...ctx,
|
||||
system: item.system,
|
||||
itemType: item.type,
|
||||
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
|
||||
choices: buildChoiceMaps(),
|
||||
effects,
|
||||
supportsEffects,
|
||||
};
|
||||
}
|
||||
|
||||
static async _onAddArrayEntry(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const path = target.dataset.path;
|
||||
if (!path) return;
|
||||
const item = (this as unknown as { item: any }).item;
|
||||
const current = (foundry.utils.getProperty(item, path) ?? []) as unknown[];
|
||||
const template = target.dataset.template;
|
||||
const entry = template ? JSON.parse(template) : '';
|
||||
await item.update({ [path]: [...current, entry] });
|
||||
}
|
||||
|
||||
static async _onRemoveArrayEntry(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const path = target.dataset.path;
|
||||
const idx = Number(target.dataset.index);
|
||||
if (!path || Number.isNaN(idx)) return;
|
||||
const item = (this as unknown as { item: any }).item;
|
||||
const current = ([...(foundry.utils.getProperty(item, path) ?? [])] as unknown[]);
|
||||
current.splice(idx, 1);
|
||||
await item.update({ [path]: current });
|
||||
}
|
||||
|
||||
static async _onEffectCreate(this: HbmItemSheet) {
|
||||
const item = (this as unknown as { item: any }).item;
|
||||
const created = await item.createEmbeddedDocuments('ActiveEffect', [{
|
||||
name: game.i18n.localize('HBM.activeEffect.newEffect'),
|
||||
icon: 'icons/svg/aura.svg',
|
||||
transfer: true,
|
||||
disabled: false,
|
||||
changes: [],
|
||||
}]);
|
||||
if (created?.[0]) created[0].sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onEffectEdit(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const item = (this as unknown as { item: any }).item;
|
||||
const effect = item.effects.get(id);
|
||||
effect?.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onEffectDelete(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const item = (this as unknown as { item: any }).item;
|
||||
await item.deleteEmbeddedDocuments('ActiveEffect', [id]);
|
||||
}
|
||||
|
||||
static async _onEffectToggle(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const item = (this as unknown as { item: any }).item;
|
||||
const effect = item.effects.get(id);
|
||||
if (!effect) return;
|
||||
await effect.update({ disabled: !effect.disabled });
|
||||
}
|
||||
}
|
||||
|
||||
/** Build localized {key → label} maps for use in <select> options. */
|
||||
function buildChoiceMaps(): Record<string, Record<string, string>> {
|
||||
const localize = (prefix: string, key: string) => {
|
||||
const k = `HBM.${prefix}.${key}`;
|
||||
const v = game.i18n.localize(k);
|
||||
return v === k ? key : v;
|
||||
};
|
||||
const map = (prefix: string, keys: readonly string[]) =>
|
||||
Object.fromEntries(keys.map((k) => [k, localize(prefix, k)]));
|
||||
return {
|
||||
schools: map('spellSchool', SPELL_SCHOOLS as unknown as readonly string[]),
|
||||
deities: map('deity', SACRED_DEITIES as unknown as readonly string[]),
|
||||
sourceBooks: map('sourceBook', SOURCE_BOOKS as unknown as readonly string[]),
|
||||
aoeShapes: map('aoeShape', AOE_SHAPES as unknown as readonly string[]),
|
||||
damageTypes: map('damageType', SPELL_DAMAGE_TYPES as unknown as readonly string[]),
|
||||
triggerEvents: map('triggerEvent', TRIGGER_EVENTS as unknown as readonly string[]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { rollAttribute, rollInitiative } from '../dice/macros';
|
||||
import { askRollParams } from '../dice/roll-dialog';
|
||||
import { askApplyDamage } from '../dice/damage-dialog';
|
||||
import { askCastOptions } from '../dice/cast-dialog';
|
||||
import { applyDamage } from '../logic/damage';
|
||||
import { castSpell } from '../logic/spell-cast';
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { ATTRIBUTES, AttributeKey, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
|
||||
const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
|
||||
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
|
||||
};
|
||||
const { HandlebarsApplicationMixin } = foundry.applications.api as unknown as {
|
||||
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => T;
|
||||
};
|
||||
|
||||
export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
static override DEFAULT_OPTIONS = {
|
||||
classes: ['hbm', 'sheet', 'npc'],
|
||||
position: { width: 640, height: 720 },
|
||||
window: { resizable: true, title: 'HBM.actor.npc' },
|
||||
actions: {
|
||||
rollAttribute: NpcSheet._onRollAttribute,
|
||||
rollInitiative: NpcSheet._onRollInitiative,
|
||||
rollNpcAttack: NpcSheet._onRollNpcAttack,
|
||||
applyDamage: NpcSheet._onApplyDamage,
|
||||
castSpell: NpcSheet._onCastSpell,
|
||||
deleteItem: NpcSheet._onDeleteItem,
|
||||
editItem: NpcSheet._onEditItem,
|
||||
actorEffectCreate: NpcSheet._onActorEffectCreate,
|
||||
actorEffectToggle: NpcSheet._onActorEffectToggle,
|
||||
actorEffectEdit: NpcSheet._onActorEffectEdit,
|
||||
actorEffectDelete: NpcSheet._onActorEffectDelete,
|
||||
addArrayEntry: NpcSheet._onAddArrayEntry,
|
||||
removeArrayEntry: NpcSheet._onRemoveArrayEntry,
|
||||
editImage: NpcSheet._onEditImage,
|
||||
recalculateMoney: NpcSheet._onRecalculateMoney,
|
||||
},
|
||||
form: { submitOnChange: true, closeOnSubmit: false },
|
||||
};
|
||||
|
||||
static override PARTS = {
|
||||
main: { template: 'systems/hbm-rpg-v3/templates/actor/npc.hbs' },
|
||||
};
|
||||
|
||||
override async _prepareContext(options: unknown) {
|
||||
const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
|
||||
const actor = (this as unknown as { actor: { system: any; items: any[] } }).actor;
|
||||
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'));
|
||||
const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({
|
||||
id: ef.id,
|
||||
name: ef.name,
|
||||
icon: ef.icon ?? 'icons/svg/aura.svg',
|
||||
disabled: ef.disabled ?? false,
|
||||
isTransferred: !!ef.origin,
|
||||
originName: ef.origin ? (actor.items.find((it: any) => ef.origin?.endsWith(it.id))?.name ?? ef.origin) : '',
|
||||
changes: ef.changes ?? [],
|
||||
}));
|
||||
|
||||
const sys = actor.system;
|
||||
const attributes = ATTRIBUTES.map(key => {
|
||||
const attrObj = sys.attributes?.[key];
|
||||
const actualVal = attrObj?.actual !== undefined ? attrObj.actual : (attrObj?.value ?? 0);
|
||||
const labels = ['0', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
|
||||
const actualLabel = labels[Math.max(0, Math.min(10, actualVal))] || String(actualVal);
|
||||
return {
|
||||
key,
|
||||
value: attrObj?.value ?? 0,
|
||||
actual: actualVal,
|
||||
actualLabel,
|
||||
label: game.i18n.localize(`HBM.attributes.${key}`)
|
||||
};
|
||||
});
|
||||
|
||||
return { ...ctx, system: actor.system, attributes, spells, actorEffects };
|
||||
}
|
||||
|
||||
override _onRender(context: unknown, options: unknown) {
|
||||
super._onRender(context, options);
|
||||
const html = (this as unknown as { element: HTMLElement }).element;
|
||||
html.addEventListener('dragover', (ev) => { ev.preventDefault(); });
|
||||
html.addEventListener('drop', (ev) => this._handleDrop(ev as DragEvent));
|
||||
}
|
||||
|
||||
private async _handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const data = (foundry.applications as any).ux.TextEditor.implementation.getDragEventData(event);
|
||||
if (!data || data['type'] !== 'Item') return;
|
||||
const item = await fromUuid<any>(data['uuid'] as string);
|
||||
if (!item) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
// Deduplicate by source slug
|
||||
const sourceSlug: string | undefined = item.flags?.['hbm-rpg-v3']?.slug;
|
||||
const existing = actor.items.find((it: any) => {
|
||||
if (it.id === item.id) return true;
|
||||
if (item.type === 'talent' && item.system?.multiSelect) return false;
|
||||
if (sourceSlug && it.flags?.['hbm-rpg-v3']?.slug === sourceSlug) return true;
|
||||
return false;
|
||||
});
|
||||
if (existing) {
|
||||
ui?.notifications?.warn(game.i18n.format('HBM.ui.itemAlreadyOwned', { name: item.name }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'talent' && item.system?.multiSelect) {
|
||||
const match = item.name.match(/\((Dziedzina|Bóstwo|Zmysł|Atrybut|Umiejętność)\)$/i);
|
||||
if (match) {
|
||||
const paramType = match[1];
|
||||
const title = game.i18n.localize('HBM.ui.selectTalentParamTitle') || 'Wybierz parametr talentu';
|
||||
const labelText = game.i18n.format('HBM.ui.selectTalentParamDesc', { param: paramType }) || `Wprowadź wartość dla parametru (${paramType}):`;
|
||||
|
||||
let specified: string | null = null;
|
||||
await (foundry.applications.api as any).DialogV2.wait({
|
||||
title,
|
||||
content: `
|
||||
<div class="hbm-talent-dialog" style="padding:10px;">
|
||||
<p>${labelText}</p>
|
||||
<input id="hbm-talent-param-input" type="text" placeholder="${paramType}" style="width:100%; margin-bottom:10px;" />
|
||||
</div>`,
|
||||
buttons: [
|
||||
{
|
||||
type: 'button', action: 'confirm',
|
||||
label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a',
|
||||
default: true,
|
||||
callback: (_ev: Event, _btn: any, html: HTMLElement) => {
|
||||
const val = (html.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
|
||||
specified = val || null;
|
||||
},
|
||||
},
|
||||
{ type: 'button', action: 'cancel', label: game.i18n.localize('HBM.ui.cancel') || 'Anuluj' },
|
||||
],
|
||||
rejectClose: false,
|
||||
});
|
||||
|
||||
if (specified) {
|
||||
const obj = item.toObject();
|
||||
obj.name = item.name.replace(/\([^)]+\)$/, `(${specified})`);
|
||||
await actor.createEmbeddedDocuments('Item', [obj]);
|
||||
return;
|
||||
} else {
|
||||
return; // cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await actor.createEmbeddedDocuments('Item', [item.toObject()]);
|
||||
}
|
||||
|
||||
static async _onRollAttribute(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const attr = target.dataset.attribute as AttributeKey | undefined;
|
||||
if (!attr) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const pool = actor.system.attributes[attr]?.value ?? 0;
|
||||
const params = await askRollParams({
|
||||
pool,
|
||||
flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }),
|
||||
});
|
||||
if (!params) return;
|
||||
await rollAttribute(actor, attr, { threshold: params.threshold, required: params.required });
|
||||
}
|
||||
|
||||
static async _onRollInitiative(this: NpcSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
await rollInitiative(actor);
|
||||
}
|
||||
|
||||
static async _onRollNpcAttack(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const idx = Number(target.dataset.index);
|
||||
if (Number.isNaN(idx)) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const attack = actor.system.combat?.attacks?.[idx];
|
||||
if (!attack) return;
|
||||
const params = await askRollParams({
|
||||
pool: Math.max(0, Number(attack.bonus) || 0),
|
||||
flavor: attack.name,
|
||||
});
|
||||
if (!params) return;
|
||||
const flavor = `${attack.name}${attack.damage ? ` — ${attack.damage}` : ''}`;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool: params.pool,
|
||||
threshold: params.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: params.required ?? TS_DEFAULT_REQUIRED,
|
||||
flavor,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({ flavor, speaker: ChatMessage.getSpeaker({ actor }) });
|
||||
}
|
||||
|
||||
static async _onApplyDamage(this: NpcSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const params = await askApplyDamage(actor);
|
||||
if (!params) return;
|
||||
await applyDamage(actor, params);
|
||||
}
|
||||
|
||||
static async _onCastSpell(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const spell = actor.items.get(itemId);
|
||||
if (!spell || spell.type !== 'spell') return;
|
||||
const opts = await askCastOptions(spell, actor);
|
||||
if (!opts) return;
|
||||
opts.speaker = ChatMessage.getSpeaker({ actor });
|
||||
await castSpell(actor, spell, opts);
|
||||
}
|
||||
|
||||
static async _onDeleteItem(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const item = actor.items.get(itemId);
|
||||
if (!item) return;
|
||||
await item.delete();
|
||||
}
|
||||
|
||||
static async _onEditItem(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const item = actor.items.get(itemId);
|
||||
if (!item) return;
|
||||
item.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onAddArrayEntry(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const path = target.dataset.path;
|
||||
if (!path) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const current = (foundry.utils.getProperty(actor, path) ?? []) as unknown[];
|
||||
const template = target.dataset.template;
|
||||
const entry = template ? JSON.parse(template) : '';
|
||||
await actor.update({ [path]: [...current, entry] });
|
||||
}
|
||||
|
||||
static async _onRemoveArrayEntry(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const path = target.dataset.path;
|
||||
const idx = Number(target.dataset.index);
|
||||
if (!path || Number.isNaN(idx)) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const current = ([...(foundry.utils.getProperty(actor, path) ?? [])] as unknown[]);
|
||||
current.splice(idx, 1);
|
||||
await actor.update({ [path]: current });
|
||||
}
|
||||
|
||||
static async _onActorEffectCreate(this: NpcSheet) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const created = await actor.createEmbeddedDocuments('ActiveEffect', [{
|
||||
name: game.i18n.localize('HBM.activeEffect.newEffect'),
|
||||
icon: 'icons/svg/aura.svg',
|
||||
disabled: false,
|
||||
changes: [],
|
||||
}]);
|
||||
created[0]?.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onActorEffectToggle(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const effect = actor.effects.get(id);
|
||||
if (!effect) return;
|
||||
await effect.update({ disabled: !effect.disabled });
|
||||
}
|
||||
|
||||
static async _onActorEffectEdit(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
actor.effects.get(id)?.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async _onActorEffectDelete(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
|
||||
const id = target.dataset.effectId;
|
||||
if (!id) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
await actor.deleteEmbeddedDocuments('ActiveEffect', [id]);
|
||||
}
|
||||
|
||||
static async _onEditImage(this: NpcSheet, event: PointerEvent, target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const current = actor.img;
|
||||
const fp = new FilePicker({
|
||||
type: "image",
|
||||
current: current,
|
||||
callback: (path: string) => {
|
||||
actor.update({ img: path });
|
||||
},
|
||||
top: this.position.top + 40,
|
||||
left: this.position.left + 10
|
||||
});
|
||||
return fp.browse();
|
||||
}
|
||||
|
||||
static async _onRecalculateMoney(this: NpcSheet, event: PointerEvent, target: HTMLElement) {
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const pln = actor.system.details?.money ?? 0;
|
||||
const currentYear = actor.system.details?.currentYear ?? 2026;
|
||||
|
||||
let eurRate = 4.35;
|
||||
let usdRate = 4.00;
|
||||
let rateSource = "Domyślne przeliczniki (brak połączenia lub rok poza zakresem API NBP)";
|
||||
let isLive = false;
|
||||
|
||||
const currentRealYear = new Date().getFullYear();
|
||||
|
||||
// Helper function to fetch rate from NBP
|
||||
const fetchNBP = async (currency: string, year: number): Promise<{ rate: number; date: string } | null> => {
|
||||
if (year >= 2002 && year <= currentRealYear) {
|
||||
// Try first 7 days of June to find a working business day (NBP doesn't publish on weekends)
|
||||
for (let day = 1; day <= 7; day++) {
|
||||
const dateString = `${year}-06-${String(day).padStart(2, '0')}`;
|
||||
try {
|
||||
const response = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/${dateString}/?format=json`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const rate = data?.rates?.[0]?.mid;
|
||||
if (typeof rate === 'number') {
|
||||
return { rate, date: dateString };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore and try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to latest rate
|
||||
try {
|
||||
const response = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/?format=json`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const rate = data?.rates?.[0]?.mid;
|
||||
const date = data?.rates?.[0]?.effectiveDate || "";
|
||||
if (typeof rate === 'number') {
|
||||
return { rate, date };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Fetch rates in parallel
|
||||
const [eurData, usdData] = await Promise.all([
|
||||
fetchNBP("eur", currentYear),
|
||||
fetchNBP("usd", currentYear)
|
||||
]);
|
||||
|
||||
if (eurData && usdData) {
|
||||
eurRate = eurData.rate;
|
||||
usdRate = usdData.rate;
|
||||
rateSource = `Pobrane z NBP (EUR z ${eurData.date}, USD z ${usdData.date})`;
|
||||
isLive = true;
|
||||
} else if (eurData) {
|
||||
eurRate = eurData.rate;
|
||||
rateSource = `Częściowo pobrane z NBP (EUR z ${eurData.date})`;
|
||||
isLive = true;
|
||||
} else if (usdData) {
|
||||
usdRate = usdData.rate;
|
||||
rateSource = `Częściowo pobrane z NBP (USD z ${usdData.date})`;
|
||||
isLive = true;
|
||||
}
|
||||
|
||||
// Conversion rates
|
||||
const eur = (pln / eurRate).toFixed(2);
|
||||
const th = (pln / eurRate).toFixed(2); // 1:1 with EUR
|
||||
const usd = (pln / usdRate).toFixed(2);
|
||||
const fc = (pln / 2.0).toFixed(2); // 2 PLN = 1 Credit
|
||||
const st = (pln / 240).toFixed(2);
|
||||
const zk = (pln / 2880).toFixed(2);
|
||||
const pl = (pln / 34560).toFixed(2);
|
||||
|
||||
const content = `
|
||||
<div class="hbm money-converter" style="padding: 10px; font-family: 'Signika', sans-serif;">
|
||||
<p><strong>Bieżąca gotówka:</strong> ${pln} PLN (Rok kampanii: ${currentYear})</p>
|
||||
<p style="font-size: 0.8rem; color: ${isLive ? '#2a7a3e' : '#888'}; margin-top: -5px;">
|
||||
<em>Kursy: ${rateSource}</em><br/>
|
||||
(1 EUR = ${eurRate.toFixed(4)} PLN, 1 USD = ${usdRate.toFixed(4)} PLN)
|
||||
</p>
|
||||
<hr style="border-top: 1px dashed var(--hbm-border, #ccc); margin: 10px 0;" />
|
||||
<h4 style="margin: 5px 0;">Waluty Ziemskie i Międzyświatowe</h4>
|
||||
<ul style="list-style: none; padding: 0; margin: 5px 0; display: flex; flex-direction: column; gap: 4px;">
|
||||
<li><strong>FC (Kredyt Federacji Sol-3):</strong> ${fc} Credits <small style="color: #666;">(Waluta Federacji Sol-3, stały kurs 2 PLN = 1 Credit)</small></li>
|
||||
<li><strong>EUR (Euro):</strong> ${eur} €</li>
|
||||
<li><strong>USD (Dolar):</strong> ${usd} $</li>
|
||||
<li><strong>Thrakka (TH):</strong> ${th} TH <small style="color: #666;">(krasnoludzka waluta rozliczeniowa, 1:1 z EUR)</small></li>
|
||||
</ul>
|
||||
<hr style="border-top: 1px dashed var(--hbm-border, #ccc); margin: 10px 0;" />
|
||||
<h4 style="margin: 5px 0;">Krasnoludzkie Monety Klanowe (System Dwunastkowy)</h4>
|
||||
<ul style="list-style: none; padding: 0; margin: 5px 0; display: flex; flex-direction: column; gap: 4px;">
|
||||
<li><strong>ST (Srebrny Talent):</strong> ${st} ST <small style="color: #666;">(1 ST = 240 PLN / 55 TH)</small></li>
|
||||
<li><strong>ZK (Złota Korona):</strong> ${zk} ZK <small style="color: #666;">(1 ZK = 2880 PLN / 660 TH)</small></li>
|
||||
<li><strong>PL (Platynowy Lingot):</strong> ${pl} PL <small style="color: #666;">(1 PL = 34560 PLN / 7920 TH)</small></li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await (foundry.applications.api as any).DialogV2.inform({
|
||||
title: game.i18n.localize('HBM.ui.moneyConverterTitle'),
|
||||
content: content,
|
||||
rejectClose: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
+930
@@ -0,0 +1,930 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Homebrew Magic: RPG v3 — Foundry VTT system styles
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── CSS custom properties ─────────────────────────────────────────────── */
|
||||
.hbm {
|
||||
--hbm-fg: #1a1a1a;
|
||||
--hbm-bg: #f4ede0;
|
||||
--hbm-bg-header: #2a1a0e;
|
||||
--hbm-border: #b29d76;
|
||||
--hbm-accent: #6b1414;
|
||||
--hbm-accent-light: #a03030;
|
||||
--hbm-success: #1f7a1f;
|
||||
--hbm-failure: #8b1a1a;
|
||||
--hbm-crit-success: #d4af37;
|
||||
--hbm-crit-failure: #5a0a0a;
|
||||
--hbm-card-bg: rgba(255, 255, 255, 0.45);
|
||||
--hbm-radius: 5px;
|
||||
|
||||
/* attribute colour coding */
|
||||
--hbm-body-color: #6b2222;
|
||||
--hbm-mind-color: #1f4f7a;
|
||||
--hbm-spirit-color: #4b2277;
|
||||
--hbm-reflex-color: #1f6b3a;
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
.theme-dark .hbm,
|
||||
.hbm.theme-dark {
|
||||
--hbm-fg: #e0d8c3;
|
||||
--hbm-bg: #1a1712;
|
||||
--hbm-bg-header: #0f0b07;
|
||||
--hbm-border: #8a7657;
|
||||
--hbm-accent: #d86c6c;
|
||||
--hbm-accent-light: #f38f8f;
|
||||
--hbm-success: #32b232;
|
||||
--hbm-failure: #b23232;
|
||||
--hbm-card-bg: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ── Base sheet shell ───────────────────────────────────────────────────── */
|
||||
.hbm.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-family: 'Signika', sans-serif;
|
||||
color: var(--hbm-fg);
|
||||
background: var(--hbm-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hbm.sheet form,
|
||||
.hbm.sheet .sheet-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sticky header ──────────────────────────────────────────────────────── */
|
||||
.hbm .sheet-header {
|
||||
flex-shrink: 0;
|
||||
background: var(--hbm-bg-header);
|
||||
color: #f0e6d0;
|
||||
padding: 0.5rem 0.75rem 0;
|
||||
border-bottom: 2px solid var(--hbm-border);
|
||||
}
|
||||
|
||||
.hbm.npc .sheet-header {
|
||||
background: #19222a; /* Dark slate blue for NPCs to distinguish from character sheets */
|
||||
}
|
||||
|
||||
.hbm .sheet-header .charname {
|
||||
width: 100%;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.25);
|
||||
color: #f0e6d0;
|
||||
margin-bottom: 0.4rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.hbm .sheet-header .charname:focus { outline: none; border-bottom-color: var(--hbm-crit-success); }
|
||||
|
||||
.hbm .sheet-header .meta-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hbm .sheet-header .meta-row label {
|
||||
font-size: 0.78rem;
|
||||
color: #c8b89a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.hbm .sheet-header .meta-row input {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.25);
|
||||
color: #f0e6d0;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
width: 6rem;
|
||||
}
|
||||
.hbm .sheet-header .meta-row input[type='number'] { width: 3rem; }
|
||||
|
||||
/* ── Tab nav ────────────────────────────────────────────────────────────── */
|
||||
.hbm .tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-top: 1px solid rgba(255,255,255,0.12);
|
||||
margin: 0 -0.75rem;
|
||||
}
|
||||
|
||||
.hbm .tabs .tab-item {
|
||||
flex: 1 1 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #c8b89a;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.4rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.hbm .tabs .tab-item:hover { color: #f0e6d0; }
|
||||
.hbm .tabs .tab-item.active { color: var(--hbm-crit-success); border-bottom-color: var(--hbm-crit-success); }
|
||||
|
||||
/* ── Scrollable body ────────────────────────────────────────────────────── */
|
||||
.hbm .sheet-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Tab panels ─────────────────────────────────────────────────────────── */
|
||||
.hbm .tab-panel { display: none; }
|
||||
.hbm .tab-panel.active { display: block; }
|
||||
|
||||
/* ── Roll button (shared) ───────────────────────────────────────────────── */
|
||||
.hbm .roll-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--hbm-accent);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.hbm .roll-btn:hover { color: var(--hbm-accent-light); }
|
||||
|
||||
/* ── Attributes grid (Stats tab) ────────────────────────────────────────── */
|
||||
.hbm .attributes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@media (max-width: 600px) { .hbm .attributes-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 380px) { .hbm .attributes-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.hbm .attribute {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
background: var(--hbm-card-bg);
|
||||
border-top-width: 3px;
|
||||
}
|
||||
/* Colour-code by attribute */
|
||||
.hbm .attribute[data-attr='body'] { border-top-color: var(--hbm-body-color); }
|
||||
.hbm .attribute[data-attr='mind'] { border-top-color: var(--hbm-mind-color); }
|
||||
.hbm .attribute[data-attr='spirit'] { border-top-color: var(--hbm-spirit-color); }
|
||||
.hbm .attribute[data-attr='reflex'] { border-top-color: var(--hbm-reflex-color); }
|
||||
|
||||
.hbm .attribute label {
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hbm .attribute input[type='number'] {
|
||||
width: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.15rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hbm-border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── Resources grid ─────────────────────────────────────────────────────── */
|
||||
.hbm .resources-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@media (max-width: 540px) { .hbm .resources-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.hbm .resource {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
background: var(--hbm-card-bg);
|
||||
}
|
||||
.hbm .resource label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.hbm .resource-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.hbm .resource-value input[type='number'] {
|
||||
width: 3rem;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hbm-border);
|
||||
background: transparent;
|
||||
}
|
||||
.hbm .resource-value .sep { color: var(--hbm-border); }
|
||||
.hbm .resource .static-value { font-size: 1.1rem; font-weight: 700; }
|
||||
.hbm .resource small { font-size: 0.7rem; color: #666; }
|
||||
|
||||
/* ── Skills grid ────────────────────────────────────────────────────────── */
|
||||
.hbm .skills-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.2rem 0.75rem;
|
||||
}
|
||||
@media (max-width: 600px) { .hbm .skills-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 380px) { .hbm .skills-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.hbm .skill {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2.5rem auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
border-bottom: 1px dotted var(--hbm-border);
|
||||
}
|
||||
.hbm .skill .skill-name {
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hbm .skill input[type='number'] {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hbm-border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── Item lists (spells, inventory) ─────────────────────────────────────── */
|
||||
.hbm .item-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hbm .item-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--hbm-card-bg);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
}
|
||||
.hbm .item-row .item-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hbm .item-row .item-meta {
|
||||
grid-column: 1 / -2;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
.hbm .item-row .item-controls {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
/* Spell / item action buttons */
|
||||
.hbm .cast-btn,
|
||||
.hbm .delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.hbm .cast-btn { color: var(--hbm-accent); }
|
||||
.hbm .cast-btn:hover { background: rgba(107,20,20,0.12); }
|
||||
.hbm .delete-btn { color: #888; }
|
||||
.hbm .delete-btn:hover { background: rgba(0,0,0,0.1); color: var(--hbm-failure); }
|
||||
|
||||
/* ── Empty drop hint ────────────────────────────────────────────────────── */
|
||||
.hbm .empty-drop {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: #999;
|
||||
margin: 2rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Biography tab ──────────────────────────────────────────────────────── */
|
||||
.hbm .bio-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.hbm .bio-section label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hbm .bio-section textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
padding: 0.4rem;
|
||||
background: var(--hbm-card-bg);
|
||||
}
|
||||
|
||||
/* ── Dialog form ────────────────────────────────────────────────────────── */
|
||||
.hbm-dialog .form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hbm-dialog .form-group label {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hbm-dialog .form-group input[type='number'] {
|
||||
width: 5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.hbm-dialog .hint {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hbm-dialog .warn { color: var(--hbm-failure); }
|
||||
|
||||
/* ── TS roll chat card ──────────────────────────────────────────────────── */
|
||||
.hbm .ts-roll { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.hbm .ts-roll .dice { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
|
||||
.hbm .ts-roll .die {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
background: #ddd;
|
||||
color: #333;
|
||||
}
|
||||
.hbm .ts-roll .die.success { background: var(--hbm-success); color: #fff; }
|
||||
.hbm .ts-roll .die.failure { background: #ddd; color: #555; }
|
||||
.hbm .ts-roll .die.crit-success { background: var(--hbm-crit-success); color: #1a1a1a; }
|
||||
.hbm .ts-roll .die.crit-failure { background: var(--hbm-crit-failure); color: #fff; }
|
||||
|
||||
.hbm .ts-roll .summary { font-weight: 600; }
|
||||
.hbm .ts-roll .summary.success { color: var(--hbm-success); }
|
||||
.hbm .ts-roll .summary.failure { color: var(--hbm-failure); }
|
||||
|
||||
/* ── Phase 6 polish ─────────────────────────────────────────── */
|
||||
|
||||
/* Conditional resource highlighting */
|
||||
.hbm .resource.blood-pool {
|
||||
background: linear-gradient(180deg, rgba(140, 20, 20, 0.12), transparent);
|
||||
border-color: #8b1a1a;
|
||||
}
|
||||
.hbm .resource.warn {
|
||||
background: rgba(180, 110, 0, 0.15);
|
||||
border-color: #b46e00;
|
||||
}
|
||||
|
||||
/* Spell badges */
|
||||
.hbm .item-name .badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.25rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--hbm-border, #888);
|
||||
color: #fff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.hbm .badge.super { background: #b46e00; }
|
||||
.hbm .badge.group { background: #4a3a8a; }
|
||||
.hbm .badge.noncombat { background: #2c7a2c; }
|
||||
.hbm .badge.race-lock { background: #5a5a5a; }
|
||||
.hbm .source-pip {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border: 1px solid var(--hbm-border, #888);
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
/* Spell-school grouping */
|
||||
.hbm details.spell-school-group {
|
||||
margin: 0.4rem 0;
|
||||
border: 1px solid var(--hbm-border, #ccc);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(255,255,255,0.4);
|
||||
}
|
||||
.hbm details.spell-school-group > summary {
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.hbm details.spell-school-group > summary .count {
|
||||
color: #777;
|
||||
font-weight: normal;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
.hbm details.spell-school-group[open] > summary {
|
||||
border-bottom: 1px dashed var(--hbm-border, #ccc);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Spell-sheet array editors */
|
||||
.hbm.item-body .array-list {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
background: rgba(0,0,0,0.03);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.hbm.item-body .array-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.hbm.item-body .array-row > input[type='text'] { flex: 1; }
|
||||
.hbm.item-body .array-row.overcast-row > input[type='text'] { flex: 3; }
|
||||
.hbm.item-body .array-row.overcast-row > input[type='number'] { width: 4rem; }
|
||||
.hbm.item-body fieldset {
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--hbm-border, #ccc);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
.hbm.item-body fieldset > legend {
|
||||
font-weight: bold;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
.hbm.item-body .row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.hbm.item-body .row.flags label { font-size: 0.85rem; }
|
||||
.hbm.item-body .row label.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hbm.item-body .row label.checkbox-label input[type='checkbox'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Dialog warning styling */
|
||||
.hbm-dialog .form-group.warn {
|
||||
background: rgba(180, 30, 30, 0.08);
|
||||
border-left: 3px solid #b41e1e;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Header rest buttons */
|
||||
.hbm .header-btn[data-action="shortRest"],
|
||||
.hbm .header-btn[data-action="longRest"] {
|
||||
background: rgba(80, 60, 140, 0.15);
|
||||
}
|
||||
/* === Spell-cast chat card === */
|
||||
.hbm.spell-cast-card { border: 1px solid #6b3fa0; border-radius: 6px; padding: .5rem; background: linear-gradient(180deg, rgba(107,63,160,.08), rgba(107,63,160,.02)); }
|
||||
.hbm.spell-cast-card .hbm-card-header { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .35rem; border-bottom: 1px dashed rgba(107,63,160,.4); padding-bottom: .25rem; }
|
||||
.hbm.spell-cast-card .hbm-card-header h3 { margin: 0; font-size: 1.1em; }
|
||||
.hbm.spell-cast-card .hbm-card-meta { display: flex; gap: .25rem; flex-wrap: wrap; }
|
||||
.hbm.spell-cast-card .badge.result.success { background: #2c7a3f; color: #fff; }
|
||||
.hbm.spell-cast-card .badge.result.failure { background: #b13a3a; color: #fff; }
|
||||
.hbm.spell-cast-card .badge.mode, .hbm.spell-cast-card .badge.school, .hbm.spell-cast-card .badge.circle, .hbm.spell-cast-card .badge.damage-type, .hbm.spell-cast-card .badge.ignores-armor, .hbm.spell-cast-card .badge.trigger-event { padding: 1px 6px; border-radius: 8px; font-size: .75em; background: rgba(107,63,160,.15); }
|
||||
.hbm.spell-cast-card .hbm-card-costs { list-style: none; padding: 0; margin: .25rem 0; display: flex; gap: .75rem; font-size: .85em; }
|
||||
.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-formula code { font-size: .8em; color: #555; }
|
||||
.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 .hbm-card-hint { font-size: .8em; color: #777; margin: .15rem 0; font-style: italic; }
|
||||
.hbm.spell-cast-card .hbm-card-description summary { cursor: pointer; font-size: .85em; color: #555; }
|
||||
|
||||
/* Cast dialog: caster checkboxes + variant radios */
|
||||
.hbm-dialog .caster-list, .hbm-dialog .variant-list { display: flex; flex-direction: column; gap: .15rem; max-height: 180px; overflow-y: auto; padding: .25rem; border: 1px solid #ccc; border-radius: 4px; }
|
||||
.hbm-dialog .caster-option, .hbm-dialog .variant-option { display: flex; gap: .35rem; align-items: center; font-size: .9em; cursor: pointer; }
|
||||
.hbm-dialog .variant-option small { color: #777; }
|
||||
|
||||
/* Actions Tab */
|
||||
.hbm .actions-panel {
|
||||
padding: 1rem;
|
||||
background: var(--hbm-card-bg);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
}
|
||||
.hbm .actions-buttons-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.hbm button.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem 0.75rem;
|
||||
background: rgba(107, 20, 20, 0.1);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
color: var(--hbm-fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s, color 0.2s;
|
||||
min-height: 5.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.hbm button.action-btn .label {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.hbm button.action-btn:hover {
|
||||
background: var(--hbm-accent);
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.hbm button.action-btn .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Active Effects tab */
|
||||
.hbm fieldset.hbm-effects {
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
padding: 1rem;
|
||||
background: var(--hbm-card-bg);
|
||||
}
|
||||
.hbm fieldset.hbm-effects legend {
|
||||
font-weight: bold;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--hbm-fg);
|
||||
}
|
||||
.hbm button.hbm-effect-add {
|
||||
background: var(--hbm-success);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--hbm-radius);
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hbm button.hbm-effect-add:hover {
|
||||
background: #289e28;
|
||||
}
|
||||
.hbm .effect-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hbm .effect-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
}
|
||||
.hbm .effect-row.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.hbm .effect-row .effect-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hbm .effect-row .effect-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hbm .effect-row .effect-meta .badge {
|
||||
background: var(--hbm-border);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.hbm .effect-row button {
|
||||
background: none;
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: 3px;
|
||||
color: var(--hbm-fg);
|
||||
padding: 0.1rem 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hbm .effect-row button:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ── NPC Sheet styling ─────────────────────────────────────────────────── */
|
||||
.hbm.npc .meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.hbm.npc .meta label {
|
||||
font-size: 0.78rem;
|
||||
color: #c8b89a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.hbm.npc .meta input {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: #f0e6d0;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.hbm.npc .attributes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hbm.npc .attributes-grid .attribute {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
background: var(--hbm-card-bg);
|
||||
}
|
||||
|
||||
.hbm.npc .attributes-grid .attribute label {
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hbm.npc .attributes-grid .attribute input[type='number'] {
|
||||
width: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.15rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hbm-border);
|
||||
background: transparent;
|
||||
color: var(--hbm-fg);
|
||||
}
|
||||
|
||||
.hbm.npc .attributes-grid .attribute .roll-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--hbm-accent);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hbm.npc .npc-meta-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--hbm-card-bg);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.hbm.npc .npc-meta-section label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hbm.npc .npc-meta-section input[type='text'] {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hbm-border);
|
||||
background: transparent;
|
||||
color: var(--hbm-fg);
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
|
||||
.hbm.npc fieldset {
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--hbm-card-bg);
|
||||
}
|
||||
|
||||
.hbm.npc fieldset legend {
|
||||
font-weight: bold;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--hbm-fg);
|
||||
}
|
||||
|
||||
.hbm.npc fieldset button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--hbm-success);
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hbm.npc fieldset .row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.hbm.npc fieldset .row input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hbm-border);
|
||||
color: var(--hbm-fg);
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
|
||||
.hbm.npc fieldset .row input[type='text'] {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.hbm.npc fieldset .row input[type='number'] {
|
||||
width: 3.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hbm.npc fieldset .row button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--hbm-accent);
|
||||
}
|
||||
|
||||
.hbm.npc fieldset .row button[data-action='removeArrayEntry'] {
|
||||
color: var(--hbm-failure);
|
||||
}
|
||||
|
||||
.hbm.npc textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
background: var(--hbm-card-bg);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
color: var(--hbm-fg);
|
||||
padding: 0.5rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.hbm.npc .spells-list .spell-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px dotted var(--hbm-border);
|
||||
}
|
||||
|
||||
.hbm.npc .spells-list .spell-row .spell-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hbm.npc .spells-list .spell-row button {
|
||||
background: rgba(107, 20, 20, 0.1);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
color: var(--hbm-fg);
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hbm.npc .spells-list .spell-row button:hover {
|
||||
background: var(--hbm-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inventory Tab styling */
|
||||
.hbm .inventory-section h3 {
|
||||
border-bottom: 2px solid var(--hbm-border);
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--hbm-accent);
|
||||
}
|
||||
.hbm .inventory-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--hbm-card-bg);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hbm .inventory-table th,
|
||||
.hbm .inventory-table td {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid rgba(178, 157, 118, 0.4);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hbm .inventory-table th {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: #666;
|
||||
}
|
||||
.hbm .inventory-table tr.item-tr:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Schedule Table */
|
||||
.schedule-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--hbm-card-bg);
|
||||
border: 1px solid var(--hbm-border);
|
||||
border-radius: var(--hbm-radius);
|
||||
font-size: 0.8rem;
|
||||
color: var(--hbm-fg);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.schedule-table th {
|
||||
padding: 6px;
|
||||
border: 1px solid var(--hbm-border);
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.schedule-table td {
|
||||
padding: 2px;
|
||||
border: 1px solid var(--hbm-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-table input[type="text"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
color: var(--hbm-fg);
|
||||
font-size: 0.8rem;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.schedule-table input[type="text"]:focus {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
{
|
||||
"id": "hbm-rpg-v3",
|
||||
"title": "Homebrew Magic: RPG v3",
|
||||
"description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 — autorska gra fabularna z mechaniką puli kości d6 (TS — Trudność:Sukcesy).",
|
||||
"version": "1.3.12",
|
||||
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
|
||||
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.3.12.zip",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14"
|
||||
},
|
||||
"documentTypes": {
|
||||
"Actor": {
|
||||
"character": {
|
||||
"htmlFields": [
|
||||
"details.biography",
|
||||
"details.customEquipment"
|
||||
]
|
||||
},
|
||||
"npc": {
|
||||
"htmlFields": [
|
||||
"lore.description",
|
||||
"details.customEquipment"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Item": {
|
||||
"spell": {
|
||||
"htmlFields": [
|
||||
"description",
|
||||
"higherCircles"
|
||||
]
|
||||
},
|
||||
"gear": {
|
||||
"htmlFields": [
|
||||
"description"
|
||||
]
|
||||
},
|
||||
"ability": {
|
||||
"htmlFields": [
|
||||
"description",
|
||||
"mechanics"
|
||||
]
|
||||
},
|
||||
"class": {
|
||||
"htmlFields": [
|
||||
"description"
|
||||
]
|
||||
},
|
||||
"race": {
|
||||
"htmlFields": [
|
||||
"description"
|
||||
]
|
||||
},
|
||||
"discipline": {
|
||||
"htmlFields": [
|
||||
"description",
|
||||
"passiveAbility"
|
||||
]
|
||||
},
|
||||
"talent": {
|
||||
"htmlFields": [
|
||||
"description",
|
||||
"effect"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Iwo Strzeboński"
|
||||
}
|
||||
],
|
||||
"esmodules": [
|
||||
"hbm.mjs"
|
||||
],
|
||||
"styles": [
|
||||
"styles/hbm.css"
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"lang": "pl",
|
||||
"name": "Polski",
|
||||
"path": "lang/pl.json"
|
||||
},
|
||||
{
|
||||
"lang": "en",
|
||||
"name": "English",
|
||||
"path": "lang/en.json"
|
||||
}
|
||||
],
|
||||
"packs": [
|
||||
{
|
||||
"name": "spells-general",
|
||||
"label": "Magia Ogólna",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/spells-general",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "spells",
|
||||
"sourceBook": "core-rules"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "spells-sacred",
|
||||
"label": "Magia Sakralna",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/spells-sacred",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "spells",
|
||||
"sourceBook": "magic-book"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "spells-academic",
|
||||
"label": "Dyscypliny Akademickie",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/spells-academic",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "spells",
|
||||
"sourceBook": "magic-book"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "spells-blood",
|
||||
"label": "Magia Krwi",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/spells-blood",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "spells",
|
||||
"sourceBook": "arcanum-sanguinis"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "spells-crimson",
|
||||
"label": "Magia Szkarłatnego Kultu",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/spells-crimson",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "spells",
|
||||
"sourceBook": "crimson-cult"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "spells-eldritch",
|
||||
"label": "Magia Otchłani",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/spells-eldritch",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "spells",
|
||||
"sourceBook": "abyss-curse"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "talents",
|
||||
"label": "Talenty",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/talents",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "talents",
|
||||
"sourceBook": "core-rules"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "talents-blood",
|
||||
"label": "Talenty — Magia Krwi",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/talents-blood",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "talents",
|
||||
"sourceBook": "arcanum-sanguinis",
|
||||
"sourceModule": "arcanum-sanguinis"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "talents-eldritch",
|
||||
"label": "Talents - Eldritch",
|
||||
"system": "hbm",
|
||||
"path": "packs/talents-eldritch",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"name": "talents-eldritch",
|
||||
"label": "Talenty — Klątwa Otchłani",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/talents-eldritch",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "talents",
|
||||
"sourceBook": "abyss-curse",
|
||||
"sourceModule": "abyss-curse"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "talents-npc",
|
||||
"label": "Talenty (NPC)",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/talents-npc",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "NONE",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "talents",
|
||||
"sourceBook": "bestiary"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "races",
|
||||
"label": "Rasy",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/races",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "races",
|
||||
"sourceBook": "humanity-guide"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disciplines",
|
||||
"label": "Dziedziny Magii",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/disciplines",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "disciplines",
|
||||
"sourceBook": "magic-book"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disciplines-forbidden",
|
||||
"label": "Dziedziny Zakazane",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/disciplines-forbidden",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "NONE",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "disciplines",
|
||||
"sourceBook": "abyss-curse",
|
||||
"forbidden": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hbm-macros",
|
||||
"label": "Makra HbM",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/hbm-macros",
|
||||
"type": "Macro",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "macros",
|
||||
"sourceBook": "core-rules"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "items-weapons",
|
||||
"label": "Bronie",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/items-weapons",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "gear",
|
||||
"sourceBook": "core-rules"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "items-armor",
|
||||
"label": "Pancerze",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/items-armor",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "gear",
|
||||
"sourceBook": "core-rules"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "items-gear",
|
||||
"label": "Ekwipunek (Inny)",
|
||||
"system": "hbm-rpg-v3",
|
||||
"path": "packs/items-gear",
|
||||
"type": "Item",
|
||||
"ownership": {
|
||||
"PLAYER": "OBSERVER",
|
||||
"ASSISTANT": "OWNER"
|
||||
},
|
||||
"flags": {
|
||||
"hbm-rpg-v3": {
|
||||
"category": "gear",
|
||||
"sourceBook": "core-rules"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"packFolders": [
|
||||
{
|
||||
"name": "Zaklęcia",
|
||||
"sorting": "a",
|
||||
"packs": [
|
||||
"spells-general",
|
||||
"spells-academic",
|
||||
"spells-sacred",
|
||||
"spells-blood",
|
||||
"spells-eldritch",
|
||||
"spells-crimson"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Talenty",
|
||||
"sorting": "a",
|
||||
"packs": [
|
||||
"talents",
|
||||
"talents-blood",
|
||||
"talents-eldritch",
|
||||
"talents-npc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rasy i Dziedziny",
|
||||
"sorting": "a",
|
||||
"packs": [
|
||||
"races",
|
||||
"disciplines",
|
||||
"disciplines-forbidden"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ekwipunek",
|
||||
"sorting": "a",
|
||||
"packs": [
|
||||
"items-weapons",
|
||||
"items-armor",
|
||||
"items-gear"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Narzędzia",
|
||||
"sorting": "a",
|
||||
"packs": [
|
||||
"hbm-macros"
|
||||
]
|
||||
}
|
||||
],
|
||||
"grid": {
|
||||
"distance": 1,
|
||||
"units": "m"
|
||||
},
|
||||
"primaryTokenAttribute": "attributes.health",
|
||||
"secondaryTokenAttribute": "attributes.mana",
|
||||
"license": "LICENSE.txt",
|
||||
"url": "https://github.com/iwo-strzebonski/hbm-books",
|
||||
"background": "systems/hbm-rpg-v3/assets/background.webp"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{!-- Actor-level Active Effects section — included by character and NPC sheets. --}}
|
||||
<fieldset class="hbm-effects actor-effects">
|
||||
<legend>{{localize 'HBM.activeEffect.title'}}</legend>
|
||||
<button type="button" class="hbm-effect-add" data-action="actorEffectCreate">+ {{localize 'HBM.activeEffect.addEffect'}}</button>
|
||||
{{#if actorEffects.length}}
|
||||
<ul class="effect-list">
|
||||
{{#each actorEffects as |e|}}
|
||||
<li class="effect-row {{#if e.disabled}}disabled{{/if}} {{#if e.isTransferred}}transferred{{/if}}">
|
||||
<img src="{{e.icon}}" width="20" height="20"/>
|
||||
<span class="effect-name">{{e.name}}</span>
|
||||
<span class="effect-meta">
|
||||
{{#if e.isTransferred}}<span class="badge" title="{{localize 'HBM.activeEffect.fromItem'}}">← {{e.originName}}</span>{{/if}}
|
||||
<span class="badge">{{e.changes.length}} {{localize 'HBM.activeEffect.changes'}}</span>
|
||||
</span>
|
||||
<button type="button" data-action="actorEffectToggle" data-effect-id="{{e.id}}" title="{{localize 'HBM.activeEffect.toggle'}}">{{#if e.disabled}}○{{else}}●{{/if}}</button>
|
||||
<button type="button" data-action="actorEffectEdit" data-effect-id="{{e.id}}" title="{{localize 'HBM.activeEffect.edit'}}">✎</button>
|
||||
{{#unless e.isTransferred}}<button type="button" data-action="actorEffectDelete" data-effect-id="{{e.id}}" title="{{localize 'HBM.activeEffect.deleteEffect'}}">✕</button>{{/unless}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="hbm-hint">{{localize 'HBM.activeEffect.emptyActor'}}</p>
|
||||
{{/if}}
|
||||
</fieldset>
|
||||
@@ -0,0 +1,656 @@
|
||||
<div class="sheet-wrapper">
|
||||
<!-- ═══ STICKY HEADER ═══════════════════════════════════════════════════ -->
|
||||
<header class="sheet-header">
|
||||
<input class="charname" type="text" name="name" value="{{document.name}}" placeholder="{{localize 'HBM.ui.name'}}" />
|
||||
<div class="meta-row">
|
||||
<label>{{localize 'HBM.details.race'}}
|
||||
<select name="system.details.race">
|
||||
<option value="">--</option>
|
||||
{{#each races as |r|}}
|
||||
<option value="{{r.key}}" {{#if (eq ../system.details.race r.key)}}selected{{/if}}>{{r.label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>{{localize 'HBM.details.year'}}
|
||||
<input type="number" name="system.details.year" value="{{system.details.year}}" min="1" max="4" />
|
||||
</label>
|
||||
<label>{{localize 'HBM.details.startingYear'}}
|
||||
<input type="number" name="system.details.startingYear" value="{{system.details.startingYear}}" min="1900" />
|
||||
</label>
|
||||
<label>{{localize 'HBM.details.currentYear'}}
|
||||
<input type="number" name="system.details.currentYear" value="{{system.details.currentYear}}" min="1900" />
|
||||
</label>
|
||||
<label>{{localize 'HBM.details.discipline'}}
|
||||
<select name="system.details.discipline">
|
||||
<option value="">--</option>
|
||||
{{#each disciplines as |d|}}
|
||||
<option value="{{d.key}}" {{#if (eq ../system.details.discipline d.key)}}selected{{/if}}>{{d.label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Tab navigation -->
|
||||
<nav class="tabs" data-group="primary">
|
||||
{{#each tabs as |tab|}}
|
||||
<button type="button" class="tab-item {{tab.cssClass}}" data-tab="{{tab.id}}">
|
||||
{{localize tab.label}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- ═══ SCROLLABLE BODY ══════════════════════════════════════════════════ -->
|
||||
<div class="sheet-body">
|
||||
|
||||
<!-- ── STATS ──────────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'stats')}}active{{/if}}" data-tab="stats">
|
||||
|
||||
<div class="attributes-grid">
|
||||
{{#each attributes as |attr|}}
|
||||
<div class="attribute" data-attr="{{attr.key}}">
|
||||
<label>{{attr.label}}</label>
|
||||
{{#if (eq attr.key 'magic')}}
|
||||
<select name="system.attributes.magic.value">
|
||||
<option value="0" {{#if (eq attr.value 0)}}selected{{/if}}>0</option>
|
||||
<option value="1" {{#if (eq attr.value 1)}}selected{{/if}}>I</option>
|
||||
<option value="2" {{#if (eq attr.value 2)}}selected{{/if}}>II</option>
|
||||
<option value="3" {{#if (eq attr.value 3)}}selected{{/if}}>III</option>
|
||||
<option value="4" {{#if (eq attr.value 4)}}selected{{/if}}>IV</option>
|
||||
<option value="5" {{#if (eq attr.value 5)}}selected{{/if}}>V</option>
|
||||
<option value="6" {{#if (eq attr.value 6)}}selected{{/if}}>VI</option>
|
||||
<option value="7" {{#if (eq attr.value 7)}}selected{{/if}}>VII</option>
|
||||
<option value="8" {{#if (eq attr.value 8)}}selected{{/if}}>VIII</option>
|
||||
<option value="9" {{#if (eq attr.value 9)}}selected{{/if}}>IX</option>
|
||||
<option value="10" {{#if (eq attr.value 10)}}selected{{/if}}>X</option>
|
||||
</select>
|
||||
<span class="actual-magic-badge" title="{{localize 'HBM.attributes.actualMagicHint'}}" style="font-size: 0.75rem; margin-top: 0.15rem; color: #a03030; font-weight: bold;">
|
||||
({{localize 'HBM.attributes.actual'}}: {{attr.actualLabel}})
|
||||
</span>
|
||||
{{else}}
|
||||
<input type="number" name="system.attributes.{{attr.key}}.value" value="{{attr.value}}"
|
||||
min="1" max="8" />
|
||||
{{/if}}
|
||||
<button type="button" class="roll-btn" data-action="rollAttribute" data-attribute="{{attr.key}}"
|
||||
title="{{localize 'HBM.roll.roll'}}">⚂</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="resources-grid">
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.health'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.health.value" value="{{system.attributes.health.value}}" min="0" />
|
||||
<span class="sep">/</span>
|
||||
<input type="number" name="system.attributes.health.max" value="{{system.attributes.health.max}}" min="0" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.physicalArmor'}}</label>
|
||||
<span class="resource-value">
|
||||
<span class="static-value" title="{{localize 'HBM.resources.physicalArmor'}}">{{system.attributes.physicalArmor.value}}</span>
|
||||
</span>
|
||||
<small>{{localize 'HBM.resources.max'}}: {{system.attributes.physicalArmor.max}}</small>
|
||||
<div style="display:flex; align-items:center; gap:0.3rem; margin-top:0.15rem;">
|
||||
<small style="white-space:nowrap;">{{localize 'HBM.ui.physicalArmorBonus'}}:</small>
|
||||
<input type="number" name="system.attributes.physicalArmor.bonus"
|
||||
value="{{system.attributes.physicalArmor.bonus}}" min="0"
|
||||
style="width:2.5rem; text-align:center; font-size:0.8rem; border:none; border-bottom:1px solid var(--hbm-border); background:transparent;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.magicalShield'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.magicalShield.value"
|
||||
value="{{system.attributes.magicalShield.value}}" min="0" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.magicalArmor'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.magicalArmor.value"
|
||||
value="{{system.attributes.magicalArmor.value}}" min="0" />
|
||||
<span class="sep">/</span>
|
||||
<input type="number" name="system.attributes.magicalArmor.max" value="{{system.attributes.magicalArmor.max}}"
|
||||
min="0" />
|
||||
</span>
|
||||
<small>{{localize 'HBM.resources.runicCounter'}}: {{system.attributes.magicalArmor.runicCounter}}/5</small>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.mana'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.mana.value" value="{{system.attributes.mana.value}}" min="0" />
|
||||
<span class="sep">/</span>
|
||||
<input type="number" name="system.attributes.mana.max" value="{{system.attributes.mana.max}}" min="0" />
|
||||
</span>
|
||||
<small>{{localize 'HBM.resources.maxManaPerSpell'}}: {{system.attributes.mana.maxPerSpell}}</small>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.zeal'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.zeal.value" value="{{system.attributes.zeal.value}}" min="0" />
|
||||
<span class="sep">/</span>
|
||||
<input type="number" name="system.attributes.zeal.max" value="{{system.attributes.zeal.max}}" min="0" />
|
||||
</span>
|
||||
</div>
|
||||
{{#if hasBloodMagic}}
|
||||
<div class="resource blood-pool">
|
||||
<label>{{localize 'HBM.resources.blood'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.blood.value" value="{{system.attributes.blood.value}}" min="0" />
|
||||
<span class="sep">/</span>
|
||||
<input type="number" name="system.attributes.blood.max" value="{{system.attributes.blood.max}}" min="0" />
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.elixirTolerance'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.elixirTolerance" value="{{system.attributes.elixirTolerance}}" min="0" />
|
||||
</span>
|
||||
<small>cap: {{elixirCap}}</small>
|
||||
</div>
|
||||
{{#if (gt system.attributes.insanity 0)}}
|
||||
<div class="resource warn">
|
||||
<label>{{localize 'HBM.resources.insanity'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.insanity" value="{{system.attributes.insanity}}" min="0" />
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.initiative'}}</label>
|
||||
<span class="static-value">{{system.attributes.initiative}}</span>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.experience'}}</label>
|
||||
<input type="number" name="system.details.experience" value="{{system.details.experience}}" min="0" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.ui.attributePoints'}}</label>
|
||||
<span class="resource-value">
|
||||
<span class="static-value" title="{{localize 'HBM.ui.pointsAvailable'}}">{{system.advancement.attributePointsAvailable}}</span>
|
||||
<span class="sep" title="Modyfikator MG">+</span>
|
||||
<input type="number" name="system.advancement.bonusAttributePoints" value="{{system.advancement.bonusAttributePoints}}" min="0" style="width: 2.2rem; text-align: center; border: none; border-bottom: 1px solid var(--hbm-border); background: transparent;" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.ui.skillPoints'}}</label>
|
||||
<span class="resource-value">
|
||||
<span class="static-value" title="{{localize 'HBM.ui.pointsAvailable'}}">{{system.advancement.skillPointsAvailable}}</span>
|
||||
<span class="sep" title="Modyfikator MG">+</span>
|
||||
<input type="number" name="system.advancement.bonusSkillPoints" value="{{system.advancement.bonusSkillPoints}}" min="0" style="width: 2.2rem; text-align: center; border: none; border-bottom: 1px solid var(--hbm-border); background: transparent;" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.ui.freeTalents'}}</label>
|
||||
<span class="resource-value">
|
||||
<span class="static-value" title="{{localize 'HBM.ui.pointsAvailable'}}">{{system.advancement.freeTalentsAvailable}}</span>
|
||||
<span class="sep" title="Modyfikator MG">+</span>
|
||||
<input type="number" name="system.advancement.bonusFreeTalents" value="{{system.advancement.bonusFreeTalents}}" min="0" style="width: 2.2rem; text-align: center; border: none; border-bottom: 1px solid var(--hbm-border); background: transparent;" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── ACTIONS ────────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'actions')}}active{{/if}}" data-tab="actions">
|
||||
<div class="actions-panel">
|
||||
<h3>{{localize 'HBM.ui.actionsTitle'}}</h3>
|
||||
<div class="actions-buttons-grid">
|
||||
<button type="button" class="action-btn" data-action="rollInitiative">
|
||||
<span class="icon">⚡</span>
|
||||
<span class="label">{{localize 'HBM.resources.initiative'}}</span>
|
||||
</button>
|
||||
<button type="button" class="action-btn" data-action="applyDamage">
|
||||
<span class="icon">⚔</span>
|
||||
<span class="label">{{localize 'HBM.damage.apply'}}</span>
|
||||
</button>
|
||||
<button type="button" class="action-btn" data-action="takeBreather">
|
||||
<span class="icon">🌬️</span>
|
||||
<span class="label">{{localize 'HBM.rest.takeBreather'}}</span>
|
||||
</button>
|
||||
<button type="button" class="action-btn" data-action="shortRest">
|
||||
<span class="icon">☕</span>
|
||||
<span class="label">{{localize 'HBM.rest.shortRest'}}</span>
|
||||
</button>
|
||||
<button type="button" class="action-btn" data-action="longRest">
|
||||
<span class="icon">🌙</span>
|
||||
<span class="label">{{localize 'HBM.rest.longRest'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── SKILLS ─────────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'skills')}}active{{/if}}" data-tab="skills">
|
||||
<div class="skills-grid">
|
||||
{{#each skills as |sk|}}
|
||||
<div class="skill">
|
||||
<span class="skill-name">{{sk.label}}</span>
|
||||
<input type="number" name="system.skills.{{sk.key}}.value" value="{{sk.value}}" min="0" max="8" />
|
||||
<button type="button" class="roll-btn" data-action="rollSkill" data-skill="{{sk.key}}"
|
||||
title="{{localize 'HBM.roll.roll'}}">⚂</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── SPELLS ─────────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'spells')}}active{{/if}}" data-tab="spells">
|
||||
{{#if spells.length}}
|
||||
{{#each spellSchoolGroups as |group|}}
|
||||
<details class="spell-school-group" open>
|
||||
<summary><strong>{{group.label}}</strong> <span class="count">({{group.items.length}})</span></summary>
|
||||
<ul class="item-list spell-list">
|
||||
{{#each group.items as |spell|}}
|
||||
<li class="item-row spell-row" data-school="{{spell.system.school}}">
|
||||
<span class="item-name">{{spell.name}}
|
||||
{{#if spell.system.isSuperspell}}<span class="badge super" title="Superczar">★</span>{{/if}}
|
||||
{{#if spell.system.requiresGroupCast}}<span class="badge group" title="Czar grupowy">⛧</span>{{/if}}
|
||||
{{#if spell.system.nonCombatOnly}}<span class="badge noncombat" title="Tylko poza walką">☮</span>{{/if}}
|
||||
{{#if spell.system.requirements.race.length}}<span class="badge race-lock" title="Wymagana rasa">🔒</span>{{/if}}
|
||||
</span>
|
||||
<span class="item-meta">
|
||||
K{{spell.system.circle}}
|
||||
· {{localize (concat 'HBM.spell.castingMode.' spell.system.castingMode)}}
|
||||
· {{localize 'HBM.resources.mana'}}: {{spell.system.manaCost}}
|
||||
{{#if (eq spell.system.castingMode 'sacred')}}{{#if spell.system.deity}} · {{spell.system.deity}}{{/if}}{{/if}}
|
||||
{{#if (eq spell.system.castingMode 'blood')}} · krew: {{spell.system.bloodCost}}{{/if}}
|
||||
{{#if spell.system.sourceBook}} · <span class="source-pip">{{spell.system.sourceBook}}</span>{{/if}}
|
||||
</span>
|
||||
<div class="item-controls">
|
||||
<button type="button" class="cast-btn" data-action="castSpell" data-item-id="{{spell.id}}"
|
||||
title="{{localize 'HBM.spell.cast'}}">✦</button>
|
||||
<button type="button" class="edit-btn" data-action="editItem" data-item-id="{{spell.id}}"
|
||||
title="{{localize 'HBM.ui.edit'}}">✎</button>
|
||||
<button type="button" class="delete-btn" data-action="deleteItem" data-item-id="{{spell.id}}"
|
||||
title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</details>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<p class="empty-drop">{{localize 'HBM.ui.dropItems'}}</p>
|
||||
{{/if}}
|
||||
</section>
|
||||
|
||||
<!-- ── TALENTS ────────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'talents')}}active{{/if}}" data-tab="talents">
|
||||
<p class="empty-drop" style="font-size:0.8rem; color:#888; margin-bottom:10px;">{{localize 'HBM.ui.dropTalentsHint'}}</p>
|
||||
{{#if talents.length}}
|
||||
<ul class="item-list">
|
||||
{{#each talents as |item|}}
|
||||
<li class="item-row">
|
||||
<span class="item-name">{{item.name}}</span>
|
||||
<span class="item-meta">{{{item.system.description}}}</span>
|
||||
<div class="item-controls">
|
||||
<button type="button" class="edit-btn" data-action="editItem" data-item-id="{{item.id}}"
|
||||
title="{{localize 'HBM.ui.edit'}}">✎</button>
|
||||
<button type="button" class="delete-btn" data-action="deleteItem" data-item-id="{{item.id}}"
|
||||
title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-drop">{{localize 'HBM.ui.dropItems'}}</p>
|
||||
{{/if}}
|
||||
</section>
|
||||
|
||||
<!-- ── INVENTORY ──────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'inventory')}}active{{/if}}" data-tab="inventory">
|
||||
<div class="money-panel" style="display: flex; gap: 15px; align-items: center; background: var(--hbm-card-bg); border: 1px solid var(--hbm-border); border-radius: var(--hbm-radius); padding: 0.75rem; margin-bottom: 15px;">
|
||||
<div style="flex: 1; display: flex; align-items: center; gap: 10px;">
|
||||
<span class="icon" style="font-size: 1.5rem;">🪙</span>
|
||||
<strong>{{localize 'HBM.ui.money'}}:</strong>
|
||||
<input type="number" name="system.details.money" value="{{system.details.money}}" min="0" style="width: 8rem; font-size: 1.1rem; text-align: center; border: none; border-bottom: 1px solid var(--hbm-border); background: transparent; color: var(--hbm-fg);" />
|
||||
<span>PLN</span>
|
||||
</div>
|
||||
<button type="button" data-action="recalculateMoney" style="padding: 4px 10px; font-weight: bold; background: var(--hbm-accent); color: #fff; border: none; border-radius: var(--hbm-radius); cursor: pointer;">
|
||||
{{localize 'HBM.ui.recalculate'}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{#if weapons.length}}
|
||||
<div class="inventory-section">
|
||||
<h3>{{localize 'HBM.gear.weapon'}}</h3>
|
||||
<table class="inventory-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left;">{{localize 'HBM.ui.name'}}</th>
|
||||
<th>{{localize 'HBM.gear.damage'}}</th>
|
||||
<th>{{localize 'HBM.gear.weight'}}</th>
|
||||
<th>{{localize 'HBM.gear.quantity'}}</th>
|
||||
<th>{{localize 'HBM.gear.equipped'}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each weapons as |item|}}
|
||||
<tr class="item-tr" data-item-id="{{item.id}}">
|
||||
<td><strong>{{item.name}}</strong></td>
|
||||
<td style="text-align: center;">{{item.system.weapon.damage}} ({{localize (concat 'HBM.damageType.' item.system.weapon.damageType)}})</td>
|
||||
<td style="text-align: center;">{{item.system.weight}}</td>
|
||||
<td style="text-align: center;">{{item.system.quantity}}</td>
|
||||
<td style="text-align: center;">
|
||||
<input type="checkbox" data-action="toggleEquipped" data-item-id="{{item.id}}" {{#if item.system.equipped}}checked{{/if}} style="cursor: pointer;" />
|
||||
</td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button type="button" class="edit-btn" data-action="editItem" data-item-id="{{item.id}}" title="{{localize 'HBM.ui.edit'}}">✎</button>
|
||||
<button type="button" class="delete-btn" data-action="deleteItem" data-item-id="{{item.id}}" title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if armors.length}}
|
||||
<div class="inventory-section" style="margin-top: 15px;">
|
||||
<h3>{{localize 'HBM.gear.armor'}}</h3>
|
||||
<table class="inventory-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left;">{{localize 'HBM.ui.name'}}</th>
|
||||
<th>{{localize 'HBM.gear.damageReduction'}}</th>
|
||||
<th>{{localize 'HBM.gear.condition'}}</th>
|
||||
<th>{{localize 'HBM.gear.weight'}}</th>
|
||||
<th>{{localize 'HBM.gear.quantity'}}</th>
|
||||
<th>{{localize 'HBM.gear.equipped'}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each armors as |item|}}
|
||||
<tr class="item-tr" data-item-id="{{item.id}}">
|
||||
<td><strong>{{item.name}}</strong></td>
|
||||
<td style="text-align: center;">{{item.system.armor.damageReduction}} DR</td>
|
||||
<td style="text-align: center;">{{item.system.armor.condition}} / {{item.system.armor.conditionMax}}</td>
|
||||
<td style="text-align: center;">{{item.system.weight}}</td>
|
||||
<td style="text-align: center;">{{item.system.quantity}}</td>
|
||||
<td style="text-align: center;">
|
||||
<input type="checkbox" data-action="toggleEquipped" data-item-id="{{item.id}}" {{#if item.system.equipped}}checked{{/if}} style="cursor: pointer;" />
|
||||
</td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button type="button" class="edit-btn" data-action="editItem" data-item-id="{{item.id}}" title="{{localize 'HBM.ui.edit'}}">✎</button>
|
||||
<button type="button" class="delete-btn" data-action="deleteItem" data-item-id="{{item.id}}" title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if equipment.length}}
|
||||
<div class="inventory-section" style="margin-top: 15px;">
|
||||
<h3>{{localize 'HBM.gear.equipment'}}</h3>
|
||||
<table class="inventory-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left;">{{localize 'HBM.ui.name'}}</th>
|
||||
<th>{{localize 'HBM.gear.weight'}}</th>
|
||||
<th>{{localize 'HBM.gear.quantity'}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each equipment as |item|}}
|
||||
<tr class="item-tr" data-item-id="{{item.id}}">
|
||||
<td>{{item.name}}</td>
|
||||
<td style="text-align: center;">{{item.system.weight}}</td>
|
||||
<td style="text-align: center;">{{item.system.quantity}}</td>
|
||||
<td style="text-align: right; white-space: nowrap;">
|
||||
<button type="button" class="edit-btn" data-action="editItem" data-item-id="{{item.id}}" title="{{localize 'HBM.ui.edit'}}">✎</button>
|
||||
<button type="button" class="delete-btn" data-action="deleteItem" data-item-id="{{item.id}}" title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq gear.length 0)}}
|
||||
<p class="empty-drop">{{localize 'HBM.ui.dropItems'}}</p>
|
||||
{{/if}}
|
||||
|
||||
<!-- Custom equipment rows -->
|
||||
<div class="custom-equipment-section" style="margin-top: 20px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
|
||||
<strong>{{localize 'HBM.ui.customEquipment'}}</strong>
|
||||
<button type="button" data-action="addArrayEntry"
|
||||
data-path="system.details.customItems"
|
||||
data-template="{"name":"","qty":1,"note":""}"
|
||||
style="padding:2px 8px; font-size:0.8rem; background:var(--hbm-success); color:#fff; border:none; border-radius:var(--hbm-radius); cursor:pointer;">
|
||||
+ {{localize 'HBM.ui.add'}}
|
||||
</button>
|
||||
</div>
|
||||
{{#if system.details.customItems.length}}
|
||||
<table class="inventory-table" style="font-size:0.85rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left;">{{localize 'HBM.ui.name'}}</th>
|
||||
<th style="width:3rem;">{{localize 'HBM.gear.quantity'}}</th>
|
||||
<th>{{localize 'HBM.ui.notes'}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each system.details.customItems as |row i|}}
|
||||
<tr>
|
||||
<td><input type="text" name="system.details.customItems.{{i}}.name" value="{{row.name}}"
|
||||
style="width:100%; border:none; border-bottom:1px solid var(--hbm-border); background:transparent; color:var(--hbm-fg); font-size:0.85rem;" /></td>
|
||||
<td><input type="number" name="system.details.customItems.{{i}}.qty" value="{{row.qty}}" min="0"
|
||||
style="width:3rem; text-align:center; border:none; border-bottom:1px solid var(--hbm-border); background:transparent; color:var(--hbm-fg); font-size:0.85rem;" /></td>
|
||||
<td><input type="text" name="system.details.customItems.{{i}}.note" value="{{row.note}}"
|
||||
style="width:100%; border:none; border-bottom:1px solid var(--hbm-border); background:transparent; color:var(--hbm-fg); font-size:0.8rem; color:#777;" /></td>
|
||||
<td style="text-align:right;">
|
||||
<button type="button" class="delete-btn" data-action="removeArrayEntry"
|
||||
data-path="system.details.customItems" data-index="{{i}}"
|
||||
title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p style="font-size:0.8rem; color:#888; font-style:italic;">{{localize 'HBM.ui.dropItems'}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── EFFECTS ────────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'effects')}}active{{/if}}" data-tab="effects">
|
||||
{{> 'systems/hbm-rpg-v3/templates/actor/_actor-effects.hbs'}}
|
||||
</section>
|
||||
|
||||
<!-- ── BIOGRAPHY ──────────────────────────────────────────────────────── -->
|
||||
<section class="tab-panel {{#if (eq tabGroups.primary 'biography')}}active{{/if}}" data-tab="biography">
|
||||
<div class="bio-grid" style="display: flex; gap: 20px; flex-direction: column;">
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div class="bio-image-container" style="display: flex; flex-direction: column; align-items: center; gap: 10px; flex-shrink: 0;">
|
||||
<img class="profile-img" src="{{document.img}}" data-action="editImage" title="{{document.name}}" height="150" width="150" style="border: 2px solid #555; border-radius: 4px; cursor: pointer; background: #222;" />
|
||||
<small style="color: #777;">{{localize 'HBM.ui.changeImageHint'}}</small>
|
||||
</div>
|
||||
<div class="bio-section" style="flex: 1; display: flex; flex-direction: column; gap: 10px;">
|
||||
<div style="overflow-x: auto; margin-top: 5px;">
|
||||
<table class="schedule-table">
|
||||
<thead>
|
||||
<tr style="background: rgba(0, 0, 0, 0.15); border-bottom: 2px solid var(--hbm-border);">
|
||||
<th style="width: 3%;">#</th>
|
||||
<th style="width: 12%;">{{localize 'HBM.schedule.hours'}}</th>
|
||||
<th style="width: 14%;">{{localize 'HBM.schedule.monday'}}</th>
|
||||
<th style="width: 14%;">{{localize 'HBM.schedule.tuesday'}}</th>
|
||||
<th style="width: 14%;">{{localize 'HBM.schedule.wednesday'}}</th>
|
||||
<th style="width: 14%;">{{localize 'HBM.schedule.thursday'}}</th>
|
||||
<th style="width: 14%;">{{localize 'HBM.schedule.friday'}}</th>
|
||||
<th style="width: 13%;">{{localize 'HBM.schedule.saturday'}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Row 1 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">1</td>
|
||||
<td><input type="text" name="system.details.schedule.row1.hours" value="{{system.details.schedule.row1.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row1.mon" value="{{system.details.schedule.row1.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row1.tue" value="{{system.details.schedule.row1.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row1.wed" value="{{system.details.schedule.row1.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row1.thu" value="{{system.details.schedule.row1.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row1.fri" value="{{system.details.schedule.row1.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row1.sat" value="{{system.details.schedule.row1.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 2 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">2</td>
|
||||
<td><input type="text" name="system.details.schedule.row2.hours" value="{{system.details.schedule.row2.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row2.mon" value="{{system.details.schedule.row2.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row2.tue" value="{{system.details.schedule.row2.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row2.wed" value="{{system.details.schedule.row2.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row2.thu" value="{{system.details.schedule.row2.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row2.fri" value="{{system.details.schedule.row2.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row2.sat" value="{{system.details.schedule.row2.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 3 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">3</td>
|
||||
<td><input type="text" name="system.details.schedule.row3.hours" value="{{system.details.schedule.row3.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row3.mon" value="{{system.details.schedule.row3.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row3.tue" value="{{system.details.schedule.row3.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row3.wed" value="{{system.details.schedule.row3.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row3.thu" value="{{system.details.schedule.row3.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row3.fri" value="{{system.details.schedule.row3.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row3.sat" value="{{system.details.schedule.row3.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Break -->
|
||||
<tr style="background: rgba(0, 0, 0, 0.08); font-weight: bold;">
|
||||
<td colspan="8" style="padding: 5px; text-align: center; letter-spacing: 0.1em; text-transform: uppercase; font-size: 0.75rem;">{{localize 'HBM.schedule.lunchBreak'}}</td>
|
||||
</tr>
|
||||
<!-- Row 4 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">4</td>
|
||||
<td><input type="text" name="system.details.schedule.row4.hours" value="{{system.details.schedule.row4.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row4.mon" value="{{system.details.schedule.row4.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row4.tue" value="{{system.details.schedule.row4.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row4.wed" value="{{system.details.schedule.row4.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row4.thu" value="{{system.details.schedule.row4.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row4.fri" value="{{system.details.schedule.row4.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row4.sat" value="{{system.details.schedule.row4.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 5 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">5</td>
|
||||
<td><input type="text" name="system.details.schedule.row5.hours" value="{{system.details.schedule.row5.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row5.mon" value="{{system.details.schedule.row5.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row5.tue" value="{{system.details.schedule.row5.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row5.wed" value="{{system.details.schedule.row5.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row5.thu" value="{{system.details.schedule.row5.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row5.fri" value="{{system.details.schedule.row5.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row5.sat" value="{{system.details.schedule.row5.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 6 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">6</td>
|
||||
<td><input type="text" name="system.details.schedule.row6.hours" value="{{system.details.schedule.row6.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row6.mon" value="{{system.details.schedule.row6.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row6.tue" value="{{system.details.schedule.row6.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row6.wed" value="{{system.details.schedule.row6.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row6.thu" value="{{system.details.schedule.row6.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row6.fri" value="{{system.details.schedule.row6.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row6.sat" value="{{system.details.schedule.row6.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 7 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">7</td>
|
||||
<td><input type="text" name="system.details.schedule.row7.hours" value="{{system.details.schedule.row7.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row7.mon" value="{{system.details.schedule.row7.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row7.tue" value="{{system.details.schedule.row7.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row7.wed" value="{{system.details.schedule.row7.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row7.thu" value="{{system.details.schedule.row7.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row7.fri" value="{{system.details.schedule.row7.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row7.sat" value="{{system.details.schedule.row7.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Break -->
|
||||
<tr style="background: rgba(0, 0, 0, 0.08); font-weight: bold;">
|
||||
<td colspan="8" style="padding: 5px; text-align: center; letter-spacing: 0.1em; text-transform: uppercase; font-size: 0.75rem;">{{localize 'HBM.schedule.dinnerBreak'}}</td>
|
||||
</tr>
|
||||
<!-- Row 8 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">8</td>
|
||||
<td><input type="text" name="system.details.schedule.row8.hours" value="{{system.details.schedule.row8.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row8.mon" value="{{system.details.schedule.row8.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row8.tue" value="{{system.details.schedule.row8.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row8.wed" value="{{system.details.schedule.row8.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row8.thu" value="{{system.details.schedule.row8.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row8.fri" value="{{system.details.schedule.row8.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row8.sat" value="{{system.details.schedule.row8.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 9 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">9</td>
|
||||
<td><input type="text" name="system.details.schedule.row9.hours" value="{{system.details.schedule.row9.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row9.mon" value="{{system.details.schedule.row9.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row9.tue" value="{{system.details.schedule.row9.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row9.wed" value="{{system.details.schedule.row9.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row9.thu" value="{{system.details.schedule.row9.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row9.fri" value="{{system.details.schedule.row9.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row9.sat" value="{{system.details.schedule.row9.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Break -->
|
||||
<tr style="background: rgba(0, 0, 0, 0.08); font-weight: bold;">
|
||||
<td colspan="8" style="padding: 5px; text-align: center; letter-spacing: 0.1em; text-transform: uppercase; font-size: 0.75rem;">{{localize 'HBM.schedule.supperBreak'}}</td>
|
||||
</tr>
|
||||
<!-- Row 10 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">10</td>
|
||||
<td><input type="text" name="system.details.schedule.row10.hours" value="{{system.details.schedule.row10.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row10.mon" value="{{system.details.schedule.row10.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row10.tue" value="{{system.details.schedule.row10.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row10.wed" value="{{system.details.schedule.row10.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row10.thu" value="{{system.details.schedule.row10.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row10.fri" value="{{system.details.schedule.row10.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row10.sat" value="{{system.details.schedule.row10.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 11 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">11</td>
|
||||
<td><input type="text" name="system.details.schedule.row11.hours" value="{{system.details.schedule.row11.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row11.mon" value="{{system.details.schedule.row11.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row11.tue" value="{{system.details.schedule.row11.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row11.wed" value="{{system.details.schedule.row11.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row11.thu" value="{{system.details.schedule.row11.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row11.fri" value="{{system.details.schedule.row11.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row11.sat" value="{{system.details.schedule.row11.sat}}" /></td>
|
||||
</tr>
|
||||
<!-- Row 12 -->
|
||||
<tr>
|
||||
<td style="font-weight: bold;">12</td>
|
||||
<td><input type="text" name="system.details.schedule.row12.hours" value="{{system.details.schedule.row12.hours}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row12.mon" value="{{system.details.schedule.row12.mon}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row12.tue" value="{{system.details.schedule.row12.tue}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row12.wed" value="{{system.details.schedule.row12.wed}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row12.thu" value="{{system.details.schedule.row12.thu}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row12.fri" value="{{system.details.schedule.row12.fri}}" /></td>
|
||||
<td><input type="text" name="system.details.schedule.row12.sat" value="{{system.details.schedule.row12.sat}}" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<label><strong>{{localize 'HBM.details.personalDetails'}}</strong></label>
|
||||
<textarea name="system.details.personalDetails" rows="4" style="width: 100%; resize: vertical; border: 1px solid var(--hbm-border); border-radius: var(--hbm-radius); padding: 5px; background: var(--hbm-card-bg); color: var(--hbm-fg);">{{system.details.personalDetails}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bio-section">
|
||||
<label><strong>{{localize 'HBM.ui.biography'}}</strong></label>
|
||||
<textarea name="system.details.biography" rows="8" style="width: 100%; resize: vertical; border: 1px solid var(--hbm-border); border-radius: var(--hbm-radius); padding: 5px; background: var(--hbm-card-bg); color: var(--hbm-fg);">{{system.details.biography}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div><!-- .sheet-body -->
|
||||
</div>
|
||||
@@ -0,0 +1,197 @@
|
||||
<div class="sheet-wrapper">
|
||||
<header class="sheet-header" style="display: flex; gap: 15px; align-items: flex-start; padding-bottom: 10px;">
|
||||
<div class="bio-image-container" style="display: flex; flex-direction: column; align-items: center; gap: 5px;">
|
||||
<img class="profile-img" src="{{document.img}}" data-action="editImage" title="{{document.name}}" height="80" width="80" style="border: 2px solid #555; border-radius: 4px; cursor: pointer; background: #222;" />
|
||||
</div>
|
||||
<div style="flex: 1; display: flex; flex-direction: column; gap: 5px;">
|
||||
<input type="text" name="name" value="{{document.name}}" placeholder="{{localize 'HBM.ui.name'}}" style="font-size: 1.4rem; font-weight: bold; background: transparent; border: none; border-bottom: 1px solid rgba(255,255,255,0.25); color: #f0e6d0; width: 100%;" />
|
||||
<div class="meta" style="margin-top: 0px; margin-bottom: 0px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;">
|
||||
<label>{{localize 'HBM.npc.type'}}: <input type="text" name="system.details.type" value="{{system.details.type}}" style="width: 7rem;"/></label>
|
||||
<label>{{localize 'HBM.npc.size'}}: <input type="text" name="system.details.size" value="{{system.details.size}}" style="width: 5rem;"/></label>
|
||||
<label>{{localize 'HBM.npc.xp'}}: <input type="number" name="system.details.xp" value="{{system.details.xp}}" min="0" style="width: 4rem;"/></label>
|
||||
<label>{{localize 'HBM.details.startingYear'}}: <input type="number" name="system.details.startingYear" value="{{system.details.startingYear}}" min="1900" style="width: 4rem;"/></label>
|
||||
<label>{{localize 'HBM.details.currentYear'}}: <input type="number" name="system.details.currentYear" value="{{system.details.currentYear}}" min="1900" style="width: 4rem;"/></label>
|
||||
<button type="button" class="header-btn" data-action="rollInitiative" title="{{localize 'HBM.resources.initiative'}}" style="margin-left: auto;">⚡ {{localize 'HBM.resources.initiative'}}</button>
|
||||
<button type="button" class="header-btn" data-action="applyDamage" title="{{localize 'HBM.damage.title'}}" style="margin-left: 10px;">⚔ {{localize
|
||||
'HBM.damage.apply'}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sheet-body">
|
||||
<section class="attributes-grid">
|
||||
{{#each attributes as |attr|}}
|
||||
<div class="attribute">
|
||||
<label>{{attr.label}}</label>
|
||||
{{#if (eq attr.key 'magic')}}
|
||||
<select name="system.attributes.magic.value" style="width: 4rem; text-align: center; border: none; border-bottom: 1px solid var(--hbm-border); background: transparent; color: var(--hbm-fg);">
|
||||
<option value="0" {{#if (eq attr.value 0)}}selected{{/if}}>0</option>
|
||||
<option value="1" {{#if (eq attr.value 1)}}selected{{/if}}>I</option>
|
||||
<option value="2" {{#if (eq attr.value 2)}}selected{{/if}}>II</option>
|
||||
<option value="3" {{#if (eq attr.value 3)}}selected{{/if}}>III</option>
|
||||
<option value="4" {{#if (eq attr.value 4)}}selected{{/if}}>IV</option>
|
||||
<option value="5" {{#if (eq attr.value 5)}}selected{{/if}}>V</option>
|
||||
<option value="6" {{#if (eq attr.value 6)}}selected{{/if}}>VI</option>
|
||||
<option value="7" {{#if (eq attr.value 7)}}selected{{/if}}>VII</option>
|
||||
<option value="8" {{#if (eq attr.value 8)}}selected{{/if}}>VIII</option>
|
||||
<option value="9" {{#if (eq attr.value 9)}}selected{{/if}}>IX</option>
|
||||
<option value="10" {{#if (eq attr.value 10)}}selected{{/if}}>X</option>
|
||||
</select>
|
||||
<span class="actual-magic-badge" title="{{localize 'HBM.attributes.actualMagicHint'}}" style="font-size: 0.75rem; margin-top: 0.15rem; color: #a03030; font-weight: bold;">
|
||||
({{localize 'HBM.attributes.actual'}}: {{attr.actualLabel}})
|
||||
</span>
|
||||
{{else}}
|
||||
<input type="number" name="system.attributes.{{attr.key}}.value" value="{{attr.value}}" min="0" max="20"/>
|
||||
{{/if}}
|
||||
<button type="button" class="roll-button" data-action="rollAttribute" data-attribute="{{attr.key}}"
|
||||
title="{{localize 'HBM.roll.roll'}}">⚂</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</section>
|
||||
|
||||
<section class="resources-grid">
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.health'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.health.value" value="{{system.attributes.health.value}}" min="0"/>
|
||||
<span class="sep">/</span>
|
||||
<input type="number" name="system.attributes.health.max" value="{{system.attributes.health.max}}" min="1"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.physicalArmor'}}</label>
|
||||
<input type="number" name="system.attributes.physicalArmor.value" value="{{system.attributes.physicalArmor.value}}"
|
||||
min="0" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.magicalShield'}}</label>
|
||||
<input type="number" name="system.attributes.magicalShield.value" value="{{system.attributes.magicalShield.value}}"
|
||||
min="0" />
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.resources.magicalArmor'}}</label>
|
||||
<span class="resource-value">
|
||||
<input type="number" name="system.attributes.magicalArmor.value" value="{{system.attributes.magicalArmor.value}}"
|
||||
min="0" />
|
||||
<span class="sep">/</span>
|
||||
<input type="number" name="system.attributes.magicalArmor.max" value="{{system.attributes.magicalArmor.max}}" min="0" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="resource">
|
||||
<label>{{localize 'HBM.npc.speed'}}</label>
|
||||
<input type="text" name="system.attributes.speed" value="{{system.attributes.speed}}"/>
|
||||
</div>
|
||||
<div class="resource" style="display: flex; flex-direction: row; gap: 8px; align-items: center; justify-content: center;">
|
||||
<label style="font-size: 0.75rem; text-transform: uppercase;">{{localize 'HBM.ui.money'}}</label>
|
||||
<input type="number" name="system.details.money" value="{{system.details.money}}" min="0" style="width: 3.5rem; text-align: center; border: none; border-bottom: 1px solid var(--hbm-border); background: transparent; color: var(--hbm-fg);"/>
|
||||
<span>PLN</span>
|
||||
<button type="button" data-action="recalculateMoney" style="padding: 1px 5px; font-size: 0.75rem; background: var(--hbm-accent); color: #fff; border: none; border-radius: var(--hbm-radius); cursor: pointer;" title="{{localize 'HBM.ui.recalculate'}}">🪙</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="npc-meta-section">
|
||||
<label>{{localize 'HBM.npc.senses'}}: <input type="text" name="system.details.senses" value="{{system.details.senses}}"/></label>
|
||||
<label>{{localize 'HBM.npc.languages'}}: <input type="text" name="system.details.languages" value="{{system.details.languages}}"/></label>
|
||||
</section>
|
||||
|
||||
<section class="combat-lists">
|
||||
<fieldset>
|
||||
<legend>{{localize 'HBM.npc.attacks'}}
|
||||
<button type="button" data-action="addArrayEntry" data-path="system.combat.attacks"
|
||||
data-template='{"name":"","description":"","damage":"","damageType":"physical","bonus":0}'
|
||||
title="{{localize 'HBM.ui.add'}}">+</button>
|
||||
</legend>
|
||||
{{#each system.combat.attacks as |a idx|}}
|
||||
<div class="row">
|
||||
<input type="text" name="system.combat.attacks.{{idx}}.name" value="{{a.name}}"
|
||||
placeholder="{{localize 'HBM.ui.name'}}" />
|
||||
<input type="number" name="system.combat.attacks.{{idx}}.bonus" value="{{a.bonus}}" min="0"
|
||||
title="{{localize 'HBM.roll.pool'}}" />
|
||||
<input type="text" name="system.combat.attacks.{{idx}}.damage" value="{{a.damage}}"
|
||||
placeholder="{{localize 'HBM.gear.damage'}}" />
|
||||
<input type="text" name="system.combat.attacks.{{idx}}.description" value="{{a.description}}"
|
||||
placeholder="{{localize 'HBM.gear.description'}}" />
|
||||
<button type="button" data-action="rollNpcAttack" data-index="{{idx}}"
|
||||
title="{{localize 'HBM.roll.roll'}}">⚂</button>
|
||||
<button type="button" data-action="removeArrayEntry" data-path="system.combat.attacks" data-index="{{idx}}"
|
||||
title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{{localize 'HBM.npc.specialAbilities'}}
|
||||
<button type="button" data-action="addArrayEntry" data-path="system.combat.specialAbilities"
|
||||
data-template='{"name":"","description":""}' title="{{localize 'HBM.ui.add'}}">+</button>
|
||||
</legend>
|
||||
{{#each system.combat.specialAbilities as |a idx|}}
|
||||
<div class="row">
|
||||
<input type="text" name="system.combat.specialAbilities.{{idx}}.name" value="{{a.name}}" />
|
||||
<input type="text" name="system.combat.specialAbilities.{{idx}}.description" value="{{a.description}}" />
|
||||
<button type="button" data-action="removeArrayEntry" data-path="system.combat.specialAbilities"
|
||||
data-index="{{idx}}">✕</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{{localize 'HBM.npc.reactions'}}
|
||||
<button type="button" data-action="addArrayEntry" data-path="system.combat.reactions"
|
||||
data-template='{"name":"","description":""}' title="{{localize 'HBM.ui.add'}}">+</button>
|
||||
</legend>
|
||||
{{#each system.combat.reactions as |a idx|}}
|
||||
<div class="row">
|
||||
<input type="text" name="system.combat.reactions.{{idx}}.name" value="{{a.name}}" />
|
||||
<input type="text" name="system.combat.reactions.{{idx}}.description" value="{{a.description}}" />
|
||||
<button type="button" data-action="removeArrayEntry" data-path="system.combat.reactions"
|
||||
data-index="{{idx}}">✕</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{{localize 'HBM.npc.legendaryActions'}}
|
||||
<button type="button" data-action="addArrayEntry" data-path="system.combat.legendaryActions"
|
||||
data-template='{"name":"","description":""}' title="{{localize 'HBM.ui.add'}}">+</button>
|
||||
</legend>
|
||||
{{#each system.combat.legendaryActions as |a idx|}}
|
||||
<div class="row">
|
||||
<input type="text" name="system.combat.legendaryActions.{{idx}}.name" value="{{a.name}}" />
|
||||
<input type="text" name="system.combat.legendaryActions.{{idx}}.description" value="{{a.description}}" />
|
||||
<button type="button" data-action="removeArrayEntry" data-path="system.combat.legendaryActions"
|
||||
data-index="{{idx}}">✕</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="lore">
|
||||
<label>{{localize 'HBM.gear.description'}}</label>
|
||||
<textarea name="system.lore.description" rows="6">{{system.lore.description}}</textarea>
|
||||
</section>
|
||||
{{#if spells.length}}
|
||||
<section class="spells-list">
|
||||
<fieldset>
|
||||
<legend>{{localize 'HBM.ui.tabs.spells'}}</legend>
|
||||
{{#each spells as |sp|}}
|
||||
<div class="row spell-row">
|
||||
<span class="badge mode mode-{{sp.system.castingMode}}"
|
||||
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}}
|
||||
<small>K{{sp.system.circle}}</small>{{/if}}</span>
|
||||
<button type="button" data-action="castSpell" data-item-id="{{sp.id}}"
|
||||
title="{{localize 'HBM.spell.cast'}}">✦ {{localize 'HBM.spell.cast'}}</button>
|
||||
<button type="button" data-action="editItem" data-item-id="{{sp.id}}"
|
||||
title="{{localize 'HBM.ui.edit'}}">✎</button>
|
||||
<button type="button" data-action="deleteItem" data-item-id="{{sp.id}}"
|
||||
title="{{localize 'HBM.ui.delete'}}">✕</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</fieldset>
|
||||
</section>
|
||||
{{/if}}
|
||||
<section class="actor-effects-section">
|
||||
{{> 'systems/hbm-rpg-v3/templates/actor/_actor-effects.hbs'}}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
<div class="hbm spell-cast-card" data-spell-name="{{spell.name}}">
|
||||
<header class="hbm-card-header">
|
||||
<h3>{{spell.name}}</h3>
|
||||
<div class="hbm-card-meta">
|
||||
{{#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 circle}}<span class="badge circle">{{localize 'HBM.spell.circle'}} {{circle}}</span>{{/if}}
|
||||
{{#if outcome.success}}
|
||||
<span class="badge result success">✓ {{localize 'HBM.spellCast.success'}}</span>
|
||||
{{else}}
|
||||
<span class="badge result failure">✗ {{localize 'HBM.spellCast.failure'}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{#if costs.length}}
|
||||
<ul class="hbm-card-costs">
|
||||
{{#each costs as |c|}}
|
||||
<li>{{c.label}}: <strong>{{c.amount}}</strong></li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{#if outcome.success}}
|
||||
{{#if damage}}
|
||||
<section class="hbm-card-damage">
|
||||
<h4>{{localize 'HBM.spellCast.damage'}}</h4>
|
||||
<div class="damage-line">
|
||||
<strong>{{damage.total}}</strong>
|
||||
{{#if damage.type}}<span class="badge damage-type">{{localize (concat 'HBM.damageType.' damage.type)}}</span>{{/if}}
|
||||
{{#if damage.ignoresArmor}}<span class="badge ignores-armor">{{localize 'HBM.spell.ignoresArmor'}}</span>{{/if}}
|
||||
</div>
|
||||
<div class="damage-formula"><code>{{damage.formula}}</code></div>
|
||||
{{#if damage.targets}}
|
||||
<div class="hbm-card-targets">
|
||||
{{#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}}">
|
||||
⚔ {{t.name}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="hbm-card-hint">{{localize 'HBM.spellCast.targetSomeone'}}</p>
|
||||
{{/if}}
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{#if save}}
|
||||
<section class="hbm-card-save">
|
||||
<strong>{{localize 'HBM.spellCast.saveTarget'}}:</strong>
|
||||
{{localize (concat 'HBM.attributes.' save.attribute)}}
|
||||
{{#if save.skill}}+ {{localize (concat 'HBM.skills.' save.skill)}}{{/if}}
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{#if statusEffects.length}}
|
||||
<section class="hbm-card-status">
|
||||
<h4>{{localize 'HBM.spellCast.statusEffects'}}</h4>
|
||||
<div class="status-buttons">
|
||||
{{#each statusEffects as |s|}}
|
||||
<button type="button" class="hbm-action" data-action="hbm-apply-status" data-effect-id="{{s.id}}">
|
||||
{{s.label}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
<p class="hbm-card-hint">{{localize 'HBM.spellCast.applyToTargets'}}</p>
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{#if triggers.length}}
|
||||
<section class="hbm-card-triggers">
|
||||
<h4>{{localize 'HBM.spellCast.triggers'}}</h4>
|
||||
<ul>
|
||||
{{#each triggers as |t|}}
|
||||
<li><span class="badge trigger-event">{{localize (concat 'HBM.triggerEvent.' t.event)}}</span> — {{t.effect}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</section>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if description}}
|
||||
<details class="hbm-card-description">
|
||||
<summary>{{localize 'HBM.spell.description'}}</summary>
|
||||
<div>{{{description}}}</div>
|
||||
</details>
|
||||
{{/if}}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user